From 3b6daa99c000c209b1cb6c2759747c7594c2d388 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 10 May 2021 11:50:12 +0000 Subject: [PATCH 01/20] Bump hosted-git-info from 2.8.8 to 2.8.9 in /back Bumps [hosted-git-info](https://github.com/npm/hosted-git-info) from 2.8.8 to 2.8.9. - [Release notes](https://github.com/npm/hosted-git-info/releases) - [Changelog](https://github.com/npm/hosted-git-info/blob/v2.8.9/CHANGELOG.md) - [Commits](https://github.com/npm/hosted-git-info/compare/v2.8.8...v2.8.9) Signed-off-by: dependabot[bot] --- back/yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/back/yarn.lock b/back/yarn.lock index 43f58988..99b5df61 100644 --- a/back/yarn.lock +++ b/back/yarn.lock @@ -1251,9 +1251,9 @@ has-values@^1.0.0: kind-of "^4.0.0" hosted-git-info@^2.1.4: - version "2.8.8" - resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-2.8.8.tgz#7539bd4bc1e0e0a895815a2e0262420b12858488" - integrity sha512-f/wzC2QaWBs7t9IYqB4T3sR1xviIViXJRJTWBlx2Gf3g0Xi5vI7Yy4koXQ1c9OYDGHN9sBy1DQ2AB8fqZBWhUg== + version "2.8.9" + resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-2.8.9.tgz#dffc0bf9a21c02209090f2aa69429e1414daf3f9" + integrity sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw== http-errors@1.7.2: version "1.7.2" From 25d1e575ef7e24db7726f45754e65a6596ee0bd3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 10 May 2021 16:57:45 +0000 Subject: [PATCH 02/20] Bump hosted-git-info from 2.8.8 to 2.8.9 in /maps Bumps [hosted-git-info](https://github.com/npm/hosted-git-info) from 2.8.8 to 2.8.9. - [Release notes](https://github.com/npm/hosted-git-info/releases) - [Changelog](https://github.com/npm/hosted-git-info/blob/v2.8.9/CHANGELOG.md) - [Commits](https://github.com/npm/hosted-git-info/compare/v2.8.8...v2.8.9) Signed-off-by: dependabot[bot] --- maps/yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/maps/yarn.lock b/maps/yarn.lock index ffb4747a..041c70ed 100644 --- a/maps/yarn.lock +++ b/maps/yarn.lock @@ -665,9 +665,9 @@ has@^1.0.3: function-bind "^1.1.1" hosted-git-info@^2.1.4: - version "2.8.8" - resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-2.8.8.tgz#7539bd4bc1e0e0a895815a2e0262420b12858488" - integrity sha512-f/wzC2QaWBs7t9IYqB4T3sR1xviIViXJRJTWBlx2Gf3g0Xi5vI7Yy4koXQ1c9OYDGHN9sBy1DQ2AB8fqZBWhUg== + version "2.8.9" + resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-2.8.9.tgz#dffc0bf9a21c02209090f2aa69429e1414daf3f9" + integrity sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw== iconv-lite@^0.4.24: version "0.4.24" From 50ee2e98100cbcb7f267139fa5fe5f17d05694a5 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 10 May 2021 16:57:50 +0000 Subject: [PATCH 03/20] Bump hosted-git-info from 2.8.8 to 2.8.9 in /uploader Bumps [hosted-git-info](https://github.com/npm/hosted-git-info) from 2.8.8 to 2.8.9. - [Release notes](https://github.com/npm/hosted-git-info/releases) - [Changelog](https://github.com/npm/hosted-git-info/blob/v2.8.9/CHANGELOG.md) - [Commits](https://github.com/npm/hosted-git-info/compare/v2.8.8...v2.8.9) Signed-off-by: dependabot[bot] --- uploader/yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/uploader/yarn.lock b/uploader/yarn.lock index 92253d44..5b6741ac 100644 --- a/uploader/yarn.lock +++ b/uploader/yarn.lock @@ -811,9 +811,9 @@ has@^1.0.3: function-bind "^1.1.1" hosted-git-info@^2.1.4: - version "2.8.8" - resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-2.8.8.tgz#7539bd4bc1e0e0a895815a2e0262420b12858488" - integrity sha512-f/wzC2QaWBs7t9IYqB4T3sR1xviIViXJRJTWBlx2Gf3g0Xi5vI7Yy4koXQ1c9OYDGHN9sBy1DQ2AB8fqZBWhUg== + version "2.8.9" + resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-2.8.9.tgz#dffc0bf9a21c02209090f2aa69429e1414daf3f9" + integrity sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw== http-errors@1.7.2: version "1.7.2" From e0d496c7b7f621adea0aaa8e6c2717b2ff8578f6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 10 May 2021 16:57:51 +0000 Subject: [PATCH 04/20] Bump hosted-git-info from 2.8.8 to 2.8.9 in /benchmark Bumps [hosted-git-info](https://github.com/npm/hosted-git-info) from 2.8.8 to 2.8.9. - [Release notes](https://github.com/npm/hosted-git-info/releases) - [Changelog](https://github.com/npm/hosted-git-info/blob/v2.8.9/CHANGELOG.md) - [Commits](https://github.com/npm/hosted-git-info/compare/v2.8.8...v2.8.9) Signed-off-by: dependabot[bot] --- benchmark/package-lock.json | 6 +++--- benchmark/yarn.lock | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/benchmark/package-lock.json b/benchmark/package-lock.json index 8d4db6cf..72d0aae4 100644 --- a/benchmark/package-lock.json +++ b/benchmark/package-lock.json @@ -230,9 +230,9 @@ } }, "hosted-git-info": { - "version": "2.8.8", - "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.8.tgz", - "integrity": "sha512-f/wzC2QaWBs7t9IYqB4T3sR1xviIViXJRJTWBlx2Gf3g0Xi5vI7Yy4koXQ1c9OYDGHN9sBy1DQ2AB8fqZBWhUg==" + "version": "2.8.9", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.9.tgz", + "integrity": "sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==" }, "indent-string": { "version": "2.1.0", diff --git a/benchmark/yarn.lock b/benchmark/yarn.lock index d93e3667..f1209dcf 100644 --- a/benchmark/yarn.lock +++ b/benchmark/yarn.lock @@ -169,8 +169,8 @@ graceful-fs@^4.1.2: resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.4.tgz#2256bde14d3632958c465ebc96dc467ca07a29fb" hosted-git-info@^2.1.4: - version "2.8.8" - resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-2.8.8.tgz#7539bd4bc1e0e0a895815a2e0262420b12858488" + version "2.8.9" + resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-2.8.9.tgz#dffc0bf9a21c02209090f2aa69429e1414daf3f9" indent-string@^2.1.0: version "2.1.0" From 003bdf18cbc5b41a13d0289f22370762b8c97d87 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 10 May 2021 16:58:41 +0000 Subject: [PATCH 05/20] Bump hosted-git-info from 2.8.8 to 2.8.9 in /messages Bumps [hosted-git-info](https://github.com/npm/hosted-git-info) from 2.8.8 to 2.8.9. - [Release notes](https://github.com/npm/hosted-git-info/releases) - [Changelog](https://github.com/npm/hosted-git-info/blob/v2.8.9/CHANGELOG.md) - [Commits](https://github.com/npm/hosted-git-info/compare/v2.8.8...v2.8.9) Signed-off-by: dependabot[bot] --- messages/yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/messages/yarn.lock b/messages/yarn.lock index 81bd0ed1..af71c938 100644 --- a/messages/yarn.lock +++ b/messages/yarn.lock @@ -2097,9 +2097,9 @@ highlight.js@^9.12.0: integrity sha512-zBZAmhSupHIl5sITeMqIJnYCDfAEc3Gdkqj65wC1lpI468MMQeeQkhcIAvk+RylAkxrCcI9xy9piHiXeQ1BdzQ== hosted-git-info@^2.1.4, hosted-git-info@^2.7.1: - version "2.8.8" - resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-2.8.8.tgz#7539bd4bc1e0e0a895815a2e0262420b12858488" - integrity sha512-f/wzC2QaWBs7t9IYqB4T3sR1xviIViXJRJTWBlx2Gf3g0Xi5vI7Yy4koXQ1c9OYDGHN9sBy1DQ2AB8fqZBWhUg== + version "2.8.9" + resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-2.8.9.tgz#dffc0bf9a21c02209090f2aa69429e1414daf3f9" + integrity sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw== html-tag@^2.0.0: version "2.0.0" From aff912da552bfcf2a7aa8d6238ada195a2e228cb Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 11 May 2021 23:45:14 +0000 Subject: [PATCH 06/20] Bump lodash from 4.17.20 to 4.17.21 in /pusher Bumps [lodash](https://github.com/lodash/lodash) from 4.17.20 to 4.17.21. - [Release notes](https://github.com/lodash/lodash/releases) - [Commits](https://github.com/lodash/lodash/compare/4.17.20...4.17.21) Signed-off-by: dependabot[bot] --- pusher/yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pusher/yarn.lock b/pusher/yarn.lock index 43f58988..9469a69d 100644 --- a/pusher/yarn.lock +++ b/pusher/yarn.lock @@ -1704,9 +1704,9 @@ lodash.once@^4.0.0: integrity sha1-DdOXEhPHxW34gJd9UEyI+0cal6w= lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.19: - version "4.17.20" - resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.20.tgz#b44a9b6297bcb698f1c51a3545a2b3b368d59c52" - integrity sha512-PlhdFcillOINfeV7Ni6oF1TAEayyZBoZ8bcshTHqOYJYlrqzRK5hagpagky5o4HfCzzd1TRkXPMFq6cKk9rGmA== + version "4.17.21" + resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" + integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== long@~3: version "3.2.0" From a1d52b42655a954ab3f159a1b09f312a439f62af Mon Sep 17 00:00:00 2001 From: kharhamel Date: Wed, 31 Mar 2021 11:21:06 +0200 Subject: [PATCH 07/20] FEATURE: added the possibility toplay emotes --- back/src/Model/GameRoom.ts | 15 ++-- back/src/Model/PositionNotifier.ts | 15 +++- back/src/Model/Zone.ts | 54 +++--------- back/src/RoomManager.ts | 3 + back/src/Services/SocketManager.ts | 24 ++++- back/tests/GameRoomTest.ts | 10 ++- back/tests/PositionNotifierTest.ts | 4 +- .../resources/emotes/pipo-popupemotes001.png | Bin 0 -> 747 bytes .../resources/emotes/pipo-popupemotes002.png | Bin 0 -> 920 bytes .../resources/emotes/pipo-popupemotes021.png | Bin 0 -> 810 bytes .../dist/resources/emotes/taba-clap-emote.png | Bin 0 -> 1305 bytes .../emotes/taba-thumbsdown-emote.png | Bin 0 -> 1981 bytes .../resources/emotes/taba-thumbsup-emote.png | Bin 0 -> 1931 bytes front/src/Connexion/EmoteEventStream.ts | 19 ++++ front/src/Connexion/RoomConnection.ts | 22 ++++- front/src/Phaser/Entity/Character.ts | 24 ++++- .../Entity/PlayerTexturesLoadingManager.ts | 21 +++-- front/src/Phaser/Game/EmoteManager.ts | 83 ++++++++++++++++++ front/src/Phaser/Game/GameScene.ts | 9 ++ messages/protos/messages.proto | 14 +++ pusher/src/Controller/IoSocketController.ts | 5 +- pusher/src/Model/Zone.ts | 17 +++- pusher/src/Services/SocketManager.ts | 19 +++- 23 files changed, 286 insertions(+), 72 deletions(-) create mode 100644 front/dist/resources/emotes/pipo-popupemotes001.png create mode 100644 front/dist/resources/emotes/pipo-popupemotes002.png create mode 100644 front/dist/resources/emotes/pipo-popupemotes021.png create mode 100644 front/dist/resources/emotes/taba-clap-emote.png create mode 100644 front/dist/resources/emotes/taba-thumbsdown-emote.png create mode 100644 front/dist/resources/emotes/taba-thumbsup-emote.png create mode 100644 front/src/Connexion/EmoteEventStream.ts create mode 100644 front/src/Phaser/Game/EmoteManager.ts diff --git a/back/src/Model/GameRoom.ts b/back/src/Model/GameRoom.ts index 4436fb60..be3e5cd3 100644 --- a/back/src/Model/GameRoom.ts +++ b/back/src/Model/GameRoom.ts @@ -2,12 +2,12 @@ import {PointInterface} from "./Websocket/PointInterface"; import {Group} from "./Group"; import {User, UserSocket} from "./User"; import {PositionInterface} from "_Model/PositionInterface"; -import {EntersCallback, LeavesCallback, MovesCallback} from "_Model/Zone"; +import {EmoteCallback, EntersCallback, LeavesCallback, MovesCallback} from "_Model/Zone"; import {PositionNotifier} from "./PositionNotifier"; import {Movable} from "_Model/Movable"; import {extractDataFromPrivateRoomId, extractRoomSlugPublicRoomId, isRoomAnonymous} from "./RoomIdentifier"; import {arrayIntersect} from "../Services/ArrayHelper"; -import {JoinRoomMessage} from "../Messages/generated/messages_pb"; +import {EmoteEventMessage, JoinRoomMessage} from "../Messages/generated/messages_pb"; import {ProtobufUtils} from "../Model/Websocket/ProtobufUtils"; import {ZoneSocket} from "src/RoomManager"; import {Admin} from "../Model/Admin"; @@ -51,8 +51,9 @@ export class GameRoom { groupRadius: number, onEnters: EntersCallback, onMoves: MovesCallback, - onLeaves: LeavesCallback) - { + onLeaves: LeavesCallback, + onEmote: EmoteCallback, + ) { this.roomId = roomId; if (isRoomAnonymous(roomId)) { @@ -74,7 +75,7 @@ export class GameRoom { this.minDistance = minDistance; this.groupRadius = groupRadius; // A zone is 10 sprites wide. - this.positionNotifier = new PositionNotifier(320, 320, onEnters, onMoves, onLeaves); + this.positionNotifier = new PositionNotifier(320, 320, onEnters, onMoves, onLeaves, onEmote); } public getGroups(): Group[] { @@ -325,4 +326,8 @@ export class GameRoom { this.versionNumber++ return this.versionNumber; } + + public emitEmoteEvent(user: User, emoteEventMessage: EmoteEventMessage) { + this.positionNotifier.emitEmoteEvent(user, emoteEventMessage); + } } diff --git a/back/src/Model/PositionNotifier.ts b/back/src/Model/PositionNotifier.ts index 6eff17a3..275bf9d0 100644 --- a/back/src/Model/PositionNotifier.ts +++ b/back/src/Model/PositionNotifier.ts @@ -8,10 +8,12 @@ * The PositionNotifier is important for performance. It allows us to send the position of players only to a restricted * number of players around the current player. */ -import {EntersCallback, LeavesCallback, MovesCallback, Zone} from "./Zone"; +import {EmoteCallback, EntersCallback, LeavesCallback, MovesCallback, Zone} from "./Zone"; import {Movable} from "_Model/Movable"; import {PositionInterface} from "_Model/PositionInterface"; import {ZoneSocket} from "../RoomManager"; +import {User} from "_Model/User"; +import {EmoteEventMessage} from "../Messages/generated/messages_pb"; interface ZoneDescriptor { i: number; @@ -24,7 +26,7 @@ export class PositionNotifier { private zones: Zone[][] = []; - constructor(private zoneWidth: number, private zoneHeight: number, private onUserEnters: EntersCallback, private onUserMoves: MovesCallback, private onUserLeaves: LeavesCallback) { + constructor(private zoneWidth: number, private zoneHeight: number, private onUserEnters: EntersCallback, private onUserMoves: MovesCallback, private onUserLeaves: LeavesCallback, private onEmote: EmoteCallback) { } private getZoneDescriptorFromCoordinates(x: number, y: number): ZoneDescriptor { @@ -77,7 +79,7 @@ export class PositionNotifier { let zone = this.zones[j][i]; if (zone === undefined) { - zone = new Zone(this.onUserEnters, this.onUserMoves, this.onUserLeaves, i, j); + zone = new Zone(this.onUserEnters, this.onUserMoves, this.onUserLeaves, this.onEmote, i, j); this.zones[j][i] = zone; } return zone; @@ -93,4 +95,11 @@ export class PositionNotifier { const zone = this.getZone(x, y); zone.removeListener(call); } + + public emitEmoteEvent(user: User, emoteEventMessage: EmoteEventMessage) { + const zoneDesc = this.getZoneDescriptorFromCoordinates(user.getPosition().x, user.getPosition().y); + const zone = this.getZone(zoneDesc.i, zoneDesc.j); + zone.emitEmoteEvent(emoteEventMessage); + + } } diff --git a/back/src/Model/Zone.ts b/back/src/Model/Zone.ts index ca695317..ffb172bb 100644 --- a/back/src/Model/Zone.ts +++ b/back/src/Model/Zone.ts @@ -3,21 +3,19 @@ import {PositionInterface} from "_Model/PositionInterface"; import {Movable} from "./Movable"; import {Group} from "./Group"; import {ZoneSocket} from "../RoomManager"; +import {EmoteEventMessage} from "../Messages/generated/messages_pb"; export type EntersCallback = (thing: Movable, fromZone: Zone|null, listener: ZoneSocket) => void; export type MovesCallback = (thing: Movable, position: PositionInterface, listener: ZoneSocket) => void; export type LeavesCallback = (thing: Movable, newZone: Zone|null, listener: ZoneSocket) => void; +export type EmoteCallback = (emoteEventMessage: EmoteEventMessage, listener: ZoneSocket) => void; export class Zone { private things: Set = new Set(); private listeners: Set = new Set(); - - /** - * @param x For debugging purpose only - * @param y For debugging purpose only - */ - constructor(private onEnters: EntersCallback, private onMoves: MovesCallback, private onLeaves: LeavesCallback, public readonly x: number, public readonly y: number) { - } + + + constructor(private onEnters: EntersCallback, private onMoves: MovesCallback, private onLeaves: LeavesCallback, private onEmote: EmoteCallback, public readonly x: number, public readonly y: number) { } /** * A user/thing leaves the zone @@ -41,9 +39,7 @@ export class Zone { */ private notifyLeft(thing: Movable, newZone: Zone|null) { for (const listener of this.listeners) { - //if (listener !== thing && (newZone === null || !listener.listenedZones.has(newZone))) { - this.onLeaves(thing, newZone, listener); - //} + this.onLeaves(thing, newZone, listener); } } @@ -57,15 +53,6 @@ export class Zone { */ private notifyEnter(thing: Movable, oldZone: Zone|null, position: PositionInterface) { for (const listener of this.listeners) { - - /*if (listener === thing) { - continue; - } - if (oldZone === null || !listener.listenedZones.has(oldZone)) { - this.onEnters(thing, listener); - } else { - this.onMoves(thing, position, listener); - }*/ this.onEnters(thing, oldZone, listener); } } @@ -85,28 +72,6 @@ export class Zone { } } - /*public startListening(listener: User): void { - for (const thing of this.things) { - if (thing !== listener) { - this.onEnters(thing, listener); - } - } - - this.listeners.add(listener); - listener.listenedZones.add(this); - } - - public stopListening(listener: User): void { - for (const thing of this.things) { - if (thing !== listener) { - this.onLeaves(thing, listener); - } - } - - this.listeners.delete(listener); - listener.listenedZones.delete(this); - }*/ - public getThings(): Set { return this.things; } @@ -119,4 +84,11 @@ export class Zone { public removeListener(socket: ZoneSocket): void { this.listeners.delete(socket); } + + public emitEmoteEvent(emoteEventMessage: EmoteEventMessage) { + for (const listener of this.listeners) { + this.onEmote(emoteEventMessage, listener); + } + + } } diff --git a/back/src/RoomManager.ts b/back/src/RoomManager.ts index 54215698..19266687 100644 --- a/back/src/RoomManager.ts +++ b/back/src/RoomManager.ts @@ -5,6 +5,7 @@ import { AdminPusherToBackMessage, AdminRoomMessage, BanMessage, + EmotePromptMessage, EmptyMessage, ItemEventMessage, JoinRoomMessage, @@ -71,6 +72,8 @@ const roomManager: IRoomManagerServer = { socketManager.emitPlayGlobalMessage(room, message.getPlayglobalmessage() as PlayGlobalMessage); } else if (message.hasQueryjitsijwtmessage()){ socketManager.handleQueryJitsiJwtMessage(user, message.getQueryjitsijwtmessage() as QueryJitsiJwtMessage); + } else if (message.hasEmotepromptmessage()){ + socketManager.handleEmoteEventMessage(room, user, message.getEmotepromptmessage() as EmotePromptMessage); }else if (message.hasSendusermessage()) { const sendUserMessage = message.getSendusermessage(); if(sendUserMessage !== undefined) { diff --git a/back/src/Services/SocketManager.ts b/back/src/Services/SocketManager.ts index 647afc95..5d5dcf03 100644 --- a/back/src/Services/SocketManager.ts +++ b/back/src/Services/SocketManager.ts @@ -26,7 +26,8 @@ import { GroupLeftZoneMessage, WorldFullWarningMessage, UserLeftZoneMessage, - BanUserMessage, RefreshRoomMessage, + EmoteEventMessage, + BanUserMessage, RefreshRoomMessage, EmotePromptMessage, } from "../Messages/generated/messages_pb"; import {User, UserSocket} from "../Model/User"; import {ProtobufUtils} from "../Model/Websocket/ProtobufUtils"; @@ -73,6 +74,9 @@ export class SocketManager { clientEventsEmitter.registerToClientLeave((clientUUid: string, roomId: string) => { gaugeManager.decNbClientPerRoomGauge(roomId); }); + + + //zoneMessageStream.stream.subscribe(myMessage); } public async handleJoinRoom(socket: UserSocket, joinRoomMessage: JoinRoomMessage): Promise<{ room: GameRoom; user: User }> { @@ -263,7 +267,8 @@ export class SocketManager { GROUP_RADIUS, (thing: Movable, fromZone: Zone|null, listener: ZoneSocket) => this.onZoneEnter(thing, fromZone, listener), (thing: Movable, position:PositionInterface, listener: ZoneSocket) => this.onClientMove(thing, position, listener), - (thing: Movable, newZone: Zone|null, listener: ZoneSocket) => this.onClientLeave(thing, newZone, listener) + (thing: Movable, newZone: Zone|null, listener: ZoneSocket) => this.onClientLeave(thing, newZone, listener), + (emoteEventMessage:EmoteEventMessage, listener: ZoneSocket) => this.onEmote(emoteEventMessage, listener), ); gaugeManager.incNbRoomGauge(); this.rooms.set(roomId, world); @@ -339,6 +344,14 @@ export class SocketManager { } } + + private onEmote(emoteEventMessage: EmoteEventMessage, client: ZoneSocket) { + const subMessage = new SubToPusherMessage(); + subMessage.setEmoteeventmessage(emoteEventMessage); + + emitZoneMessage(subMessage, client); + } + private emitCreateUpdateGroupEvent(client: ZoneSocket, fromZone: Zone|null, group: Group): void { const position = group.getPosition(); const pointMessage = new PointMessage(); @@ -751,6 +764,13 @@ export class SocketManager { recipient.socket.write(clientMessage); }); } + + handleEmoteEventMessage(room: GameRoom, user: User, emotePromptMessage: EmotePromptMessage) { + const emoteEventMessage = new EmoteEventMessage(); + emoteEventMessage.setEmote(emotePromptMessage.getEmote()); + emoteEventMessage.setActoruserid(user.id); + room.emitEmoteEvent(user, emoteEventMessage); + } } export const socketManager = new SocketManager(); diff --git a/back/tests/GameRoomTest.ts b/back/tests/GameRoomTest.ts index 45721334..6bdc6912 100644 --- a/back/tests/GameRoomTest.ts +++ b/back/tests/GameRoomTest.ts @@ -5,6 +5,7 @@ import {Group} from "../src/Model/Group"; import {User, UserSocket} from "_Model/User"; import {JoinRoomMessage, PositionMessage} from "../src/Messages/generated/messages_pb"; import Direction = PositionMessage.Direction; +import {EmoteCallback} from "_Model/Zone"; function createMockUser(userId: number): User { return { @@ -33,6 +34,8 @@ function createJoinRoomMessage(uuid: string, x: number, y: number): JoinRoomMess return joinRoomMessage; } +const emote: EmoteCallback = (emoteEventMessage, listener): void => {} + describe("GameRoom", () => { it("should connect user1 and user2", () => { let connectCalledNumber: number = 0; @@ -43,7 +46,8 @@ describe("GameRoom", () => { } - const world = new GameRoom('_/global/test.json', connect, disconnect, 160, 160, () => {}, () => {}, () => {}); + + const world = new GameRoom('_/global/test.json', connect, disconnect, 160, 160, () => {}, () => {}, () => {}, emote); @@ -72,7 +76,7 @@ describe("GameRoom", () => { } - const world = new GameRoom('_/global/test.json', connect, disconnect, 160, 160, () => {}, () => {}, () => {}); + const world = new GameRoom('_/global/test.json', connect, disconnect, 160, 160, () => {}, () => {}, () => {}, emote); const user1 = world.join(createMockUserSocket(), createJoinRoomMessage('1', 100, 100)); @@ -101,7 +105,7 @@ describe("GameRoom", () => { disconnectCallNumber++; } - const world = new GameRoom('_/global/test.json', connect, disconnect, 160, 160, () => {}, () => {}, () => {}); + const world = new GameRoom('_/global/test.json', connect, disconnect, 160, 160, () => {}, () => {}, () => {}, emote); const user1 = world.join(createMockUserSocket(), createJoinRoomMessage('1', 100, 100)); diff --git a/back/tests/PositionNotifierTest.ts b/back/tests/PositionNotifierTest.ts index 5901202f..24b171d9 100644 --- a/back/tests/PositionNotifierTest.ts +++ b/back/tests/PositionNotifierTest.ts @@ -23,7 +23,7 @@ describe("PositionNotifier", () => { moveTriggered = true; }, (thing: Movable) => { leaveTriggered = true; - }); + }, () => {}); const user1 = new User(1, 'test', '10.0.0.2', { x: 500, @@ -98,7 +98,7 @@ describe("PositionNotifier", () => { moveTriggered = true; }, (thing: Movable) => { leaveTriggered = true; - }); + }, () => {}); const user1 = new User(1, 'test', '10.0.0.2', { x: 500, diff --git a/front/dist/resources/emotes/pipo-popupemotes001.png b/front/dist/resources/emotes/pipo-popupemotes001.png new file mode 100644 index 0000000000000000000000000000000000000000..a3db6d6d088107ed91cbcc4e6259c278dd729ff9 GIT binary patch literal 747 zcmVPx#1ZP1_K>z@;j|==^1poj532;bRa{vGi!vFvd!vV){sAK>D0)RG`eTJMovx_r&XXzl3VS@^FMv+SBvnhT1bP2>cXJOyK>}DZsjx0jq!>lmcAy zTj6^R0IP&r0V(vtsc90c0QdY>_z?zbC0GRz`rxC0U;UacsTJUwUkN|L04SAU6+p5c zqP9h;0QdZg0RVxczl2E6fU3WhkZQ+K!h;_DS^4GDS3Ct#Tqz)T3e^H;Zh&Wg*#Lmh z?i9q=!I(g41DXm*!^i06OG zh7TJMr=^5a0iO8-M@nct0ma>4X(<4jM;-yS0=)AF)Dn&qP~H8NGZf&NKcJQn1To29 zAQtBCuc!q8l+IC53h>S!P)tCu)x3m#jIe{}hSoCOb;KQv(QsEx)nyzvJM-;#e9{G20G5%Xf4qX3J{}z(K zwVs_n3yq{uINmZGhB`w5^pP?<{{s+X5%~E`@iP?QnSX;OJjBmXfM@;Px#1ZP1_K>z@;j|==^1poj532;bRa{vGi!vFvd!vV){sAK>D11(8JK~!i%?V8PT z+b|G?Ra;t78EM1ETvLWy(9tdUs1g(|2M@DYyali%P0*fvGdrG$@V*~N%Eh<;d?W7n z`>QwU=#PZuwUdq>3zomV{dqkekJt10tVUj!=U-_AyIYfv9tw7!o*&B~Is(ibX#~5@ zE&#ixAMe#Lw2`zj3_{E(pr_mGVOM^LYeeAhubVs80N84xRe%nSvFs^;xy1z*3Hd0*y3jr@Cg-7_FWO?U-F!wW}g3($%7FzmsKBXAb%O){{j2v&eD zJ{n%wqYVn}-jE)KJjelj=F3x%qjWX_%gy%KBz)qdr}PS0zv0Gl6V53<*kFV8S7AE23EhxZogK!6$a&bCTm z1&sR09>`fus{ox}6UpcKz*#rH4L>RG`=41iE^PMPyc64<-Y zE5P$%_ZPv_Ie4*n7ohW7bY81(pA~*dAM{gb4HSW7Sqe^{B_Srp@GgKAP-z(rJTK0W zbDD_0Mt13al{PM3b;3L_eQH1p=tYHKL&zgq0P~?=E5au)ILCbC9VMc=s^y9Y>Er5j|#VcSmKiJZPmwzQvf{0-vxVOI$DPT)@ zp;bU6|5jT0S4KuTa3u{2BS>UKtiGyhpsHdsYF+ zFe85fmf_Dr81nj$`m+!PcAcG{0wWp-rz3}hP**4bJ~UsQze2=Ng#0{H{0ark%%6dY u8RAzcU}pZzOz|reFtz+E@cj$8-Tndm{J>uK%%V#G0000Px#1ZP1_K>z@;j|==^1poj532;bRa{vGi!vFvd!vV){sAK>D0>4Q_K~!i%?Ud_n z<3J3AU87rYOZj;kTexZQ5gXx62_5-YEs zb?LV7@UO2w&-?xUd^{d+gX_4zrxBh#nsw=>@a&K0=V27hKr>q!;aQ(9fM*?&-xI!G zukJ{sk@d(j3~@vOTGz_Uv6#wFaSL460~2`rEO5VLLKgsZa5}4YBp`yQ~c12f`@_+^MllSPDz`2ouOaY*y zpD7^9-_a#u{yoE!sD!8h(AAF$i1N?mCd2%h@Bk*fi5Qg-6#!;S7!?rZ*IDIcar0-w z6Rh(l1J+q4q5^;wo|C~VAd_F_lw*0pcwo<)hl~M@C5nl#3}p4O#+xiy+ts&_9x&)& z^o3%L)=#~CnyN6d-okDH^1;g)0Ub-fnqQOc!2Akl3{=Vo-UI09Lt9R_fSLTD(t)Kv zRY0YDaK-{=^s60M`ilV^RH0Koz`nB;(8*7rZHS(-CMp2Pht8=G6%ggGW|lSqcV z6F?f^Sj>of+WNs`M(p7s%Z&C5Y_1xS!ZYHQE`ICr*rEVtz&W4J{|`J2pS~9w z*KvPKVV-q#0X*x|`AujmH)Pl}(RfFtw2pot^I&rraT o`Cl9Px((@8`@RA_>>`^i-*Hqj53~>T__gpHMkhp-^!DzR&#SFyKrkgLFC# z(=>g3y4^0??Y7%jwUEz=fAru5{`&EsSozWb$jQp(GECEirmYG4YT6oXdk4ee6}sIn z9LI@l1k3;Wa0S!q$|vI=9Rjw!6DwaBK%;RoWsOf%Z!oR#4WJ9rv^AKf=@v4N2|PY< z6Zn5|@C(1%)2APvaVB2;d~N__@NC+|{7T+}t|!s!jqvjIg|rX=p-pHsPNqi>Ucj-v zVF!gD7ys#pXZTqBCH4SqexmA6oS1Lr3&`6BDtXJ-PRq7;P%f91ZeTQjT3ZqFpYPc| zj!1Fx!?IQQo&eaH&n8~UTljA82lRR)T)e)0a-c&X?em79!VeREocv1OLe|i*yS0w2 zp((S2_`VguyTcQJt|#&S@^9pA1HIk|m%R~ySqOCd^F3SXykYpnfa~Tf50|v_!jFSb z*qfUve7j#l@k!bf0%@0pAmY0Lko0*^I%IHEPeJ{R71(X>>Y$ zy#2TD*1)#!ZmkPL;PICLfBNjT_wmDJp#gc@z+^CX4bb(Z`+tU>^eju!ETH+*+KPuC zWWe=B+tY4ot3rc=_=HXPbnrBj{QfGH^VPJyk;3lQx-#E)4`5X<=A;Hp24mk0h)eZi zZY~2XZPge4Jd%yO)9K?~|IG87mMYr~fVVuN7$B19GQc|?#eg{YLE|QEq*kKsjTEwm zro@r1fJGP(Mo|nP_k(#=$y?amOi9PRz2RlfBWVDi(g1SKMH?Ve*3jGs4%xV)$_=2~ z?fM!QVE~WAJ6=?JHBq zaWEWSc^cz3U^u*j<2b%ov^@SI3<$%gYk2eLZvdhP&*Luvv)vhbdBkA=`?gfH{CRx3 zhPVs}%cpAyGj>s5-uc~2kZcytYXEJ&X!gnYbPe+u5C=bO4Wd3GzV99&nuSqkfN1uq z^ozrQD16l#NL#1VN9p)X>Aao|ng3_O6!31nm5$F)FXjLQuL53y&E~mu_66}(YY-U_ z2cNA$B%b*)zHdj2E`r{Uo5uiZe^hyIR>Ws(5Q#P*ZoWzlwD~H0_aOUe27ga>_HIZd zRrBf3Lc;J#4I-(UABC?XRlYC)QUu`tDne$!_doycf&uYY0YL`H_%hK0{~Y+z0N6sv zcR9T4W3zdVcDs#Mt0e`q1>ASOQUGi*nM?-NYQ;TPtGz_4)e7|uP8b93GCy!XfYrgl zexlWCq1ikKaGQKscof|=zS5_OydubxLYOCvxOA8Jp;rJ?#77_eUE}`;Eqa+RDLP?j P00000NkvXXu0mjf%DsGb literal 0 HcmV?d00001 diff --git a/front/dist/resources/emotes/taba-thumbsdown-emote.png b/front/dist/resources/emotes/taba-thumbsdown-emote.png new file mode 100644 index 0000000000000000000000000000000000000000..86e89c7b0e46be5f6e452563cee04cf1510b0c25 GIT binary patch literal 1981 zcmV;u2SWIXP)Px+cS%G+RCt{2oIh;hNEF7uU2Q>$A`0B1YhvjN$SM+4tCVTBpbP1|thPXR^=T%9o;Hrw3U&f#S|x*j@BunRZ6D7U&(-vj*if1G*GM6 z0{g61D_EAr_7ydx;1~So%#RH~I#{>cMXgptrE(CtZ>4g8bgC81z1V280 zEo*nc5CGDRQ1HX~G6B&1I919YcUsVO4fR6}08r9eK(&lwD0``2Fh zFDudh4+NkneDtXGfm`p8qS{l;myeJE7u92|0uqdCmflmU>leULV6?i}!m1@R08~ph0J3PYbWjK9TeS?^c038uqiPxJ#g#IC zn0D!Zl(P0e3&5z8Pu9Sj0N{g=uY~lTs$!I)%6t+ZxA!mxeHMhBc7r*~f={3d%kYzw zW&ot+s|o;FzA-V`d635j-mXJUcmO^%|Kp^h|Ap=aC8v!+AG6CbgYpX@6pV4fmsWh^ z`Y%2Br^9(vVc_8^fa(BHVMBA%w7RarwjH+6;U9qXU6t&I@SkyV=zpOAWKIESo*8HY zo@y929aK5>^Yh330qb;H6ar{EY$aA<89jyp@Q?uzX2aSs-DUoSbD(j5z!o4>vG*+j zumD`GR*@&6fyM>cbdW8;0GM4*BQJ*YoT$%5`BaIi;XUVo3Bi|!uW|sS;rngK5&&uW zQDVL?eX|ZHeE+BDf1dp>R{{B`u9jIK(4~N_A!_B}Z&sk1X9ny|0s!EtzyOq2;fJgM zagq&yETT35fI6;N5+)n`{h#*&n1`9O|9SQQOTc|(Vl*1z?(Sz`2fCUcs4AOaY@Qha zHn)M%9WoKRyZedJXcTxvLxk_)a*06KHR0e(!%vcJJ6J3o%&wW3QE zi>uJnlRWtWdv@r59{tazpqni4EA08Fz|?X%WiP2CNF}Wm8iY~VCyviM?5TvyDVis3 zmIcFZi1jNCUqqqtoFr!)&1{TgrO_|An-^= zT!h=5?Zt3i*HDLvPP@TAySe!hn|+e_ezyN+>pHd#5mtS(^1~!k0rKF>sNg@|{tv8z z@u;q8}i^s@j;`J&=}AEe+H{7`;i3d$LQ)oLZZ zGGt6lG(S%9_RBZ?{^#FP&dc)o)wapha#zP3lTAfs8()?mr!<}aRQNOvqsM@vRy_CB zf0g{m>wtVFkoj>M9!$rPH(!qLU#y`O(=?5rhRB{QtiE;khB;xA&hTZOF>!&qIxg`6&D^ z_`Bn)>VKgC^c!<@bc7Ed-m}lXe*G8o`CKa7$TUE~FZlb)4?PEr)A{+?i}`$xo0}g1 z+{_~j&!U1~@b`%?bT4R_5i~!}a-Nk|1;60$3tzed`i$7Q^98@)?-T!j$6lZfW5~OD P00000NkvXXu0mjfvI^`T literal 0 HcmV?d00001 diff --git a/front/dist/resources/emotes/taba-thumbsup-emote.png b/front/dist/resources/emotes/taba-thumbsup-emote.png new file mode 100644 index 0000000000000000000000000000000000000000..46bfc7b445d09672589ad410a0adb26abb30a73a GIT binary patch literal 1931 zcmV;62Xy#}P)Px+MM*?KRCt{2oUw1)I26Xe79BhWfrn@{a6`4(DT0%y!edr#aW>Vx4hC*MBxCX) zWaypYEzYJjW6>!-7_qat!vV%^&JYwCv*iZ)F%(5oq)a)@RUZ&ER%QL(qu%3tq~!pW zDpjgf>F3ld0Qma#tI^5l=NFH8=Nuj$yfV4g`}gnJ?-jq|KNtUv4S08V7p+zcjYcDL z&SJ5^Y&K)(ni^8^EB-BnQG#aSax0UDC>)Y7ddxyKb?^rAr7z_q$4@AY+ z^M~Cw`#l<6{Z#&oz8B>y6X5*(;?cx$QT6&Z>h*0j8VzPhkwJJ#eEwzt$qgx+KkT*< z_4{~OUg753GI_lUgiZM`1|hJS@mKj?gfC41g759Uv$dOv3;|$02rrROH)Isgo`o-} zY`!iCH^I-7DgSjDa9(?R@6hY@j2192kuL_qruliQ_Ho=+fDgo94U9G6^U^W9VT-jSy6c+q*HM}L z=35W*dA9m&itqBewtN%cb=PGr5M`fk!4viS=!PxSn)|4EZQOiYqUJpXn;4+c^}0;sZtf(if&cmN1GA%EHe&Q}Ft5k6g0dfYMy zZTT*JSMhT{38oeTxA*BJSOtC5Z?j;md2JSq{H#bbWi-0NWOAK{WDH8iVpIUPtOED$+ApD!_=r)Pe? z?HPYbT7u~07!Nm>EFg-`2(0>W;**apgwM|4`yPUWh_xfW?;$vf3vmIi`mEL*e2XR8 z5^%>%Zh|SFs!!knK_>~in%7RXmi!v0`PO4=Ef+SF6@+=(r0+%vKu~@LrUfWJ)AGUZ zOslqw@A>(*i}=Ao1R%K)6tIE(iq@jppO`AqO>nAJ^~3Ew+kl`z5VrtpUK>qZIZ1*a zAptkxJ4=AYVj*1wCA0n#-LRGFz4^Y7W?$Y|Hfst-EEWr?Au2vqAL8;eKWndOA;y(y zm8|*Px<_1OkK$Qs5lA-yQ1`+%{F>KJRUnyIm0a1$2ZD-Y%4gRFoCTpNzYO1H{M-r< zjjk~4wlRulED0$1$N)b0fut33q2MS^J!(KnK(+vO!Cyqw%E?-wO^GUyI0FO7v|uh( zRuI~#Y~No4$s?nK!2ox6-=!x}7Rwfv;x<@nD^$q-ySwig3Fa6jog01-ueOK|{ z0LiywK79D7E`vx{0w5ds;tVY7!Byoik1tjsvF|sPEr0waM)R%52A%(GBh~(_eCI!W zvuS^{{@>oGt}80FO`W4cd`$~v8-&(;hrX-$tOZ(S;iJj(@6pMzb<3g3ep9|Q0RF8Va1>*F8YY7uCyQTdyHYov3x1t!`M&S5E#6vlA3+lcIDh#>A^yVtQ&Ei|frz9ZpEg)Y3^L#JD4~}BsG)z?? z@dk@L-5A*NT?e5J-?i^LekuVd3jrt}Nu?m91+ffH=aD9pYi;%y;qzP>pVvD4;3!6b z?Bf99;~@N}qvGHf<;$v&D5`|I04TzD8H7doE_~19vjhA$^KHKlmoaQ*0QddO44nU~ z0518w^ZzG)>fyT_xg7t0{_Fuzf8whPLRbD5@!iyaX#&WMu$2zZ8&L6I2H!RaUyA?A z&jI*yWMc>qrt4T+U-5r9{x81`5E$~Q<#5R_%YW?$Vf1D)g#IpztUsSjt}&aAVtH@hko&_?f>7k*CAMgICk( zls(r!nOs}L*HOi<_%8Wok3&n0pt5ALz2#aJzv8>%TUP+Vh>bg6@hiSd{(n|7Zf<$@ RovZ)=002ovPDHLkV1fW^zxn_G literal 0 HcmV?d00001 diff --git a/front/src/Connexion/EmoteEventStream.ts b/front/src/Connexion/EmoteEventStream.ts new file mode 100644 index 00000000..97d0d213 --- /dev/null +++ b/front/src/Connexion/EmoteEventStream.ts @@ -0,0 +1,19 @@ +import {Subject} from "rxjs"; + +interface EmoteEvent { + userId: number, + emoteName: string, +} + +class EmoteEventStream { + + private _stream:Subject = new Subject(); + public stream = this._stream.asObservable(); + + + onMessage(userId: number, emoteName:string) { + this._stream.next({userId, emoteName}); + } +} + +export const emoteEventStream = new EmoteEventStream(); \ No newline at end of file diff --git a/front/src/Connexion/RoomConnection.ts b/front/src/Connexion/RoomConnection.ts index 6edb9c45..fa462f50 100644 --- a/front/src/Connexion/RoomConnection.ts +++ b/front/src/Connexion/RoomConnection.ts @@ -27,6 +27,8 @@ import { SendJitsiJwtMessage, CharacterLayerMessage, PingMessage, + EmoteEventMessage, + EmotePromptMessage, SendUserMessage, BanUserMessage } from "../Messages/generated/messages_pb" @@ -47,6 +49,7 @@ import {adminMessagesService} from "./AdminMessagesService"; import {worldFullMessageStream} from "./WorldFullMessageStream"; import {worldFullWarningStream} from "./WorldFullWarningStream"; import {connectionManager} from "./ConnectionManager"; +import {emoteEventStream} from "./EmoteEventStream"; const manualPingDelay = 20000; @@ -124,7 +127,7 @@ export class RoomConnection implements RoomConnection { if (message.hasBatchmessage()) { for (const subMessage of (message.getBatchmessage() as BatchMessage).getPayloadList()) { - let event: string; + let event: string|null = null; let payload; if (subMessage.hasUsermovedmessage()) { event = EventMessage.USER_MOVED; @@ -144,11 +147,16 @@ export class RoomConnection implements RoomConnection { } else if (subMessage.hasItemeventmessage()) { event = EventMessage.ITEM_EVENT; payload = subMessage.getItemeventmessage(); + } else if (subMessage.hasEmoteeventmessage()) { + const emoteMessage = subMessage.getEmoteeventmessage() as EmoteEventMessage; + emoteEventStream.onMessage(emoteMessage.getActoruserid(), emoteMessage.getEmote()); } else { throw new Error('Unexpected batch message type'); } - this.dispatch(event, payload); + if (event) { + this.dispatch(event, payload); + } } } else if (message.hasRoomjoinedmessage()) { const roomJoinedMessage = message.getRoomjoinedmessage() as RoomJoinedMessage; @@ -599,4 +607,14 @@ export class RoomConnection implements RoomConnection { public isAdmin(): boolean { return this.hasTag('admin'); } + + public emitEmoteEvent(emoteName: string): void { + const emoteMessage = new EmotePromptMessage(); + emoteMessage.setEmote(emoteName) + + const clientToServerMessage = new ClientToServerMessage(); + clientToServerMessage.setEmotepromptmessage(emoteMessage); + + this.socket.send(clientToServerMessage.serializeBinary().buffer); + } } diff --git a/front/src/Phaser/Entity/Character.ts b/front/src/Phaser/Entity/Character.ts index 9f2bd1fd..bb8c2fb0 100644 --- a/front/src/Phaser/Entity/Character.ts +++ b/front/src/Phaser/Entity/Character.ts @@ -5,6 +5,9 @@ import Container = Phaser.GameObjects.Container; import Sprite = Phaser.GameObjects.Sprite; import {TextureError} from "../../Exception/TextureError"; import {Companion} from "../Companion/Companion"; +import {getEmoteAnimName} from "../Game/EmoteManager"; + +const playerNameY = - 25; interface AnimationData { key: string; @@ -23,6 +26,7 @@ export abstract class Character extends Container { //private teleportation: Sprite; private invisible: boolean; public companion?: Companion; + private emote: Phaser.GameObjects.Sprite | null = null; constructor(scene: Phaser.Scene, x: number, @@ -54,7 +58,7 @@ export abstract class Character extends Container { }); this.add(this.teleportation);*/ - this.playerName = new BitmapText(scene, 0, - 25, 'main_font', name, 7); + this.playerName = new BitmapText(scene, 0, playerNameY, 'main_font', name, 7); this.playerName.setOrigin(0.5).setCenterAlign().setDepth(99999); this.add(this.playerName); @@ -225,7 +229,23 @@ export abstract class Character extends Container { this.scene.sys.updateList.remove(sprite); } } + this.list.forEach(objectContaining => objectContaining.destroy()) super.destroy(); - this.playerName.destroy(); + } + + playEmote(emoteKey: string) { + if (this.emote) return; + + this.playerName.setVisible(false); + this.emote = new Sprite(this.scene, 0, -40, emoteKey, 1); + this.emote.setDepth(99999); + this.add(this.emote); + this.scene.sys.updateList.add(this.emote); + this.emote.play(getEmoteAnimName(emoteKey)); + this.emote.on(Phaser.Animations.Events.SPRITE_ANIMATION_COMPLETE, () => { + this.emote?.destroy(); + this.emote = null; + this.playerName.setVisible(true); + }); } } diff --git a/front/src/Phaser/Entity/PlayerTexturesLoadingManager.ts b/front/src/Phaser/Entity/PlayerTexturesLoadingManager.ts index 6d8b84c2..95f00a9e 100644 --- a/front/src/Phaser/Entity/PlayerTexturesLoadingManager.ts +++ b/front/src/Phaser/Entity/PlayerTexturesLoadingManager.ts @@ -2,6 +2,10 @@ import LoaderPlugin = Phaser.Loader.LoaderPlugin; import type {CharacterTexture} from "../../Connexion/LocalUser"; import {BodyResourceDescriptionInterface, LAYERS, PLAYER_RESOURCES} from "./PlayerTextures"; +export interface FrameConfig { + frameWidth: number, + frameHeight: number, +} export const loadAllLayers = (load: LoaderPlugin): BodyResourceDescriptionInterface[][] => { const returnArray:BodyResourceDescriptionInterface[][] = []; @@ -26,7 +30,10 @@ export const loadAllDefaultModels = (load: LoaderPlugin): BodyResourceDescriptio export const loadCustomTexture = (loaderPlugin: LoaderPlugin, texture: CharacterTexture) : Promise => { const name = 'customCharacterTexture'+texture.id; const playerResourceDescriptor: BodyResourceDescriptionInterface = {name, img: texture.url, level: texture.level} - return createLoadingPromise(loaderPlugin, playerResourceDescriptor); + return createLoadingPromise(loaderPlugin, playerResourceDescriptor, { + frameWidth: 32, + frameHeight: 32 + }); } export const lazyLoadPlayerCharacterTextures = (loadPlugin: LoaderPlugin, texturekeys:Array): Promise => { @@ -36,7 +43,10 @@ export const lazyLoadPlayerCharacterTextures = (loadPlugin: LoaderPlugin, textur //TODO refactor const playerResourceDescriptor = getRessourceDescriptor(textureKey); if (playerResourceDescriptor && !loadPlugin.textureManager.exists(playerResourceDescriptor.name)) { - promisesList.push(createLoadingPromise(loadPlugin, playerResourceDescriptor)); + promisesList.push(createLoadingPromise(loadPlugin, playerResourceDescriptor, { + frameWidth: 32, + frameHeight: 32 + })); } }catch (err){ console.error(err); @@ -69,15 +79,12 @@ export const getRessourceDescriptor = (textureKey: string|BodyResourceDescriptio throw 'Could not find a data for texture '+textureName; } -const createLoadingPromise = (loadPlugin: LoaderPlugin, playerResourceDescriptor: BodyResourceDescriptionInterface) => { +export const createLoadingPromise = (loadPlugin: LoaderPlugin, playerResourceDescriptor: BodyResourceDescriptionInterface, frameConfig: FrameConfig) => { return new Promise((res) => { if (loadPlugin.textureManager.exists(playerResourceDescriptor.name)) { return res(playerResourceDescriptor); } - loadPlugin.spritesheet(playerResourceDescriptor.name, playerResourceDescriptor.img, { - frameWidth: 32, - frameHeight: 32 - }); + loadPlugin.spritesheet(playerResourceDescriptor.name, playerResourceDescriptor.img, frameConfig); loadPlugin.once('filecomplete-spritesheet-' + playerResourceDescriptor.name, () => res(playerResourceDescriptor)); }); } diff --git a/front/src/Phaser/Game/EmoteManager.ts b/front/src/Phaser/Game/EmoteManager.ts new file mode 100644 index 00000000..b33952fe --- /dev/null +++ b/front/src/Phaser/Game/EmoteManager.ts @@ -0,0 +1,83 @@ +import {BodyResourceDescriptionInterface} from "../Entity/PlayerTextures"; +import {createLoadingPromise} from "../Entity/PlayerTexturesLoadingManager"; +import {emoteEventStream} from "../../Connexion/EmoteEventStream"; +import {GameScene} from "./GameScene"; + +enum RegisteredEmoteTypes { + short = 1, + long = 2, +} + +interface RegisteredEmote extends BodyResourceDescriptionInterface { + name: string; + img: string; + type: RegisteredEmoteTypes +} + +export const emotes: {[key: string]: RegisteredEmote} = { + 'emote-exclamation': {name: 'emote-exclamation', img: 'resources/emotes/pipo-popupemotes001.png', type: RegisteredEmoteTypes.short}, + 'emote-interrogation': {name: 'emote-interrogation', img: 'resources/emotes/pipo-popupemotes002.png', type: RegisteredEmoteTypes.short}, + 'emote-sleep': {name: 'emote-sleep', img: 'resources/emotes/pipo-popupemotes002.png', type: RegisteredEmoteTypes.short}, + 'emote-clap': {name: 'emote-clap', img: 'resources/emotes/taba-clap-emote.png', type: RegisteredEmoteTypes.short}, + 'emote-thumbsdown': {name: 'emote-thumbsdown', img: 'resources/emotes/taba-thumbsdown-emote.png', type: RegisteredEmoteTypes.long}, + 'emote-thumbsup': {name: 'emote-thumbsup', img: 'resources/emotes/taba-thumbsup-emote.png', type: RegisteredEmoteTypes.long}, +}; + +export const getEmoteAnimName = (emoteKey: string): string => { + return 'anim-'+emoteKey; +} + +export class EmoteManager { + + constructor(private scene: GameScene) { + + //todo: use a radial menu instead? + this.registerEmoteOnKey('keyup-Y', 'emote-clap'); + this.registerEmoteOnKey('keyup-U', 'emote-thumbsup'); + this.registerEmoteOnKey('keyup-I', 'emote-thumbsdown'); + this.registerEmoteOnKey('keyup-O', 'emote-exclamation'); + this.registerEmoteOnKey('keyup-P', 'emote-interrogation'); + this.registerEmoteOnKey('keyup-T', 'emote-sleep'); + + + emoteEventStream.stream.subscribe((event) => { + const actor = this.scene.MapPlayersByKey.get(event.userId); + if (actor) { + this.lazyLoadEmoteTexture(event.emoteName).then(emoteKey => { + actor.playEmote(emoteKey); + }) + } + }) + } + + private registerEmoteOnKey(keyboardKey: string, emoteKey: string) { + this.scene.input.keyboard.on(keyboardKey, () => { + this.scene.connection?.emitEmoteEvent(emoteKey); + this.lazyLoadEmoteTexture(emoteKey).then(emoteKey => { + this.scene.CurrentPlayer.playEmote(emoteKey); + }) + }); + } + + lazyLoadEmoteTexture(textureKey: string): Promise { + const emoteDescriptor = emotes[textureKey]; + if (emoteDescriptor === undefined) { + throw 'Emote not found!'; + } + const loadPromise = createLoadingPromise(this.scene.load, emoteDescriptor, { + frameWidth: 32, + frameHeight: 32, + }); + this.scene.load.start(); + return loadPromise.then(() => { + const frameConfig = emoteDescriptor.type === RegisteredEmoteTypes.short ? {frames: [0,1,2]} : {frames : [0,1,2,3,4,5,6,7]}; + this.scene.anims.create({ + key: getEmoteAnimName(textureKey), + frames: this.scene.anims.generateFrameNumbers(textureKey, frameConfig), + frameRate: 3, + repeat: 2, + }); + return textureKey; + }); + } +} \ No newline at end of file diff --git a/front/src/Phaser/Game/GameScene.ts b/front/src/Phaser/Game/GameScene.ts index 748897c5..d7b635c0 100644 --- a/front/src/Phaser/Game/GameScene.ts +++ b/front/src/Phaser/Game/GameScene.ts @@ -91,6 +91,7 @@ import {touchScreenManager} from "../../Touch/TouchScreenManager"; import {PinchManager} from "../UserInput/PinchManager"; import {joystickBaseImg, joystickBaseKey, joystickThumbImg, joystickThumbKey} from "../Components/MobileJoystick"; import {waScaleManager} from "../Services/WaScaleManager"; +import {EmoteManager} from "./EmoteManager"; export interface GameSceneInitInterface { initPosition: PointInterface|null, @@ -189,6 +190,7 @@ export class GameScene extends DirtyScene implements CenterListener { private physicsEnabled: boolean = true; private mapTransitioning: boolean = false; //used to prevent transitions happenning at the same time. private onVisibilityChangeCallback: () => void; + private emoteManager!: EmoteManager; constructor(private room: Room, MapUrlFile: string, customKey?: string|undefined) { super({ @@ -226,6 +228,11 @@ export class GameScene extends DirtyScene implements CenterListener { this.load.image(joystickBaseKey, joystickBaseImg); this.load.image(joystickThumbKey, joystickThumbImg); } + //todo: in an emote manager. + this.load.spritesheet('emote-music', 'resources/emotes/pipo-popupemotes005.png', { + frameHeight: 32, + frameWidth: 32, + }); this.load.on(FILE_LOAD_ERROR, (file: {src: string}) => { // If we happen to be in HTTP and we are trying to load a URL in HTTPS only... (this happens only in dev environments) if (window.location.protocol === 'http:' && file.src === this.MapUrlFile && file.src.startsWith('http:') && this.originalMapUrl === undefined) { @@ -509,6 +516,8 @@ export class GameScene extends DirtyScene implements CenterListener { } document.addEventListener('visibilitychange', this.onVisibilityChangeCallback); + + this.emoteManager = new EmoteManager(this); } /** diff --git a/messages/protos/messages.proto b/messages/protos/messages.proto index 52ca4d50..3a5afb57 100644 --- a/messages/protos/messages.proto +++ b/messages/protos/messages.proto @@ -66,6 +66,15 @@ message ReportPlayerMessage { string reportComment = 2; } +message EmotePromptMessage { + string emote = 2; +} + +message EmoteEventMessage { + int32 actorUserId = 1; + string emote = 2; +} + message QueryJitsiJwtMessage { string jitsiRoom = 1; string tag = 2; // FIXME: rather than reading the tag from the query, we should read it from the current map! @@ -84,6 +93,7 @@ message ClientToServerMessage { StopGlobalMessage stopGlobalMessage = 10; ReportPlayerMessage reportPlayerMessage = 11; QueryJitsiJwtMessage queryJitsiJwtMessage = 12; + EmotePromptMessage emotePromptMessage = 13; } } @@ -122,6 +132,7 @@ message SubMessage { UserJoinedMessage userJoinedMessage = 4; UserLeftMessage userLeftMessage = 5; ItemEventMessage itemEventMessage = 6; + EmoteEventMessage emoteEventMessage = 7; } } @@ -247,6 +258,7 @@ message ServerToClientMessage { WorldFullMessage worldFullMessage = 16; RefreshRoomMessage refreshRoomMessage = 17; WorldConnexionMessage worldConnexionMessage = 18; + EmoteEventMessage emoteEventMessage = 19; } } @@ -317,6 +329,7 @@ message PusherToBackMessage { QueryJitsiJwtMessage queryJitsiJwtMessage = 11; SendUserMessage sendUserMessage = 12; BanUserMessage banUserMessage = 13; + EmotePromptMessage emotePromptMessage = 14; } } @@ -334,6 +347,7 @@ message SubToPusherMessage { ItemEventMessage itemEventMessage = 6; SendUserMessage sendUserMessage = 7; BanUserMessage banUserMessage = 8; + EmoteEventMessage emoteEventMessage = 9; } } diff --git a/pusher/src/Controller/IoSocketController.ts b/pusher/src/Controller/IoSocketController.ts index b3e38e03..15be68c7 100644 --- a/pusher/src/Controller/IoSocketController.ts +++ b/pusher/src/Controller/IoSocketController.ts @@ -12,7 +12,8 @@ import { WebRtcSignalToServerMessage, PlayGlobalMessage, ReportPlayerMessage, - QueryJitsiJwtMessage, SendUserMessage, ServerToClientMessage, CompanionMessage + EmoteEventMessage, + QueryJitsiJwtMessage, SendUserMessage, ServerToClientMessage, CompanionMessage, EmotePromptMessage } from "../Messages/generated/messages_pb"; import {UserMovesMessage} from "../Messages/generated/messages_pb"; import {TemplatedApp} from "uWebSockets.js" @@ -330,6 +331,8 @@ export class IoSocketController { socketManager.handleReportMessage(client, message.getReportplayermessage() as ReportPlayerMessage); } else if (message.hasQueryjitsijwtmessage()){ socketManager.handleQueryJitsiJwtMessage(client, message.getQueryjitsijwtmessage() as QueryJitsiJwtMessage); + } else if (message.hasEmotepromptmessage()){ + socketManager.handleEmotePromptMessage(client, message.getEmotepromptmessage() as EmotePromptMessage); } /* Ok is false if backpressure was built up, wait for drain */ diff --git a/pusher/src/Model/Zone.ts b/pusher/src/Model/Zone.ts index 3f39a5ed..5c50ef00 100644 --- a/pusher/src/Model/Zone.ts +++ b/pusher/src/Model/Zone.ts @@ -6,13 +6,11 @@ import { PointMessage, PositionMessage, UserJoinedMessage, UserJoinedZoneMessage, UserLeftZoneMessage, UserMovedMessage, ZoneMessage, + EmoteEventMessage, CompanionMessage } from "../Messages/generated/messages_pb"; -import * as messages_pb from "../Messages/generated/messages_pb"; import {ClientReadableStream} from "grpc"; import {PositionDispatcher} from "_Model/PositionDispatcher"; -import {socketManager} from "../Services/SocketManager"; -import {ProtobufUtils} from "_Model/Websocket/ProtobufUtils"; import Debug from "debug"; const debug = Debug("zone"); @@ -24,6 +22,7 @@ export interface ZoneEventListener { onGroupEnters(group: GroupDescriptor, listener: ExSocketInterface): void; onGroupMoves(group: GroupDescriptor, listener: ExSocketInterface): void; onGroupLeaves(groupId: number, listener: ExSocketInterface): void; + onEmote(emoteMessage: EmoteEventMessage, listener: ExSocketInterface): void; } /*export type EntersCallback = (thing: Movable, listener: User) => void; @@ -184,6 +183,9 @@ export class Zone { userDescriptor.update(userMovedMessage); this.notifyUserMove(userDescriptor); + } else if(message.hasEmoteeventmessage()) { + const emoteEventMessage = message.getEmoteeventmessage() as EmoteEventMessage; + this.notifyEmote(emoteEventMessage); } else { throw new Error('Unexpected message'); } @@ -262,6 +264,15 @@ export class Zone { } } + private notifyEmote(emoteMessage: EmoteEventMessage) { + for (const listener of this.listeners) { + if (listener.userId === emoteMessage.getActoruserid()) { + continue; + } + this.socketListener.onEmote(emoteMessage, listener); + } + } + /** * Notify listeners of this zone that this group left */ diff --git a/pusher/src/Services/SocketManager.ts b/pusher/src/Services/SocketManager.ts index d692186a..78bbe330 100644 --- a/pusher/src/Services/SocketManager.ts +++ b/pusher/src/Services/SocketManager.ts @@ -23,7 +23,8 @@ import { WorldConnexionMessage, AdminPusherToBackMessage, ServerToAdminClientMessage, - UserJoinedRoomMessage, UserLeftRoomMessage, AdminMessage, BanMessage, RefreshRoomMessage + EmoteEventMessage, + UserJoinedRoomMessage, UserLeftRoomMessage, AdminMessage, BanMessage, RefreshRoomMessage, EmotePromptMessage } from "../Messages/generated/messages_pb"; import {ProtobufUtils} from "../Model/Websocket/ProtobufUtils"; import {JITSI_ISS, SECRET_JITSI_KEY} from "../Enum/EnvironmentVariable"; @@ -254,6 +255,15 @@ export class SocketManager implements ZoneEventListener { this.handleViewport(client, viewport.toObject()) } + + + onEmote(emoteMessage: EmoteEventMessage, listener: ExSocketInterface): void { + const subMessage = new SubMessage(); + subMessage.setEmoteeventmessage(emoteMessage); + + emitInBatch(listener, subMessage); + } + // Useless now, will be useful again if we allow editing details in game handleSetPlayerDetails(client: ExSocketInterface, playerDetailsMessage: SetPlayerDetailsMessage) { const pusherToBackMessage = new PusherToBackMessage(); @@ -578,6 +588,13 @@ export class SocketManager implements ZoneEventListener { this.updateRoomWithAdminData(room); } + + handleEmotePromptMessage(client: ExSocketInterface, emoteEventmessage: EmotePromptMessage) { + const pusherToBackMessage = new PusherToBackMessage(); + pusherToBackMessage.setEmotepromptmessage(emoteEventmessage); + + client.backConnection.write(pusherToBackMessage); + } } export const socketManager = new SocketManager(); From 35b37a6a88cb57600dd5f3290b62b6206ca8184d Mon Sep 17 00:00:00 2001 From: kharhamel Date: Mon, 10 May 2021 17:10:41 +0200 Subject: [PATCH 08/20] Added a radial menu to run emotes --- CHANGELOG.md | 4 ++ front/src/Phaser/Components/ChatModeIcon.ts | 4 +- front/src/Phaser/Components/MobileJoystick.ts | 5 +- front/src/Phaser/Components/OpenChatIcon.ts | 3 +- .../Phaser/Components/PresentationModeIcon.ts | 4 +- front/src/Phaser/Components/RadialMenu.ts | 58 +++++++++++++++++++ front/src/Phaser/Entity/Character.ts | 50 +++++++++++----- front/src/Phaser/Entity/RemotePlayer.ts | 12 ++-- front/src/Phaser/Game/DepthIndexes.ts | 8 +++ front/src/Phaser/Game/EmoteManager.ts | 46 ++++++++------- front/src/Phaser/Game/GameScene.ts | 13 ++++- front/src/Phaser/Player/Player.ts | 45 ++++++++++---- pusher/src/Services/SocketManager.ts | 2 + 13 files changed, 191 insertions(+), 63 deletions(-) create mode 100644 front/src/Phaser/Components/RadialMenu.ts create mode 100644 front/src/Phaser/Game/DepthIndexes.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 2028e3b7..e5d9138a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ ### Updates +- Added the emote feature to Workadventure. (@Kharhamel, @Tabascoeye) + - The emote menu can be opened by clicking on your character. + - Clicking on one of its element will close the menu and play an emote above your character. + - This emote can be seen by other players. - Mobile support has been improved - WorkAdventure automatically sets the zoom level based on the viewport size to ensure a sensible size of the map is visible, whatever the viewport used - Mouse wheel support to zoom in / out diff --git a/front/src/Phaser/Components/ChatModeIcon.ts b/front/src/Phaser/Components/ChatModeIcon.ts index 932a4d88..69449a1d 100644 --- a/front/src/Phaser/Components/ChatModeIcon.ts +++ b/front/src/Phaser/Components/ChatModeIcon.ts @@ -1,3 +1,5 @@ +import { DEPTH_INGAME_TEXT_INDEX } from "../Game/DepthIndexes"; + export class ChatModeIcon extends Phaser.GameObjects.Sprite { constructor(scene: Phaser.Scene, x: number, y: number) { super(scene, x, y, 'layout_modes', 3); @@ -6,6 +8,6 @@ export class ChatModeIcon extends Phaser.GameObjects.Sprite { this.setOrigin(0, 1); this.setInteractive(); this.setVisible(false); - this.setDepth(99999); + this.setDepth(DEPTH_INGAME_TEXT_INDEX); } } \ No newline at end of file diff --git a/front/src/Phaser/Components/MobileJoystick.ts b/front/src/Phaser/Components/MobileJoystick.ts index fced71da..46efcbc2 100644 --- a/front/src/Phaser/Components/MobileJoystick.ts +++ b/front/src/Phaser/Components/MobileJoystick.ts @@ -1,5 +1,6 @@ import VirtualJoystick from 'phaser3-rex-plugins/plugins/virtualjoystick.js'; import {waScaleManager} from "../Services/WaScaleManager"; +import {DEPTH_INGAME_TEXT_INDEX} from "../Game/DepthIndexes"; //the assets were found here: https://hannemann.itch.io/virtual-joystick-pack-free export const joystickBaseKey = 'joystickBase'; @@ -19,8 +20,8 @@ export class MobileJoystick extends VirtualJoystick { x: -1000, y: -1000, radius: radius * window.devicePixelRatio, - base: scene.add.image(0, 0, joystickBaseKey).setDisplaySize(baseSize * window.devicePixelRatio, baseSize * window.devicePixelRatio).setDepth(99999), - thumb: scene.add.image(0, 0, joystickThumbKey).setDisplaySize(thumbSize * window.devicePixelRatio, thumbSize * window.devicePixelRatio).setDepth(99999), + base: scene.add.image(0, 0, joystickBaseKey).setDisplaySize(baseSize * window.devicePixelRatio, baseSize * window.devicePixelRatio).setDepth(DEPTH_INGAME_TEXT_INDEX), + thumb: scene.add.image(0, 0, joystickThumbKey).setDisplaySize(thumbSize * window.devicePixelRatio, thumbSize * window.devicePixelRatio).setDepth(DEPTH_INGAME_TEXT_INDEX), enable: true, dir: "8dir", }); diff --git a/front/src/Phaser/Components/OpenChatIcon.ts b/front/src/Phaser/Components/OpenChatIcon.ts index 1e9429e8..ab07a80c 100644 --- a/front/src/Phaser/Components/OpenChatIcon.ts +++ b/front/src/Phaser/Components/OpenChatIcon.ts @@ -1,4 +1,5 @@ import {discussionManager} from "../../WebRtc/DiscussionManager"; +import {DEPTH_INGAME_TEXT_INDEX} from "../Game/DepthIndexes"; export const openChatIconName = 'openChatIcon'; export class OpenChatIcon extends Phaser.GameObjects.Image { @@ -9,7 +10,7 @@ export class OpenChatIcon extends Phaser.GameObjects.Image { this.setOrigin(0, 1); this.setInteractive(); this.setVisible(false); - this.setDepth(99999); + this.setDepth(DEPTH_INGAME_TEXT_INDEX); this.on("pointerup", () => discussionManager.showDiscussionPart()); } diff --git a/front/src/Phaser/Components/PresentationModeIcon.ts b/front/src/Phaser/Components/PresentationModeIcon.ts index 49ff2ea1..09c8beb5 100644 --- a/front/src/Phaser/Components/PresentationModeIcon.ts +++ b/front/src/Phaser/Components/PresentationModeIcon.ts @@ -1,3 +1,5 @@ +import {DEPTH_INGAME_TEXT_INDEX} from "../Game/DepthIndexes"; + export class PresentationModeIcon extends Phaser.GameObjects.Sprite { constructor(scene: Phaser.Scene, x: number, y: number) { super(scene, x, y, 'layout_modes', 0); @@ -6,6 +8,6 @@ export class PresentationModeIcon extends Phaser.GameObjects.Sprite { this.setOrigin(0, 1); this.setInteractive(); this.setVisible(false); - this.setDepth(99999); + this.setDepth(DEPTH_INGAME_TEXT_INDEX); } } \ No newline at end of file diff --git a/front/src/Phaser/Components/RadialMenu.ts b/front/src/Phaser/Components/RadialMenu.ts new file mode 100644 index 00000000..a2a646f5 --- /dev/null +++ b/front/src/Phaser/Components/RadialMenu.ts @@ -0,0 +1,58 @@ +import Sprite = Phaser.GameObjects.Sprite; +import {DEPTH_UI_INDEX} from "../Game/DepthIndexes"; + +export interface RadialMenuItem { + sprite: string, + frame: number, + name: string, +} + +const menuRadius = 80; +export const RadialMenuClickEvent = 'radialClick'; + +export class RadialMenu extends Phaser.GameObjects.Container { + + constructor(scene: Phaser.Scene, x: number, y: number, private items: RadialMenuItem[]) { + super(scene, x, y); + this.setDepth(DEPTH_UI_INDEX) + this.scene.add.existing(this); + this.initItems(); + } + + private initItems() { + const itemsNumber = this.items.length; + this.items.forEach((item, index) => this.createRadialElement(item, index, itemsNumber)) + } + + private createRadialElement(item: RadialMenuItem, index: number, itemsNumber: number) { + const image = new Sprite(this.scene, 0, menuRadius, item.sprite, item.frame); + this.add(image); + this.scene.sys.updateList.add(image); + image.setDepth(DEPTH_UI_INDEX) + image.setInteractive({ + hitArea: new Phaser.Geom.Circle(0, 0, 25), + hitAreaCallback: Phaser.Geom.Circle.Contains, //eslint-disable-line @typescript-eslint/unbound-method + useHandCursor: true, + }); + image.on('pointerdown', () => this.emit(RadialMenuClickEvent, item)); + image.on('pointerover', () => { + this.scene.tweens.add({ + targets: image, + scale: 2, + duration: 500, + ease: 'Power3', + }) + }); + image.on('pointerout', () => { + this.scene.tweens.add({ + targets: image, + scale: 1, + duration: 500, + ease: 'Power3', + }) + }); + const angle = 2 * Math.PI * index / itemsNumber; + Phaser.Actions.RotateAroundDistance([image], {x: 0, y: 0}, angle, menuRadius); + } + +} \ No newline at end of file diff --git a/front/src/Phaser/Entity/Character.ts b/front/src/Phaser/Entity/Character.ts index bb8c2fb0..bc536eb4 100644 --- a/front/src/Phaser/Entity/Character.ts +++ b/front/src/Phaser/Entity/Character.ts @@ -6,6 +6,8 @@ import Sprite = Phaser.GameObjects.Sprite; import {TextureError} from "../../Exception/TextureError"; import {Companion} from "../Companion/Companion"; import {getEmoteAnimName} from "../Game/EmoteManager"; +import {GameScene} from "../Game/GameScene"; +import {DEPTH_INGAME_TEXT_INDEX} from "../Game/DepthIndexes"; const playerNameY = - 25; @@ -17,6 +19,8 @@ interface AnimationData { frames : number[] } +const interactiveRadius = 40; + export abstract class Character extends Container { private bubble: SpeechBubble|null = null; private readonly playerName: BitmapText; @@ -28,14 +32,16 @@ export abstract class Character extends Container { public companion?: Companion; private emote: Phaser.GameObjects.Sprite | null = null; - constructor(scene: Phaser.Scene, + constructor(scene: GameScene, x: number, y: number, texturesPromise: Promise, name: string, direction: PlayerAnimationDirections, moving: boolean, - frame?: string | number + frame: string | number, + companion: string|null, + companionTexturePromise?: Promise ) { super(scene, x, y/*, texture, frame*/); this.PlayerValue = name; @@ -49,19 +55,18 @@ export abstract class Character extends Container { this.invisible = false }) - /*this.teleportation = new Sprite(scene, -20, -10, 'teleportation', 3); - this.teleportation.setInteractive(); - this.teleportation.visible = false; - this.teleportation.on('pointerup', () => { - this.report.visible = false; - this.teleportation.visible = false; - }); - this.add(this.teleportation);*/ - this.playerName = new BitmapText(scene, 0, playerNameY, 'main_font', name, 7); - this.playerName.setOrigin(0.5).setCenterAlign().setDepth(99999); + this.playerName.setOrigin(0.5).setCenterAlign().setDepth(DEPTH_INGAME_TEXT_INDEX); this.add(this.playerName); + if (this.isClickable()) { + this.setInteractive({ + hitArea: new Phaser.Geom.Circle(0, 0, interactiveRadius), + hitAreaCallback: Phaser.Geom.Circle.Contains, //eslint-disable-line @typescript-eslint/unbound-method + useHandCursor: true, + }); + } + scene.add.existing(this); this.scene.physics.world.enableBody(this); @@ -73,6 +78,10 @@ export abstract class Character extends Container { this.setDepth(-1); this.playAnimation(direction, moving); + + if (typeof companion === 'string') { + this.addCompanion(companion, companionTexturePromise); + } } public addCompanion(name: string, texturePromise?: Promise): void { @@ -80,6 +89,8 @@ export abstract class Character extends Container { this.companion = new Companion(this.scene, this.x, this.y, name, texturePromise); } } + + public abstract isClickable(): boolean; public addTextures(textures: string[], frame?: string | number): void { for (const texture of textures) { @@ -87,7 +98,6 @@ export abstract class Character extends Container { throw new TextureError('texture not found'); } 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({ @@ -234,18 +244,26 @@ export abstract class Character extends Container { } playEmote(emoteKey: string) { - if (this.emote) return; + this.cancelPreviousEmote(); this.playerName.setVisible(false); this.emote = new Sprite(this.scene, 0, -40, emoteKey, 1); - this.emote.setDepth(99999); + this.emote.setDepth(DEPTH_INGAME_TEXT_INDEX); this.add(this.emote); this.scene.sys.updateList.add(this.emote); this.emote.play(getEmoteAnimName(emoteKey)); - this.emote.on(Phaser.Animations.Events.SPRITE_ANIMATION_COMPLETE, () => { + this.emote.on(Phaser.Animations.Events.ANIMATION_COMPLETE, () => { this.emote?.destroy(); this.emote = null; this.playerName.setVisible(true); }); } + + cancelPreviousEmote() { + if (!this.emote) return; + + this.emote?.destroy(); + this.emote = null; + this.playerName.setVisible(true); + } } diff --git a/front/src/Phaser/Entity/RemotePlayer.ts b/front/src/Phaser/Entity/RemotePlayer.ts index 4787d1f2..4e00f102 100644 --- a/front/src/Phaser/Entity/RemotePlayer.ts +++ b/front/src/Phaser/Entity/RemotePlayer.ts @@ -21,14 +21,10 @@ export class RemotePlayer extends Character { companion: string|null, companionTexturePromise?: Promise ) { - super(Scene, x, y, texturesPromise, name, direction, moving, 1); - + super(Scene, x, y, texturesPromise, name, direction, moving, 1, companion, companionTexturePromise); + //set data this.userId = userId; - - if (typeof companion === 'string') { - this.addCompanion(companion, companionTexturePromise); - } } updatePosition(position: PointInterface): void { @@ -42,4 +38,8 @@ export class RemotePlayer extends Character { this.companion.setTarget(position.x, position.y, position.direction as PlayerAnimationDirections); } } + + isClickable(): boolean { + return false; //todo: make remote players clickable if they are logged in. + } } diff --git a/front/src/Phaser/Game/DepthIndexes.ts b/front/src/Phaser/Game/DepthIndexes.ts new file mode 100644 index 00000000..d2d38328 --- /dev/null +++ b/front/src/Phaser/Game/DepthIndexes.ts @@ -0,0 +1,8 @@ +//this file contains all the depth indexes which will be used in our game + +export const DEPTH_TILE_INDEX = 0; +//Note: Player characters use their y coordinate as their depth to simulate a perspective. +//See the Character class. +export const DEPTH_OVERLAY_INDEX = 10000; +export const DEPTH_INGAME_TEXT_INDEX = 100000; +export const DEPTH_UI_INDEX = 1000000; diff --git a/front/src/Phaser/Game/EmoteManager.ts b/front/src/Phaser/Game/EmoteManager.ts index b33952fe..0256f458 100644 --- a/front/src/Phaser/Game/EmoteManager.ts +++ b/front/src/Phaser/Game/EmoteManager.ts @@ -2,6 +2,7 @@ import {BodyResourceDescriptionInterface} from "../Entity/PlayerTextures"; import {createLoadingPromise} from "../Entity/PlayerTexturesLoadingManager"; import {emoteEventStream} from "../../Connexion/EmoteEventStream"; import {GameScene} from "./GameScene"; +import {RadialMenuItem} from "../Components/RadialMenu"; enum RegisteredEmoteTypes { short = 1, @@ -14,10 +15,11 @@ interface RegisteredEmote extends BodyResourceDescriptionInterface { type: RegisteredEmoteTypes } +//the last 3 emotes are courtesy of @tabascoeye export const emotes: {[key: string]: RegisteredEmote} = { - 'emote-exclamation': {name: 'emote-exclamation', img: 'resources/emotes/pipo-popupemotes001.png', type: RegisteredEmoteTypes.short}, + 'emote-exclamation': {name: 'emote-exclamation', img: 'resources/emotes/pipo-popupemotes001.png', type: RegisteredEmoteTypes.short, }, 'emote-interrogation': {name: 'emote-interrogation', img: 'resources/emotes/pipo-popupemotes002.png', type: RegisteredEmoteTypes.short}, - 'emote-sleep': {name: 'emote-sleep', img: 'resources/emotes/pipo-popupemotes002.png', type: RegisteredEmoteTypes.short}, + 'emote-sleep': {name: 'emote-sleep', img: 'resources/emotes/pipo-popupemotes021.png', type: RegisteredEmoteTypes.short}, 'emote-clap': {name: 'emote-clap', img: 'resources/emotes/taba-clap-emote.png', type: RegisteredEmoteTypes.short}, 'emote-thumbsdown': {name: 'emote-thumbsdown', img: 'resources/emotes/taba-thumbsdown-emote.png', type: RegisteredEmoteTypes.long}, 'emote-thumbsup': {name: 'emote-thumbsup', img: 'resources/emotes/taba-thumbsup-emote.png', type: RegisteredEmoteTypes.long}, @@ -30,16 +32,6 @@ export const getEmoteAnimName = (emoteKey: string): string => { export class EmoteManager { constructor(private scene: GameScene) { - - //todo: use a radial menu instead? - this.registerEmoteOnKey('keyup-Y', 'emote-clap'); - this.registerEmoteOnKey('keyup-U', 'emote-thumbsup'); - this.registerEmoteOnKey('keyup-I', 'emote-thumbsdown'); - this.registerEmoteOnKey('keyup-O', 'emote-exclamation'); - this.registerEmoteOnKey('keyup-P', 'emote-interrogation'); - this.registerEmoteOnKey('keyup-T', 'emote-sleep'); - - emoteEventStream.stream.subscribe((event) => { const actor = this.scene.MapPlayersByKey.get(event.userId); if (actor) { @@ -49,16 +41,7 @@ export class EmoteManager { } }) } - - private registerEmoteOnKey(keyboardKey: string, emoteKey: string) { - this.scene.input.keyboard.on(keyboardKey, () => { - this.scene.connection?.emitEmoteEvent(emoteKey); - this.lazyLoadEmoteTexture(emoteKey).then(emoteKey => { - this.scene.CurrentPlayer.playEmote(emoteKey); - }) - }); - } - + lazyLoadEmoteTexture(textureKey: string): Promise { const emoteDescriptor = emotes[textureKey]; if (emoteDescriptor === undefined) { @@ -70,6 +53,9 @@ export class EmoteManager { }); this.scene.load.start(); return loadPromise.then(() => { + if (this.scene.anims.exists(getEmoteAnimName(textureKey))) { + return Promise.resolve(textureKey); + } const frameConfig = emoteDescriptor.type === RegisteredEmoteTypes.short ? {frames: [0,1,2]} : {frames : [0,1,2,3,4,5,6,7]}; this.scene.anims.create({ key: getEmoteAnimName(textureKey), @@ -80,4 +66,20 @@ export class EmoteManager { return textureKey; }); } + + getMenuImages(): Promise { + const promises = []; + for (const key in emotes) { + const promise = this.lazyLoadEmoteTexture(key).then((textureKey) => { + const emoteDescriptor = emotes[textureKey]; + return { + sprite: textureKey, + name: textureKey, + frame: emoteDescriptor.type === RegisteredEmoteTypes.short ? 1 : 4, + } + }); + promises.push(promise); + } + return Promise.all(promises); + } } \ No newline at end of file diff --git a/front/src/Phaser/Game/GameScene.ts b/front/src/Phaser/Game/GameScene.ts index d7b635c0..b6b3e57e 100644 --- a/front/src/Phaser/Game/GameScene.ts +++ b/front/src/Phaser/Game/GameScene.ts @@ -9,7 +9,7 @@ import type { PositionInterface, RoomJoinedMessageInterface } from "../../Connexion/ConnexionModels"; -import {CurrentGamerInterface, hasMovedEventName, Player} from "../Player/Player"; +import {hasMovedEventName, Player, requestEmoteEventName} from "../Player/Player"; import { DEBUG_MODE, JITSI_PRIVATE_MODE, @@ -90,6 +90,7 @@ import {TextUtils} from "../Components/TextUtils"; import {touchScreenManager} from "../../Touch/TouchScreenManager"; import {PinchManager} from "../UserInput/PinchManager"; import {joystickBaseImg, joystickBaseKey, joystickThumbImg, joystickThumbKey} from "../Components/MobileJoystick"; +import {DEPTH_OVERLAY_INDEX} from "./DepthIndexes"; import {waScaleManager} from "../Services/WaScaleManager"; import {EmoteManager} from "./EmoteManager"; @@ -132,7 +133,7 @@ const defaultStartLayerName = 'start'; export class GameScene extends DirtyScene implements CenterListener { Terrains : Array; - CurrentPlayer!: CurrentGamerInterface; + CurrentPlayer!: Player; MapPlayers!: Phaser.Physics.Arcade.Group; MapPlayersByKey : Map = new Map(); Map!: Phaser.Tilemaps.Tilemap; @@ -428,7 +429,7 @@ export class GameScene extends DirtyScene implements CenterListener { } } if (layer.type === 'objectgroup' && layer.name === 'floorLayer') { - depth = 10000; + depth = DEPTH_OVERLAY_INDEX; } if (layer.type === 'objectgroup') { for (const object of layer.objects) { @@ -1132,6 +1133,12 @@ ${escapedMessage} this.companion, this.companion !== null ? lazyLoadCompanionResource(this.load, this.companion) : undefined ); + this.CurrentPlayer.on('pointerdown', () => { + this.emoteManager.getMenuImages().then((emoteMenuElements) => this.CurrentPlayer.openOrCloseEmoteMenu(emoteMenuElements)) + }) + this.CurrentPlayer.on(requestEmoteEventName, (emoteKey: string) => { + this.connection?.emitEmoteEvent(emoteKey); + }) }catch (err){ if(err instanceof TextureError) { gameManager.leaveGame(this, SelectCharacterSceneName, new SelectCharacterScene()); diff --git a/front/src/Phaser/Player/Player.ts b/front/src/Phaser/Player/Player.ts index 6044ba84..e93b25c7 100644 --- a/front/src/Phaser/Player/Player.ts +++ b/front/src/Phaser/Player/Player.ts @@ -2,17 +2,15 @@ import {PlayerAnimationDirections} from "./Animation"; import type {GameScene} from "../Game/GameScene"; import {UserInputEvent, UserInputManager} from "../UserInput/UserInputManager"; import {Character} from "../Entity/Character"; +import {RadialMenu, RadialMenuClickEvent, RadialMenuItem} from "../Components/RadialMenu"; export const hasMovedEventName = "hasMoved"; -export interface CurrentGamerInterface extends Character{ - moveUser(delta: number) : void; - say(text : string) : void; - isMoving(): boolean; -} +export const requestEmoteEventName = "requestEmote"; -export class Player extends Character implements CurrentGamerInterface { +export class Player extends Character { private previousDirection: string = PlayerAnimationDirections.Down; private wasMoving: boolean = false; + private emoteMenu: RadialMenu|null = null; constructor( Scene: GameScene, @@ -26,14 +24,10 @@ export class Player extends Character implements CurrentGamerInterface { companion: string|null, companionTexturePromise?: Promise ) { - super(Scene, x, y, texturesPromise, name, direction, moving, 1); + super(Scene, x, y, texturesPromise, name, direction, moving, 1, companion, companionTexturePromise); //the current player model should be push away by other players to prevent conflict this.getBody().setImmovable(false); - - if (typeof companion === 'string') { - this.addCompanion(companion, companionTexturePromise); - } } moveUser(delta: number): void { @@ -88,4 +82,33 @@ export class Player extends Character implements CurrentGamerInterface { public isMoving(): boolean { return this.wasMoving; } + + openOrCloseEmoteMenu(emotes:RadialMenuItem[]) { + if(this.emoteMenu) { + this.closeEmoteMenu(); + } else { + this.openEmoteMenu(emotes); + } + } + + isClickable(): boolean { + return true; + } + + openEmoteMenu(emotes:RadialMenuItem[]): void { + this.cancelPreviousEmote(); + this.emoteMenu = new RadialMenu(this.scene, 0, 0, emotes) + this.emoteMenu.on(RadialMenuClickEvent, (item: RadialMenuItem) => { + this.closeEmoteMenu(); + this.emit(requestEmoteEventName, item.name); + this.playEmote(item.name); + }) + this.add(this.emoteMenu); + } + + closeEmoteMenu(): void { + if (!this.emoteMenu) return; + this.emoteMenu.destroy(); + this.emoteMenu = null; + } } diff --git a/pusher/src/Services/SocketManager.ts b/pusher/src/Services/SocketManager.ts index 78bbe330..3bf8467a 100644 --- a/pusher/src/Services/SocketManager.ts +++ b/pusher/src/Services/SocketManager.ts @@ -74,6 +74,7 @@ export class SocketManager implements ZoneEventListener { client.adminConnection = adminRoomStream; adminRoomStream.on('data', (message: ServerToAdminClientMessage) => { + if (message.hasUserjoinedroom()) { const userJoinedRoomMessage = message.getUserjoinedroom() as UserJoinedRoomMessage; if (!client.disconnecting) { @@ -331,6 +332,7 @@ export class SocketManager implements ZoneEventListener { const room: PusherRoom | undefined = this.rooms.get(socket.roomId); if (room) { debug('Leaving room %s.', socket.roomId); + room.leave(socket); if (room.isEmpty()) { this.rooms.delete(socket.roomId); From d93b30f9820d719bcd9f5ca469ce12d6d5bfca54 Mon Sep 17 00:00:00 2001 From: kharhamel Date: Wed, 19 May 2021 18:08:53 +0200 Subject: [PATCH 09/20] improved radial menu --- back/src/Services/SocketManager.ts | 3 --- front/src/Phaser/Components/RadialMenu.ts | 18 ++++++++++++++++-- front/src/Phaser/Entity/Character.ts | 8 +++++--- front/src/Phaser/Game/EmoteManager.ts | 10 +++++----- front/src/Phaser/Player/Player.ts | 19 ++++++++++++++++--- front/src/Phaser/Services/WaScaleManager.ts | 9 +++++++++ 6 files changed, 51 insertions(+), 16 deletions(-) diff --git a/back/src/Services/SocketManager.ts b/back/src/Services/SocketManager.ts index 5d5dcf03..dd40b951 100644 --- a/back/src/Services/SocketManager.ts +++ b/back/src/Services/SocketManager.ts @@ -74,9 +74,6 @@ export class SocketManager { clientEventsEmitter.registerToClientLeave((clientUUid: string, roomId: string) => { gaugeManager.decNbClientPerRoomGauge(roomId); }); - - - //zoneMessageStream.stream.subscribe(myMessage); } public async handleJoinRoom(socket: UserSocket, joinRoomMessage: JoinRoomMessage): Promise<{ room: GameRoom; user: User }> { diff --git a/front/src/Phaser/Components/RadialMenu.ts b/front/src/Phaser/Components/RadialMenu.ts index a2a646f5..d566258c 100644 --- a/front/src/Phaser/Components/RadialMenu.ts +++ b/front/src/Phaser/Components/RadialMenu.ts @@ -1,5 +1,6 @@ import Sprite = Phaser.GameObjects.Sprite; import {DEPTH_UI_INDEX} from "../Game/DepthIndexes"; +import {waScaleManager} from "../Services/WaScaleManager"; export interface RadialMenuItem { sprite: string, @@ -7,16 +8,21 @@ export interface RadialMenuItem { name: string, } -const menuRadius = 80; +const menuRadius = 60; export const RadialMenuClickEvent = 'radialClick'; export class RadialMenu extends Phaser.GameObjects.Container { + private resizeCallback: OmitThisParameter<() => void>; constructor(scene: Phaser.Scene, x: number, y: number, private items: RadialMenuItem[]) { super(scene, x, y); this.setDepth(DEPTH_UI_INDEX) this.scene.add.existing(this); this.initItems(); + + this.resize(); + this.resizeCallback = this.resize.bind(this); + this.scene.scale.on(Phaser.Scale.Events.RESIZE, this.resizeCallback); } private initItems() { @@ -54,5 +60,13 @@ export class RadialMenu extends Phaser.GameObjects.Container { const angle = 2 * Math.PI * index / itemsNumber; Phaser.Actions.RotateAroundDistance([image], {x: 0, y: 0}, angle, menuRadius); } - + + private resize() { + this.setScale(waScaleManager.uiScalingFactor); + } + + public destroy() { + this.scene.scale.removeListener(Phaser.Scale.Events.RESIZE, this.resizeCallback); + super.destroy(); + } } \ No newline at end of file diff --git a/front/src/Phaser/Entity/Character.ts b/front/src/Phaser/Entity/Character.ts index bc536eb4..1975182c 100644 --- a/front/src/Phaser/Entity/Character.ts +++ b/front/src/Phaser/Entity/Character.ts @@ -6,8 +6,9 @@ import Sprite = Phaser.GameObjects.Sprite; import {TextureError} from "../../Exception/TextureError"; import {Companion} from "../Companion/Companion"; import {getEmoteAnimName} from "../Game/EmoteManager"; -import {GameScene} from "../Game/GameScene"; +import type {GameScene} from "../Game/GameScene"; import {DEPTH_INGAME_TEXT_INDEX} from "../Game/DepthIndexes"; +import {waScaleManager} from "../Services/WaScaleManager"; const playerNameY = - 25; @@ -19,7 +20,7 @@ interface AnimationData { frames : number[] } -const interactiveRadius = 40; +const interactiveRadius = 35; export abstract class Character extends Container { private bubble: SpeechBubble|null = null; @@ -247,8 +248,9 @@ export abstract class Character extends Container { this.cancelPreviousEmote(); this.playerName.setVisible(false); - this.emote = new Sprite(this.scene, 0, -40, emoteKey, 1); + this.emote = new Sprite(this.scene, 0, -30 - waScaleManager.uiScalingFactor * 10, emoteKey, 1); this.emote.setDepth(DEPTH_INGAME_TEXT_INDEX); + this.emote.setScale(waScaleManager.uiScalingFactor) this.add(this.emote); this.scene.sys.updateList.add(this.emote); this.emote.play(getEmoteAnimName(emoteKey)); diff --git a/front/src/Phaser/Game/EmoteManager.ts b/front/src/Phaser/Game/EmoteManager.ts index 0256f458..5d8d7179 100644 --- a/front/src/Phaser/Game/EmoteManager.ts +++ b/front/src/Phaser/Game/EmoteManager.ts @@ -1,8 +1,8 @@ -import {BodyResourceDescriptionInterface} from "../Entity/PlayerTextures"; +import type {BodyResourceDescriptionInterface} from "../Entity/PlayerTextures"; import {createLoadingPromise} from "../Entity/PlayerTexturesLoadingManager"; import {emoteEventStream} from "../../Connexion/EmoteEventStream"; -import {GameScene} from "./GameScene"; -import {RadialMenuItem} from "../Components/RadialMenu"; +import type {GameScene} from "./GameScene"; +import type {RadialMenuItem} from "../Components/RadialMenu"; enum RegisteredEmoteTypes { short = 1, @@ -56,11 +56,11 @@ export class EmoteManager { if (this.scene.anims.exists(getEmoteAnimName(textureKey))) { return Promise.resolve(textureKey); } - const frameConfig = emoteDescriptor.type === RegisteredEmoteTypes.short ? {frames: [0,1,2]} : {frames : [0,1,2,3,4,5,6,7]}; + const frameConfig = emoteDescriptor.type === RegisteredEmoteTypes.short ? {frames: [0,1,2,2]} : {frames : [0,1,2,3,4,]}; this.scene.anims.create({ key: getEmoteAnimName(textureKey), frames: this.scene.anims.generateFrameNumbers(textureKey, frameConfig), - frameRate: 3, + frameRate: 5, repeat: 2, }); return textureKey; diff --git a/front/src/Phaser/Player/Player.ts b/front/src/Phaser/Player/Player.ts index e93b25c7..b971407b 100644 --- a/front/src/Phaser/Player/Player.ts +++ b/front/src/Phaser/Player/Player.ts @@ -11,6 +11,7 @@ export class Player extends Character { private previousDirection: string = PlayerAnimationDirections.Down; private wasMoving: boolean = false; private emoteMenu: RadialMenu|null = null; + private updateListener: () => void; constructor( Scene: GameScene, @@ -28,6 +29,14 @@ export class Player extends Character { //the current player model should be push away by other players to prevent conflict this.getBody().setImmovable(false); + + this.updateListener = () => { + if (this.emoteMenu) { + this.emoteMenu.x = this.x; + this.emoteMenu.y = this.y; + } + }; + this.scene.events.addListener('postupdate', this.updateListener); } moveUser(delta: number): void { @@ -97,13 +106,12 @@ export class Player extends Character { openEmoteMenu(emotes:RadialMenuItem[]): void { this.cancelPreviousEmote(); - this.emoteMenu = new RadialMenu(this.scene, 0, 0, emotes) + this.emoteMenu = new RadialMenu(this.scene, this.x, this.y, emotes) this.emoteMenu.on(RadialMenuClickEvent, (item: RadialMenuItem) => { this.closeEmoteMenu(); this.emit(requestEmoteEventName, item.name); this.playEmote(item.name); - }) - this.add(this.emoteMenu); + }); } closeEmoteMenu(): void { @@ -111,4 +119,9 @@ export class Player extends Character { this.emoteMenu.destroy(); this.emoteMenu = null; } + + destroy() { + this.scene.events.removeListener('postupdate', this.updateListener); + super.destroy(); + } } diff --git a/front/src/Phaser/Services/WaScaleManager.ts b/front/src/Phaser/Services/WaScaleManager.ts index 9b013e32..ef375a39 100644 --- a/front/src/Phaser/Services/WaScaleManager.ts +++ b/front/src/Phaser/Services/WaScaleManager.ts @@ -8,6 +8,7 @@ class WaScaleManager { private hdpiManager: HdpiManager; private scaleManager!: ScaleManager; private game!: Game; + private actualZoom: number = 1; public constructor(private minGamePixelsNumber: number, private absoluteMinPixelNumber: number) { this.hdpiManager = new HdpiManager(minGamePixelsNumber, absoluteMinPixelNumber); @@ -28,6 +29,7 @@ class WaScaleManager { const { game: gameSize, real: realSize } = this.hdpiManager.getOptimalGameSize({width: width * devicePixelRatio, height: height * devicePixelRatio}); + this.actualZoom = realSize.width / gameSize.width / devicePixelRatio; this.scaleManager.setZoom(realSize.width / gameSize.width / devicePixelRatio); this.scaleManager.resize(gameSize.width, gameSize.height); @@ -48,6 +50,13 @@ class WaScaleManager { this.applyNewSize(); } + /** + * This is used to scale back the ui components to counter-act the zoom. + */ + public get uiScalingFactor(): number { + return this.actualZoom > 1 ? 1 : 2; + } + } export const waScaleManager = new WaScaleManager(640*480, 196*196); From 4d18e0ceb498cc44d2d060dc4270a79a242c3ead Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20N=C3=A9grier?= Date: Tue, 25 May 2021 10:43:01 +0200 Subject: [PATCH 10/20] Removing parsing of TSX files in "maps" container The TSX extension is used by Typescript (for JSX like files) but ALSO by Tiled (for tilesets). We don't need the Typescript TSX files so this PR is preventing Typescript from parsing those files in the "maps" container. --- maps/tsconfig.json | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/maps/tsconfig.json b/maps/tsconfig.json index 9a140744..22abe8d0 100644 --- a/maps/tsconfig.json +++ b/maps/tsconfig.json @@ -20,5 +20,8 @@ "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ "noFallthroughCasesInSwitch": true /* Report errors for fallthrough cases in switch statement. */ - } + }, + "include": [ + "**/*.ts" + ] } From 595c5ca64d62f575461e2330ed28ac42a0f334f8 Mon Sep 17 00:00:00 2001 From: kharhamel Date: Fri, 21 May 2021 16:25:12 +0200 Subject: [PATCH 11/20] now use custom emotes with tweens instead of transistions --- back/src/Services/SocketManager.ts | 1 + front/dist/resources/emotes/clap-emote.png | Bin 0 -> 15333 bytes front/dist/resources/emotes/hand-emote.png | Bin 0 -> 10840 bytes front/dist/resources/emotes/heart-emote.png | Bin 0 -> 8139 bytes .../resources/emotes/pipo-popupemotes001.png | Bin 747 -> 0 bytes .../resources/emotes/pipo-popupemotes002.png | Bin 920 -> 0 bytes .../resources/emotes/pipo-popupemotes021.png | Bin 810 -> 0 bytes .../dist/resources/emotes/taba-clap-emote.png | Bin 1305 -> 0 bytes .../emotes/taba-thumbsdown-emote.png | Bin 1981 -> 0 bytes .../resources/emotes/taba-thumbsup-emote.png | Bin 1931 -> 0 bytes front/dist/resources/emotes/thanks-emote.png | Bin 0 -> 11279 bytes .../resources/emotes/thumb-down-emote.png | Bin 0 -> 8822 bytes .../dist/resources/emotes/thumb-up-emote.png | Bin 0 -> 8842 bytes front/src/Connexion/EmoteEventStream.ts | 2 +- front/src/Connexion/RoomConnection.ts | 2 +- front/src/Phaser/Components/RadialMenu.ts | 24 +++--- front/src/Phaser/Entity/Character.ts | 70 +++++++++++++++--- front/src/Phaser/Game/EmoteManager.ts | 64 +++++++--------- front/src/Phaser/Game/GameScene.ts | 1 + front/src/Phaser/Services/WaScaleManager.ts | 2 +- pusher/src/Controller/IoSocketController.ts | 2 +- 21 files changed, 106 insertions(+), 62 deletions(-) create mode 100644 front/dist/resources/emotes/clap-emote.png create mode 100644 front/dist/resources/emotes/hand-emote.png create mode 100644 front/dist/resources/emotes/heart-emote.png delete mode 100644 front/dist/resources/emotes/pipo-popupemotes001.png delete mode 100644 front/dist/resources/emotes/pipo-popupemotes002.png delete mode 100644 front/dist/resources/emotes/pipo-popupemotes021.png delete mode 100644 front/dist/resources/emotes/taba-clap-emote.png delete mode 100644 front/dist/resources/emotes/taba-thumbsdown-emote.png delete mode 100644 front/dist/resources/emotes/taba-thumbsup-emote.png create mode 100644 front/dist/resources/emotes/thanks-emote.png create mode 100644 front/dist/resources/emotes/thumb-down-emote.png create mode 100644 front/dist/resources/emotes/thumb-up-emote.png diff --git a/back/src/Services/SocketManager.ts b/back/src/Services/SocketManager.ts index dd40b951..c58b3d9f 100644 --- a/back/src/Services/SocketManager.ts +++ b/back/src/Services/SocketManager.ts @@ -68,6 +68,7 @@ export class SocketManager { private rooms: Map = new Map(); constructor() { + clientEventsEmitter.registerToClientJoin((clientUUid: string, roomId: string) => { gaugeManager.incNbClientPerRoomGauge(roomId); }); diff --git a/front/dist/resources/emotes/clap-emote.png b/front/dist/resources/emotes/clap-emote.png new file mode 100644 index 0000000000000000000000000000000000000000..a64f2e5fdafd3d6ddc3a6f5180a52e772b4509c3 GIT binary patch literal 15333 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 Vshe!5G$P&I3_XLA(jg!qJurZj!VuDu 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 literal 0 HcmV?d00001 diff --git a/front/dist/resources/emotes/pipo-popupemotes001.png b/front/dist/resources/emotes/pipo-popupemotes001.png deleted file mode 100644 index a3db6d6d088107ed91cbcc4e6259c278dd729ff9..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 747 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 P00000NkvXXu0mjf%DsGb diff --git a/front/dist/resources/emotes/taba-thumbsdown-emote.png b/front/dist/resources/emotes/taba-thumbsdown-emote.png deleted file mode 100644 index 86e89c7b0e46be5f6e452563cee04cf1510b0c25..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1981 zcmV;u2SWIXP)Px+cS%G+RCt{2oIh;hNEF7uU2Q>$A`0B1YhvjN$SM+4tCVTBpbP1|thPXR^=T%9o;Hrw3U&f#S|x*j@BunRZ6D7U&(-vj*if1G*GM6 z0{g61D_EAr_7ydx;1~So%#RH~I#{>cMXgptrE(CtZ>4g8bgC81z1V280 zEo*nc5CGDRQ1HX~G6B&1I919YcUsVO4fR6}08r9eK(&lwD0``2Fh zFDudh4+NkneDtXGfm`p8qS{l;myeJE7u92|0uqdCmflmU>leULV6?i}!m1@R08~ph0J3PYbWjK9TeS?^c038uqiPxJ#g#IC zn0D!Zl(P0e3&5z8Pu9Sj0N{g=uY~lTs$!I)%6t+ZxA!mxeHMhBc7r*~f={3d%kYzw zW&ot+s|o;FzA-V`d635j-mXJUcmO^%|Kp^h|Ap=aC8v!+AG6CbgYpX@6pV4fmsWh^ z`Y%2Br^9(vVc_8^fa(BHVMBA%w7RarwjH+6;U9qXU6t&I@SkyV=zpOAWKIESo*8HY zo@y929aK5>^Yh330qb;H6ar{EY$aA<89jyp@Q?uzX2aSs-DUoSbD(j5z!o4>vG*+j zumD`GR*@&6fyM>cbdW8;0GM4*BQJ*YoT$%5`BaIi;XUVo3Bi|!uW|sS;rngK5&&uW zQDVL?eX|ZHeE+BDf1dp>R{{B`u9jIK(4~N_A!_B}Z&sk1X9ny|0s!EtzyOq2;fJgM zagq&yETT35fI6;N5+)n`{h#*&n1`9O|9SQQOTc|(Vl*1z?(Sz`2fCUcs4AOaY@Qha zHn)M%9WoKRyZedJXcTxvLxk_)a*06KHR0e(!%vcJJ6J3o%&wW3QE zi>uJnlRWtWdv@r59{tazpqni4EA08Fz|?X%WiP2CNF}Wm8iY~VCyviM?5TvyDVis3 zmIcFZi1jNCUqqqtoFr!)&1{TgrO_|An-^= zT!h=5?Zt3i*HDLvPP@TAySe!hn|+e_ezyN+>pHd#5mtS(^1~!k0rKF>sNg@|{tv8z z@u;q8}i^s@j;`J&=}AEe+H{7`;i3d$LQ)oLZZ zGGt6lG(S%9_RBZ?{^#FP&dc)o)wapha#zP3lTAfs8()?mr!<}aRQNOvqsM@vRy_CB zf0g{m>wtVFkoj>M9!$rPH(!qLU#y`O(=?5rhRB{QtiE;khB;xA&hTZOF>!&qIxg`6&D^ z_`Bn)>VKgC^c!<@bc7Ed-m}lXe*G8o`CKa7$TUE~FZlb)4?PEr)A{+?i}`$xo0}g1 z+{_~j&!U1~@b`%?bT4R_5i~!}a-Nk|1;60$3tzed`i$7Q^98@)?-T!j$6lZfW5~OD P00000NkvXXu0mjfvI^`T diff --git a/front/dist/resources/emotes/taba-thumbsup-emote.png b/front/dist/resources/emotes/taba-thumbsup-emote.png deleted file mode 100644 index 46bfc7b445d09672589ad410a0adb26abb30a73a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1931 zcmV;62Xy#}P)Px+MM*?KRCt{2oUw1)I26Xe79BhWfrn@{a6`4(DT0%y!edr#aW>Vx4hC*MBxCX) zWaypYEzYJjW6>!-7_qat!vV%^&JYwCv*iZ)F%(5oq)a)@RUZ&ER%QL(qu%3tq~!pW zDpjgf>F3ld0Qma#tI^5l=NFH8=Nuj$yfV4g`}gnJ?-jq|KNtUv4S08V7p+zcjYcDL z&SJ5^Y&K)(ni^8^EB-BnQG#aSax0UDC>)Y7ddxyKb?^rAr7z_q$4@AY+ z^M~Cw`#l<6{Z#&oz8B>y6X5*(;?cx$QT6&Z>h*0j8VzPhkwJJ#eEwzt$qgx+KkT*< z_4{~OUg753GI_lUgiZM`1|hJS@mKj?gfC41g759Uv$dOv3;|$02rrROH)Isgo`o-} zY`!iCH^I-7DgSjDa9(?R@6hY@j2192kuL_qruliQ_Ho=+fDgo94U9G6^U^W9VT-jSy6c+q*HM}L z=35W*dA9m&itqBewtN%cb=PGr5M`fk!4viS=!PxSn)|4EZQOiYqUJpXn;4+c^}0;sZtf(if&cmN1GA%EHe&Q}Ft5k6g0dfYMy zZTT*JSMhT{38oeTxA*BJSOtC5Z?j;md2JSq{H#bbWi-0NWOAK{WDH8iVpIUPtOED$+ApD!_=r)Pe? z?HPYbT7u~07!Nm>EFg-`2(0>W;**apgwM|4`yPUWh_xfW?;$vf3vmIi`mEL*e2XR8 z5^%>%Zh|SFs!!knK_>~in%7RXmi!v0`PO4=Ef+SF6@+=(r0+%vKu~@LrUfWJ)AGUZ zOslqw@A>(*i}=Ao1R%K)6tIE(iq@jppO`AqO>nAJ^~3Ew+kl`z5VrtpUK>qZIZ1*a zAptkxJ4=AYVj*1wCA0n#-LRGFz4^Y7W?$Y|Hfst-EEWr?Au2vqAL8;eKWndOA;y(y zm8|*Px<_1OkK$Qs5lA-yQ1`+%{F>KJRUnyIm0a1$2ZD-Y%4gRFoCTpNzYO1H{M-r< zjjk~4wlRulED0$1$N)b0fut33q2MS^J!(KnK(+vO!Cyqw%E?-wO^GUyI0FO7v|uh( zRuI~#Y~No4$s?nK!2ox6-=!x}7Rwfv;x<@nD^$q-ySwig3Fa6jog01-ueOK|{ z0LiywK79D7E`vx{0w5ds;tVY7!Byoik1tjsvF|sPEr0waM)R%52A%(GBh~(_eCI!W zvuS^{{@>oGt}80FO`W4cd`$~v8-&(;hrX-$tOZ(S;iJj(@6pMzb<3g3ep9|Q0RF8Va1>*F8YY7uCyQTdyHYov3x1t!`M&S5E#6vlA3+lcIDh#>A^yVtQ&Ei|frz9ZpEg)Y3^L#JD4~}BsG)z?? z@dk@L-5A*NT?e5J-?i^LekuVd3jrt}Nu?m91+ffH=aD9pYi;%y;qzP>pVvD4;3!6b z?Bf99;~@N}qvGHf<;$v&D5`|I04TzD8H7doE_~19vjhA$^KHKlmoaQ*0QddO44nU~ z0518w^ZzG)>fyT_xg7t0{_Fuzf8whPLRbD5@!iyaX#&WMu$2zZ8&L6I2H!RaUyA?A z&jI*yWMc>qrt4T+U-5r9{x81`5E$~Q<#5R_%YW?$Vf1D)g#IpztUsSjt}&aAVtH@hko&_?f>7k*CAMgICk( zls(r!nOs}L*HOi<_%8Wok3&n0pt5ALz2#aJzv8>%TUP+Vh>bg6@hiSd{(n|7Zf<$@ RovZ)=002ovPDHLkV1fW^zxn_G diff --git a/front/dist/resources/emotes/thanks-emote.png b/front/dist/resources/emotes/thanks-emote.png new file mode 100644 index 0000000000000000000000000000000000000000..8e326ed595dc42096c54822b469fa87d362f5f94 GIT binary patch literal 11279 zcmd_QXEa>z7dEW--i?|NEzynWy|?IPbWsN>${-?oH$;dsdLN^Q8KMi(qMK1h3klIh z)DS#=|FzyP@3-gkbJjZRtZScZpR@OU?|q#QXD1pPX;YE`$Z&9QD0Ou-OmT4V!2g{j z`1h7tihM&H9DE#O19MI7R|fZ%{|Ao`vuF2N|J!{`M2PF}%|J#)V;!DBNymtbi$g?8 ze!cTJrh5e+ANTJsA!S?eBcS4|k`7W5ytpqL=`*LNKLiE&h}9IyxkaQew}ia18%c<9 z>1pv-C-^8S@Z#SwONo=JJHL{VBKdP+LV3G4=QrmyBfHCTRTs|p3ggr(6pYW2QP}+G?Xb! z0^%)=slD@%GyS}(_P#k#z(Wanei;>3W&$105VPQ9N_r*{b)(QvlS~W*Z8cm#18dGF zlEL+(ZXXb_y=#IBT5{$tFWl)Bt=$QUNmIVS&q9?ZRJD(MD>%I=m0!)p}aBfKmHw14q`c+C_;#GQ` zUg9qPBEJ%qH~(}LA=AAoVZ#>ueUFpT1UT-V`NX+yh9#z@gNi089aGS%of|M(tk0MF z)>X;=b#*+q+8`YtpD}HWX^Ac?KtD&@@3?HzEqPRQvgE)eE}4mvsS(-j~mUhr}(9mg!ltm+VnBZ)9e?&zS+Hd zt|S>;i1f}r>wbKqbhmbPUAwPv=Q!7K;ozp=74pQh>ZfDL}TjlJdcMc3CyA&dG`sx(YHA=Olmd|$_Q`0jiaNsj+X zt-{tfWaRmnN&b)mW~<4p?J*Q zbn}?bE;dp~R~iCc^rlU|8)>DSn7IG!UXdprdiVDAnt&8zTcg4_X>?1>Q}6ifnC3Y_ z)>stYd1&zrX~xE7!ZKQHe(+Sy16RCmfiy4ngebO4nxCVKkjr@IG@^yG*3dp?E9ye$ zRZDtQGVyl~8?P&KeEgD;W%Ryrl=+(6^lytc=`Nd2<(~~x!7Z#B166I<*CpPe#y5R&1)`Sk(9Nim$y+f@pb;O@)a4sbi8ufF#8m5qK6yM~#vNp6=<$*jn;U3_VF3SrRr zac_Jw+seS0O4(PlO+U%0VqWuR^B$CCNEy2|8}Dc@<=ri{shsjJ%ENHAE^ zUV(LmY5c9wz%CDG^`KFt_%_Ou?-Tj2Q63Cr_G|hjy_FPsHBD%z>47#1n5NzFsK1`E z_KzOYem_W+4=bGWQ)9RPaf7)T^Y$+7cB@ldvwi)dBnJH2i>$Nk8TD=EK{a^a^1aNd zt=JDZ6&rqG^>xzg zXFBHPKF0z6*bv*FRq#se_Mg7(HfzKJXX}bO=3Uvh>#`p%xnIrYH%h?!QPqem4C=cbOlE%X=`DZkjq%qm+M}B0~+3A)cSjwc>89N2}mWPWX&M zQ+_2++$0$wq1CW<-Fd6hUuvwAkhUU4ivv=7$B065H;T~4;uSLtmD4HXrE+sMS!-q$ z;=uJJTU^0ZVBcODxr%@k_)Y~&CL3p&(BJOWfzxup%~Junjq__9Cze2DA=4A>Sxt!w z{%5H058!>$i=FLuoj7p}NYV0uPB!_&QR;KR!S!klQ|;ikPA{<*NNryHiH1sp}w@2t&2rRb2ektj-}C z9O^aV65zm2Y94i?7Y(-Z8sWZ2{?!C|f$+J$i30tpH(my%R0`-4taBxZ2_#A>E#{X; zg~8kN9Dm#5z%uQHj@05n2%eK+6Sup>Mi(Yf&yvBRp5tn#4lET0$U2a@ZR~XVjExGT zOkE57JPDd=pmpNbN2GN)RR)rj52e!nA6mtffjG?Ss9~bXEZ|;BMMO2U#giQ-nj|1C z_6Np?nV2c{+U^#ZS?oSH%>avfPOAhsGUWO={S+}o1WuPS+#?=14s#|SW?_3+io%RW zla}%OXAumlxvrh;AX|fd{4VJpNnrG*-|7B5edYACs%B7I`&|8+u#Z@)aJCZ51o{|% zOa3ss8e*LXruM|Ro;uQ;G!g7qvzHg!W(OW9 zn>1}4KNkJjm(x{P5otL;3cUCDY%C#mMc`c~VYWz|0psEGmLY&eO@*7Ljta#|4){U} z0m;o0R>yOJ7q8C~+@qANiif!324(AO<^YS1@re>Mnn;F)-eG<@3vV|Br8#e0h`ANN*62i-9-=lq-D70j~j!4@9)I zN294z?4GM24O^s$bgd^f09PP46{L5IlpjuMV?nIcX^{c^{Dbe(PQn++{kmK5nUjB- zPR~vF;`f2xYpcNkUMVt#^>cNW?O!yS^U*;5YJpIFYRp$MK+5qT>`RcZhrjp@P7gmN zN`XT@&TVm%e{;s?nH1td6g>BseMHj#f@o&IF~{UU%uJZsdBKp(u=86A*!#X#|C02B zHx_APwImVM|D~+SxHN=fGxenwK1`FUt<0>347>*mw*)_#X@tF#`n~q?0VZ{JW59L5 zSu#ZGw{WGoxBIVIwR@D|wMuPJ4N0q(rhdIJrUSd6G(!+d&+L*7IE(8)_gd{Rtgd`9 zv+Fh7?=skIzpwoqq1d;rqnqNa&VYqhLn16ERXCX>qXxtDSKaPEU)e?1kz{6ImND@mi)O^mX`kkz~{F>6RflNa@cNbAtD@iOLpk&!Em2s%iXFO(DyfylTsr*2wu&oaFSRkGxaG7mxc9 z1*OC+G3K?dB9w2My)bhBd1ZJ`P#e~q3!4d(H!YB`v3wqH0Y?Vjw_&8GwAiPS7BE;? z@r%Y!mj`Y>7g!HFWYIP)(?e$Rb$lLCfYrg;e?&s5t=Z8waKruM@V-iIaco(H4o=ke z)24(`lnAZ?8~!JYQB7$qus?h!2*G=SVUKD`{husSBMQE5>s;%4gwE@8nTM)Ui_?r) zYxCWQ@r@YMzWu=TeM(^F?0ZoGE_3hlc%5V|DU^78+`nb&ds+1Dq?le9w1yosDw{8j znzKcFK8bep>Di43?*?_&n5k1A zu0piltuiQ2nlBBRf76z+Y)W|awwDXj4IgYShBW6_L(p89ON%M?&buL@1rCjxw99f3 zxr3@Vuxzmh{Pg29#0unhB}LxO&>!DpePr_fHxQNksOYn`)5%$Z8x4x^9mQ1mLd8Yy_%8_E$n01`~WLfLZ+{~AXQvt;&jX!S_G_8IA>0B;Y z2#9edwE0(lZu}PxdK`*N<1p_@Jo5VAHpEtA$a-n zplCAFoO54%vj*Jfc&QDDEWbS-+ia?Td0_<%{1Lui;Qh)1!#K3b$e2!}Q>^f_Iw1i! zT48`0sYIrydIa+F*btr`q!I)Y=#`T2dWPgg^=_KP&Fhr75SnZG$VZTwa||_o6vU>^ zyv9=dDhF&=QK6}Cj6Z-sHbaKT$5(#U22R}FQTqA&P2Aocu6i7GorC@qk~KtH%rVR9 zmDe4;8h~Tw>6jE;s%pxskw;okK+*dk&5OP#vw+>-r=+Ru$SXjIZN}@%g-=P^GOK7n znfCY@O6%f_Xg_T0)hVN81gPI3U!nCSj|7u%CVNre|{xUl+EZkCKQ-6Sr{qsE@00?J&AYh5oXsRXv8o;q+ zR`F`Qygt)Xsc1SH<~`p-2TJxfaI~7@C`3t$?+64vxuH>)%Zu+S6RhfAnstU z88fSbIq(@XSOTAE8voTqem9@5Lf@!ysFhZ5O054D?_@n1?X3>I7(T)`YO$U*?21VY zwdMdr)990?LD?ys2y8Jma7eYQ@At&N%aAz6Qk2GvXOxj4sI8({4pBiyBJihehA{lY z+z$pM%~=+uD6$ck6z}3!zgvvZGY_>sS$QmY=x%6S?>~|=`60QX|5|WgvV7vp;c)@Y3z>Q~n!fycxHee=x1aMd zr4S0Q9LgGP-PH@_n-b|(vPWtM;GAs%$et#YnMgF0m|dz&HALW+l@mPa)YDt9VXZCv zSnGf>U5$(AraT{#VI_PcXcIq5nqa9^OQ+a!jRQiP-(praxgH zjKg0GGtG^GRUCHz#l&IRdC!^=F5(XhOrfyh@IZbAsnQpC$Pk1{D87wL?Jg57bFK*# zCPVo~{{GIi>K;`y7|lf7utdxWNY|k_DSv?;Fv&8Kad6UDIWfx+mqO1a2YqYk*I~!u0k!$%DoCB-*Nwf{Rio|7l_Wu8A#cr{9COEp4?ZBV};%K(R8N7QoI@ zzL*OsmoJBcuXbl)6iTz0War|(=$w6+27fy(<71wqX{vOb?Q*p{hhlBYPnxUr!!&C` zGz}R>+bE0>*)jy6ej?*gLT%LpFPEtnLMG!S;j0Z7>uURoLaN9sP2VyVUy53EeUs|s zYEuNr9r7DH|3&YB?tw8Y${Mz$h5JW`*koB1?zTY}$zc3Dlu|uK)1IA5-Zln9hlP#PTSV#23saPpwhhLa(&VSZ22upm;*C{Oz&>Gw?NKut&L{Am0dA<1DYMP%q zvI2y`SbdE$HeOOmNNp)){ZccVWt?GamhrvX)Q}Ml%vCdV?QZ8N1m!Z0y%3AV0>;Q| zJv&>MSJ#VC2B)JpP6)j-noz=P-x5^e`?mhLE3zuof*Q1l{5I0G4PmMnR+vRbh`e}j z*caEH+!dww9G-ZC2cxnD>M=ZlyFZ85ac7D)7N-{|Y#EafBjfxSyjzNiHM1L+0N_sU zOXm9M1~Q-_yU&^v74UU;F+pNCBRK09;j)-9{a3(_5=)KI*RCj$E-z|i2p*Dcy;g3d z>%8pTIHQka;q^R!dTu6)RpS6ZotFsciP6m*gzK_t7@}cnTU3DneoG9bNfoWchZdcx z4^`0q9v1_;GAlQm5^2KK+Y>Gwy#DGx7V{Zlj^^K2T^QuGb4eX{idD6G8Tb zE^`5U4n2YlU(^jN%~+=J&%vg-7nDLQ;w_$?@cl)km+#q3|EI7pry%@ zRq7LQSYW)0??(%CuyAQNW6cnzl+1JR30R&0Y}M-w{7?a%Z?ZwVmYOms%<-^dnr217 zUatWy1b?$JK8y(uiy2S$O9x*jh)&r5wobe*M8SjRcKqjQ2 zB3aD(v9NaJ(K*<0X+)&a%wV za)7X-7t>dKpaXwltv53Z=ZfBt>-vy4J3U&OHi<>x!x-1m1B`_R+~V+&Yu0?3ji2ch z3p1LX9>1L}OS@ckCM8=|8jeYX17UcRJReeaexqhNalsOVk{j}+Gc@V9SxOr$C3Czc zkE8d};Rc}ByhiY2wE%ChJhE=bx&%VA0>c{liVu+?^*+;LNkeYF%x`{Z$(A@)g9eRy zbW%*jcbmK_XmM5=qCkcL|2*J|xp<&om{AHFY%3Xz|7mtUXyn+ww;l~m~%r(jaBVjMNrYM6`={$y+e*~)U3Ry+J z+#)ERYnwm*Nodv=!@sio(AU|G&iKcLy}IW*i^{sDm2r8$M+^K|c7B#xOIMK#2Ko-( zFUY4s=c^0Y$FiUNwdzr`GPA16h=PTZRG8+2M_LCdUI9-%0`pnW!O$8)xcmmMeC>w* z5kN6;@Yh|=;t*zxQCmh8Zeb~Jb6v`7Y_e8T77xLHdSKalC|rXiys^dGX<%LV*`&|s zMPCL(n;D5vPY71ddq9(2M_&@KN>la!?%0c6FCS$aaP9m2h|P5^8SE%%@RmA$OCH8L zFpuPS`bOB2_40t_*S0QjNY)vCY&#KGtlKXl;7GmThYy8k_-m+a|UYp$J-QL&lT#rM`cEZ{ul>e< z6eQ(4>CcG4csyHPk>Vp8z?;tr78D}1t|ckZBIqOlp3mPfZEvMdpLoVqgyCoY0@@23 zw-Z_ZX<9YZS}$s}&~5KzY2Tp!-6y7OJE=0RD7#>1w7uwudr)%Jc6$M(z(>xZThJI+ zZnM_@33)ZZ;;s2<| z?Q?)5{E-fSJ68%HiIRH8i+ktq|D2Bk^<-`O-KnSlQh6cM#z2-9^IiVFXQKFE8=V-P+x9cQR9v5s`+ToLf1lXi`(Hf?etFk!&BGR7 z$CDA+u+7!Cwt$buT5j%t@4C6(821T|dY{c%XA@3eWfcKh!V18ys(gVh(}lv%6hX~O zv`wojb-rC9fa|ZQJgn1XR_i^thx*omaRY;$`}e)>=20FBHP^h!cpr%Ram`XBD35OB zhI6DIDcqOkF=bkP_bF(r&w|m0z54+Gg|iqPBRnj}eT@@MpvsS?sMt2|fjv;bllemL zN-K~;^YM{$y1-H+3o*&}7@>#oVpmr5hYBYN8EaCV*hfSpgw8ofVDo{WpD!jrDyZj+{{D zmD4d!1eDk_W?Qc^iwA58Ds6s~`%F5UU0SJ_*3V-8`aDEqTL48FokzkU8m|4Lt4h)~d1is6Y1=)&k$j2LJM5aTv^nuBBLJC)6x4kEx-@kVAx) zkwpK-2K}{aW@YVrYR(d1)u(lOHTwX^(p;##XH5Qd*fNEzLc4(}+HwbQ1wCDO+QZvO zibDyi9nTE-^J+4~#qOvr=LyC&%8%2i>EWeO9qAf^oHJK3<{7*`gh3 zBDSx43BQS@_yK#mazXJh*O^|b472m$(0}sV)aa4f%}MIVl9FNKc4vFn?*vs2We-(( zc-7Zr{UT2|6>))hADuw@m>`Gl{sBs(UOpzNh8OKW$?Kr{#6@q?VpM@dN6$Q*=v%cq zdW@&SyMNfvt;Q5x|8uA!J{K;!sJaB^{bsE@q(rPmzkn>1c&CUg>ITWMewV!IO>I7n zh3Ae(vep^R2d=F{AfW44o$Irr`TN_$0S6E57lT+s)qBUbhEpu7^lqL7#%A2wUBxBh zqSE~RT;J$3N(K=h<&MivbqWQIWa_ot|AL}qnh1R1**lIM3YLlC@iFR>qg3~<4;T`v zVzOQd`t*;>i1NVy#>ww8IA-2ks>P}{qnLJM#BYSomMy3kOBWvG`RFrQ+xU~%>}LYb z$>1GwFCHQ}a+p=nsbibqIO)s!XH`u{m(bL)&s;m&pK2n?tl&<`EF1XX=zJpR`?0q- zLET3Yl8(8DrL#%d?3b)K*BfN@FG`~77NgPSU*lycVOESHq6&L|)I4 zM`(>y;Z9j(Se8lBHb@Ws!3~G9R{Zy&TI{mR@7-$A6~4H)ZAH1cPle@$e!{`{zgObP z2R;u`pgaUjyafn7V_bi#u_<Ak{pJ*w$-vk}%YKMrRC-5CZusjRuTHew< z#`M$xYuE3Xq0Y{We?G8Hn*h5y=$DR}C(Bur>%h6swh;~T=7Ou5Vm|MRw@5r=>^Mmk z2F;Cb<(u;23g^`CJSrek;fm)tMTTm-%u}rvGV44v(o1l+Wc#ukgNpZ>Yd=ioKV=EU zNw@ua=6=OEF5cwLm)#d7*k^;G3h~mTl0<9KR{g|fF0@@N!i9zw31v=i|B{tBo|d|k z@TH9}B5ae+-^j@`Wl(|Eev7ZYlHOZ7UA0$LH{9pCT{BLMt~S~Tl2Fbj@e(>#e6#OsO@Hmlg zM;?Y2Ba{>f()dE6?*CQM>*g-p6#}_tdh23&sETIUmf7#F^Z7zjohPhH>MgZA=CIz+ z*|^FkMn*Idq5BP{LIY%KC+j$R%y+otkvP&T^Eer!4}N*}AkX@r7+()ncYmpTRrM|RPknlkKoqlsy z)5Y9}gG1u@-z-20TGPEBkkgD>Tx+V&_xwHbh`{7I#M4*`3&+m)2qC(Tat{dKw4T*P zKs=Rar-oFOquI4|`@V8&KubocSS_5c{0}AP#l~oto*SRAunFU~BllJc^@pEhfMrj^ zLU2{l*1M)CleYn=8|K+}mprzL01x73F3f{&ak~QXTgmGRTiXT(_OK>+f30_K056@nL-q)8C9X0+4(e+~C^4K^aeAza`V9-c%D`^j{_N2W64`9+4`#jh&5U zsJF6v$A5wB@ajXkxap9<_7BH%*$*9_$;YC#TOz9H%WX?k(_)X@=8|my>9DUTWE>$M zv4;#;)A}6Lp_?G2#u0tdP*Mk-S+$6xt5&Y{BGX}4x`2~2)e zz0}IH6qxQu;a}Z}_%wuW6Kpa=tGWjSd{JKf@sBb0lAfExtdS=JKcsc6jAVFOinV>L4;DJr2tKG41!FhFFxWk;U zNFWIyD1btk$T_jjuHZ1OB3+>%gAS7Kp%b=`Xr8TlVdi{YSyj;*?A_UIb#%4aMG}f} zj*6J#w-E=5uY60tuOB=OZ*^R9{rE4-R+k2-8h_+bXI6Xnc_H|ZwdX>uVEzNI0$Y|F zI*Ja*N)g3NSm4!VtU?q#ac0vGrdDASZcE1bl?!-V1liZw^UiR!Xmabz(%{;E zYCFp%65xT8Flh_ft5aQK~f}F zpKLkn?z`6LG@1X=3;E`30My*$CS4Z``^aXHf8w&@m9Ex;_1u4Y$efCS!-66x6@RSS0cS)Anl$?`XH@R^MawotfM+IaDum!@_C3Bgw zUb32>t{#vW^SIiJKM&VxQmd2w5m_L7N~1Au!twl?$@dExa@`>Q>?~a&F^q;6PWx=# zIZo+d;K_n`&Pws}*QezW)yCqjmE=Naq9q*25Me=1B&1%)kC=cOyn3Tb#%HuYg$0eDk2AIwr9wGI>3}eAi}AoV7Xy_X zr3K>}$}slD&1~=MCPS;eg)coaAU)NH1w|5*lWuKMdluiN)`M#aBdEcrNt?O!(cY#v z#-&>p?(0Rq-8$jlp;FD~J_{c+M9uOYpt$1bEpNIjr1>4Cvi$zM7)S`mx# z2_`iwyvrhjlNTdH4@Ll?l&6%<%XzJQ1jS9!Y)H9f1X;tDG1u^2D!vysx(VGe{+Q8B z0v|nlihr(-~D)$+?9<7 z+_NlNdm<#+40|6MaYx_wzVZg3GgvpmT{t!-zB-*EX#C|<0?b*10RBFlJB}Lcvv?+$KuK2!$cT-AQ$0q>S=eI7a1FSHyy+E$+gh?Gn`SrYf z2-YiuP!0b%9OMEXh;u`KNS9WMf!%udntO0&S_)MTFUUDH3~kT~K$VQtDvjbwBtCaf zEL<=dB>4TPQcVZnzlYk22b@%zMT$0~Bw>sOSnoST!UdDC%@M78-lb?_&nrKC6Eixb z9`^;RLu{sZ`N&1XP#0alZO?JsB7<(J?=62rVPZyu)Q_kNCFL~?B^$n(IOYbAmSo|! zq$M6crUTZA@2O`);k{X#ilH!YY2amw&5Oer&<*^n5+as{`)sQ z)%(gbDA9|P5fyZ%Dy5LeOZ!PubnkUUDi3(H*s|{|3U4iGh4^vKH57gW?@*bXv4p9E zNf|mEHPH;Jlx^`sPbLJ>Eqp46LA0fn1mH>B$kh7O^vXwgUk|Gk6JU67)y;ijIk|XP z7EUeu(E~79aTZ=MswukM>x`2qX{Cl18KEtFR2yxx+K9#boy`r#O~dL=WM?f-OD1jJ z$BhIL2VA}zcAjaGDPFk2D`ol)w_ktfmCJ$4T=2O_9gPP-O5EPlX#=KhaDLr8#JHkK z+FHg^i<2kFXpuNt2W<)Z->exsipJH^%RKlNi*&9b{ZS%v->A(c)7;}=rh0Qfr&K;d zQ{W*O4Z4}PZQB$kc7IUeb#1A?bnODj{_>_P6@hP1#~6Ys~gUZ7y= z`}!b%)p`Fi0Ffei1Oec#h4N*$6U&2NBLLr@en_vROfyE$bO}<@lz9%spH#R((E285 zxFi5n{r8{lOgfQ6KxGtMp5m$1pW`yMO?Ar7?I*v15m))LpVFezxY$G=c!6PZWAUgR zjxu!U?I0}EbQ-NxXD)|0f9`#rz*il$icT*8#a5r3PN(sMXUGiez7All)JOXJfuAdq zsxy{!UV<6OV%18F1^)k+v!e6w@BbU~Q<=J(?-s-T|0e!AaH1_Jj440HIQ?hQ)ilzm IS96N`KO~m_t^fc4 literal 0 HcmV?d00001 diff --git a/front/dist/resources/emotes/thumb-down-emote.png b/front/dist/resources/emotes/thumb-down-emote.png new file mode 100644 index 0000000000000000000000000000000000000000..8ec7c9612a05da5da42c2c7ca9cd37b38f5df04f GIT binary patch literal 8822 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
9i(Grt4g#S^=&GHByr?mPcE#k`$4)3X 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 literal 0 HcmV?d00001 diff --git a/front/dist/resources/emotes/thumb-up-emote.png b/front/dist/resources/emotes/thumb-up-emote.png new file mode 100644 index 0000000000000000000000000000000000000000..eecb0e578e05f7fa5e02d55d972aec83cda65a2e GIT binary patch literal 8842 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 li*epywf~hrcmK this.createRadialElement(item, index, itemsNumber)) + const menuRadius = 70 + (waScaleManager.uiScalingFactor - 1) * 20; + this.items.forEach((item, index) => this.createRadialElement(item, index, itemsNumber, menuRadius)) } - private createRadialElement(item: RadialMenuItem, index: number, itemsNumber: number) { - const image = new Sprite(this.scene, 0, menuRadius, item.sprite, item.frame); + private createRadialElement(item: RadialMenuItem, index: number, itemsNumber: number, menuRadius: number) { + const image = new Sprite(this.scene, 0, menuRadius, item.image); this.add(image); this.scene.sys.updateList.add(image); - image.setDepth(DEPTH_UI_INDEX) + const scalingFactor = waScaleManager.uiScalingFactor * 0.075; + image.setScale(scalingFactor) image.setInteractive({ - hitArea: new Phaser.Geom.Circle(0, 0, 25), - hitAreaCallback: Phaser.Geom.Circle.Contains, //eslint-disable-line @typescript-eslint/unbound-method useHandCursor: true, }); image.on('pointerdown', () => this.emit(RadialMenuClickEvent, item)); image.on('pointerover', () => { this.scene.tweens.add({ targets: image, - scale: 2, + props: { + scale: 2 * scalingFactor, + }, duration: 500, ease: 'Power3', }) @@ -52,7 +52,9 @@ export class RadialMenu extends Phaser.GameObjects.Container { image.on('pointerout', () => { this.scene.tweens.add({ targets: image, - scale: 1, + props: { + scale: scalingFactor, + }, duration: 500, ease: 'Power3', }) diff --git a/front/src/Phaser/Entity/Character.ts b/front/src/Phaser/Entity/Character.ts index 1975182c..b1a85943 100644 --- a/front/src/Phaser/Entity/Character.ts +++ b/front/src/Phaser/Entity/Character.ts @@ -5,7 +5,6 @@ import Container = Phaser.GameObjects.Container; import Sprite = Phaser.GameObjects.Sprite; import {TextureError} from "../../Exception/TextureError"; import {Companion} from "../Companion/Companion"; -import {getEmoteAnimName} from "../Game/EmoteManager"; import type {GameScene} from "../Game/GameScene"; import {DEPTH_INGAME_TEXT_INDEX} from "../Game/DepthIndexes"; import {waScaleManager} from "../Services/WaScaleManager"; @@ -32,6 +31,7 @@ export abstract class Character extends Container { private invisible: boolean; public companion?: Companion; private emote: Phaser.GameObjects.Sprite | null = null; + private emoteTween: Phaser.Tweens.Tween|null = null; constructor(scene: GameScene, x: number, @@ -246,24 +246,76 @@ export abstract class Character extends Container { playEmote(emoteKey: string) { this.cancelPreviousEmote(); + + const scalingFactor = waScaleManager.uiScalingFactor * 0.05; + const emoteY = -30 - scalingFactor * 10; this.playerName.setVisible(false); - this.emote = new Sprite(this.scene, 0, -30 - waScaleManager.uiScalingFactor * 10, emoteKey, 1); - this.emote.setDepth(DEPTH_INGAME_TEXT_INDEX); - this.emote.setScale(waScaleManager.uiScalingFactor) + this.emote = new Sprite(this.scene, 0, 0, emoteKey); + this.emote.setAlpha(0); + this.emote.setScale(0.1 * scalingFactor); this.add(this.emote); this.scene.sys.updateList.add(this.emote); - this.emote.play(getEmoteAnimName(emoteKey)); - this.emote.on(Phaser.Animations.Events.ANIMATION_COMPLETE, () => { - this.emote?.destroy(); - this.emote = null; - this.playerName.setVisible(true); + + this.createStartTransition(scalingFactor, emoteY); + } + + private createStartTransition(scalingFactor: number, emoteY: number) { + this.emoteTween = this.scene.tweens.add({ + targets: this.emote, + props: { + scale: scalingFactor, + alpha: 1, + y: emoteY, + }, + ease: 'Power2', + duration: 500, + onComplete: () => { + this.startPulseTransition(emoteY, scalingFactor); + } + }); + } + + private startPulseTransition(emoteY: number, scalingFactor: number) { + this.emoteTween = this.scene.tweens.add({ + targets: this.emote, + props: { + y: emoteY * 1.3, + scale: scalingFactor * 1.1 + }, + duration: 250, + yoyo: true, + repeat: 1, + completeDelay: 200, + onComplete: () => { + this.startExitTransition(emoteY); + } + }); + } + + private startExitTransition(emoteY: number) { + this.emoteTween = this.scene.tweens.add({ + targets: this.emote, + props: { + alpha: 0, + y: 2 * emoteY, + }, + ease: 'Power2', + duration: 500, + onComplete: () => { + this.destroyEmote(); + } }); } cancelPreviousEmote() { if (!this.emote) return; + this.emoteTween?.remove(); + this.destroyEmote() + } + + private destroyEmote() { this.emote?.destroy(); this.emote = null; this.playerName.setVisible(true); diff --git a/front/src/Phaser/Game/EmoteManager.ts b/front/src/Phaser/Game/EmoteManager.ts index 5d8d7179..2e0bbd67 100644 --- a/front/src/Phaser/Game/EmoteManager.ts +++ b/front/src/Phaser/Game/EmoteManager.ts @@ -1,38 +1,30 @@ import type {BodyResourceDescriptionInterface} from "../Entity/PlayerTextures"; -import {createLoadingPromise} from "../Entity/PlayerTexturesLoadingManager"; import {emoteEventStream} from "../../Connexion/EmoteEventStream"; import type {GameScene} from "./GameScene"; import type {RadialMenuItem} from "../Components/RadialMenu"; +import LoaderPlugin = Phaser.Loader.LoaderPlugin; +import type {Subscription} from "rxjs"; -enum RegisteredEmoteTypes { - short = 1, - long = 2, -} interface RegisteredEmote extends BodyResourceDescriptionInterface { name: string; img: string; - type: RegisteredEmoteTypes } -//the last 3 emotes are courtesy of @tabascoeye export const emotes: {[key: string]: RegisteredEmote} = { - 'emote-exclamation': {name: 'emote-exclamation', img: 'resources/emotes/pipo-popupemotes001.png', type: RegisteredEmoteTypes.short, }, - 'emote-interrogation': {name: 'emote-interrogation', img: 'resources/emotes/pipo-popupemotes002.png', type: RegisteredEmoteTypes.short}, - 'emote-sleep': {name: 'emote-sleep', img: 'resources/emotes/pipo-popupemotes021.png', type: RegisteredEmoteTypes.short}, - 'emote-clap': {name: 'emote-clap', img: 'resources/emotes/taba-clap-emote.png', type: RegisteredEmoteTypes.short}, - 'emote-thumbsdown': {name: 'emote-thumbsdown', img: 'resources/emotes/taba-thumbsdown-emote.png', type: RegisteredEmoteTypes.long}, - 'emote-thumbsup': {name: 'emote-thumbsup', img: 'resources/emotes/taba-thumbsup-emote.png', type: RegisteredEmoteTypes.long}, + 'emote-heart': {name: 'emote-heart', img: 'resources/emotes/heart-emote.png'}, + 'emote-clap': {name: 'emote-clap', img: 'resources/emotes/clap-emote.png'}, + 'emote-hand': {name: 'emote-hand', img: 'resources/emotes/hand-emote.png'}, + 'emote-thanks': {name: 'emote-thanks', img: 'resources/emotes/thanks-emote.png'}, + 'emote-thumb-up': {name: 'emote-thumb-up', img: 'resources/emotes/thumb-up-emote.png'}, + 'emote-thumb-down': {name: 'emote-thumb-down', img: 'resources/emotes/thumb-down-emote.png'}, }; -export const getEmoteAnimName = (emoteKey: string): string => { - return 'anim-'+emoteKey; -} - export class EmoteManager { + private subscription: Subscription; constructor(private scene: GameScene) { - emoteEventStream.stream.subscribe((event) => { + this.subscription = emoteEventStream.stream.subscribe((event) => { const actor = this.scene.MapPlayersByKey.get(event.userId); if (actor) { this.lazyLoadEmoteTexture(event.emoteName).then(emoteKey => { @@ -41,45 +33,41 @@ export class EmoteManager { } }) } + createLoadingPromise(loadPlugin: LoaderPlugin, playerResourceDescriptor: BodyResourceDescriptionInterface) { + return new Promise((res) => { + if (loadPlugin.textureManager.exists(playerResourceDescriptor.name)) { + return res(playerResourceDescriptor.name); + } + loadPlugin.image(playerResourceDescriptor.name, playerResourceDescriptor.img); + loadPlugin.once('filecomplete-image-' + playerResourceDescriptor.name, () => res(playerResourceDescriptor.name)); + }); + } lazyLoadEmoteTexture(textureKey: string): Promise { const emoteDescriptor = emotes[textureKey]; if (emoteDescriptor === undefined) { throw 'Emote not found!'; } - const loadPromise = createLoadingPromise(this.scene.load, emoteDescriptor, { - frameWidth: 32, - frameHeight: 32, - }); + const loadPromise = this.createLoadingPromise(this.scene.load, emoteDescriptor); this.scene.load.start(); - return loadPromise.then(() => { - if (this.scene.anims.exists(getEmoteAnimName(textureKey))) { - return Promise.resolve(textureKey); - } - const frameConfig = emoteDescriptor.type === RegisteredEmoteTypes.short ? {frames: [0,1,2,2]} : {frames : [0,1,2,3,4,]}; - this.scene.anims.create({ - key: getEmoteAnimName(textureKey), - frames: this.scene.anims.generateFrameNumbers(textureKey, frameConfig), - frameRate: 5, - repeat: 2, - }); - return textureKey; - }); + return loadPromise } getMenuImages(): Promise { const promises = []; for (const key in emotes) { const promise = this.lazyLoadEmoteTexture(key).then((textureKey) => { - const emoteDescriptor = emotes[textureKey]; return { - sprite: textureKey, + image: textureKey, name: textureKey, - frame: emoteDescriptor.type === RegisteredEmoteTypes.short ? 1 : 4, } }); promises.push(promise); } return Promise.all(promises); } + + destroy() { + this.subscription.unsubscribe(); + } } \ No newline at end of file diff --git a/front/src/Phaser/Game/GameScene.ts b/front/src/Phaser/Game/GameScene.ts index b6b3e57e..deebf9d3 100644 --- a/front/src/Phaser/Game/GameScene.ts +++ b/front/src/Phaser/Game/GameScene.ts @@ -940,6 +940,7 @@ ${escapedMessage} this.messageSubscription?.unsubscribe(); this.userInputManager.destroy(); this.pinchManager?.destroy(); + this.emoteManager.destroy(); for(const iframeEvents of this.iframeSubscriptionList){ iframeEvents.unsubscribe(); diff --git a/front/src/Phaser/Services/WaScaleManager.ts b/front/src/Phaser/Services/WaScaleManager.ts index ef375a39..ca8b668d 100644 --- a/front/src/Phaser/Services/WaScaleManager.ts +++ b/front/src/Phaser/Services/WaScaleManager.ts @@ -54,7 +54,7 @@ class WaScaleManager { * This is used to scale back the ui components to counter-act the zoom. */ public get uiScalingFactor(): number { - return this.actualZoom > 1 ? 1 : 2; + return this.actualZoom > 1 ? 1 : 1.2; } } diff --git a/pusher/src/Controller/IoSocketController.ts b/pusher/src/Controller/IoSocketController.ts index 15be68c7..6d120f50 100644 --- a/pusher/src/Controller/IoSocketController.ts +++ b/pusher/src/Controller/IoSocketController.ts @@ -183,7 +183,7 @@ export class IoSocketController { // If we get an HTTP 404, the token is invalid. Let's perform an anonymous login! console.warn('Cannot find user with uuid "'+userUuid+'". Performing an anonymous login instead.'); } else if(err?.response?.status == 403) { - // If we get an HTTP 404, the world is full. We need to broadcast a special error to the client. + // If we get an HTTP 403, the world is full. We need to broadcast a special error to the client. // we finish immediately the upgrade then we will close the socket as soon as it starts opening. return res.upgrade({ rejected: true, From bc19cbd52507b68baf6e260cb911a129dafe9dc0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20N=C3=A9grier?= Date: Wed, 26 May 2021 11:57:57 +0200 Subject: [PATCH 12/20] Moving Physics optim to DirtyScene The Physics engine is now disabled only if no sprites are moving (if they have no velocity). Also, if a sprite is moving (if it has a velocity), the dirty state is set. --- front/src/Phaser/Game/DirtyScene.ts | 22 ++++++++++++++++++++++ front/src/Phaser/Game/GameScene.ts | 16 ---------------- 2 files changed, 22 insertions(+), 16 deletions(-) diff --git a/front/src/Phaser/Game/DirtyScene.ts b/front/src/Phaser/Game/DirtyScene.ts index e44ce07b..2e94aa66 100644 --- a/front/src/Phaser/Game/DirtyScene.ts +++ b/front/src/Phaser/Game/DirtyScene.ts @@ -12,6 +12,7 @@ export abstract class DirtyScene extends ResizableScene { private isAlreadyTracking: boolean = false; protected dirty:boolean = true; private objectListChanged:boolean = true; + private physicsEnabled: boolean = false; /** * Track all objects added to the scene and adds a callback each time an animation is added. @@ -37,6 +38,27 @@ export abstract class DirtyScene extends ResizableScene { this.events.on(Events.RENDER, () => { this.objectListChanged = false; }); + + this.physics.disableUpdate(); + this.events.on(Events.POST_UPDATE, () => { + let objectMoving = false; + for (const body of this.physics.world.bodies.entries) { + if (body.velocity.x !== 0 || body.velocity.y !== 0) { + this.objectListChanged = true; + objectMoving = true; + if (!this.physicsEnabled) { + this.physics.enableUpdate(); + this.physicsEnabled = true; + } + break; + } + } + if (!objectMoving && this.physicsEnabled) { + this.physics.disableUpdate(); + this.physicsEnabled = false; + } + }); + } private trackAnimation(): void { diff --git a/front/src/Phaser/Game/GameScene.ts b/front/src/Phaser/Game/GameScene.ts index 748897c5..cf882afd 100644 --- a/front/src/Phaser/Game/GameScene.ts +++ b/front/src/Phaser/Game/GameScene.ts @@ -186,7 +186,6 @@ export class GameScene extends DirtyScene implements CenterListener { private popUpElements : Map = new Map(); private originalMapUrl: string|undefined; private pinchManager: PinchManager|undefined; - private physicsEnabled: boolean = true; private mapTransitioning: boolean = false; //used to prevent transitions happenning at the same time. private onVisibilityChangeCallback: () => void; @@ -1088,8 +1087,6 @@ ${escapedMessage} } createCollisionWithPlayer() { - this.physics.disableUpdate(); - this.physicsEnabled = false; //add collision layer this.Layers.forEach((Layer: Phaser.Tilemaps.TilemapLayer) => { this.physics.add.collider(this.CurrentPlayer, Layer, (object1: GameObject, object2: GameObject) => { @@ -1223,20 +1220,7 @@ ${escapedMessage} this.dirty = false; mediaManager.updateScene(); this.currentTick = time; - if (this.CurrentPlayer.isMoving()) { - this.dirty = true; - } this.CurrentPlayer.moveUser(delta); - if (this.CurrentPlayer.isMoving()) { - this.dirty = true; - if (!this.physicsEnabled) { - this.physics.enableUpdate(); - this.physicsEnabled = true; - } - } else if (this.physicsEnabled) { - this.physics.disableUpdate(); - this.physicsEnabled = false; - } // Let's handle all events while (this.pendingEvents.length !== 0) { From 28d78a79881b975fa63cad6772bd32a9314d6754 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20N=C3=A9grier?= Date: Tue, 18 May 2021 16:38:56 +0200 Subject: [PATCH 13/20] Switching MediaManager to using a Svelte store This allows cleaner and more expressive code, especially regarding whether the webcam should be on or off. --- front/src/Phaser/Game/GameScene.ts | 4 +- front/src/Phaser/Login/EnableCameraScene.ts | 51 ++- front/src/Stores/MediaStore.ts | 360 ++++++++++++++++++++ front/src/Stores/PeerStore.ts | 32 ++ front/src/WebRtc/JitsiFactory.ts | 5 + front/src/WebRtc/MediaManager.ts | 14 + front/style/style.css | 2 + 7 files changed, 458 insertions(+), 10 deletions(-) create mode 100644 front/src/Stores/MediaStore.ts create mode 100644 front/src/Stores/PeerStore.ts diff --git a/front/src/Phaser/Game/GameScene.ts b/front/src/Phaser/Game/GameScene.ts index a5b719e5..7bbf1226 100644 --- a/front/src/Phaser/Game/GameScene.ts +++ b/front/src/Phaser/Game/GameScene.ts @@ -92,6 +92,7 @@ import {PinchManager} from "../UserInput/PinchManager"; import {joystickBaseImg, joystickBaseKey, joystickThumbImg, joystickThumbKey} from "../Components/MobileJoystick"; import {DEPTH_OVERLAY_INDEX} from "./DepthIndexes"; import {waScaleManager} from "../Services/WaScaleManager"; +import {peerStore} from "../../Stores/PeerStore"; import {EmoteManager} from "./EmoteManager"; export interface GameSceneInitInterface { @@ -516,7 +517,7 @@ export class GameScene extends DirtyScene implements CenterListener { } document.addEventListener('visibilitychange', this.onVisibilityChangeCallback); - + this.emoteManager = new EmoteManager(this); } @@ -622,6 +623,7 @@ export class GameScene extends DirtyScene implements CenterListener { // When connection is performed, let's connect SimplePeer this.simplePeer = new SimplePeer(this.connection, !this.room.isPublic, this.playerName); + peerStore.connectToSimplePeer(this.simplePeer); this.GlobalMessageManager = new GlobalMessageManager(this.connection); userMessageManager.setReceiveBanListener(this.bannedUser.bind(this)); diff --git a/front/src/Phaser/Login/EnableCameraScene.ts b/front/src/Phaser/Login/EnableCameraScene.ts index 755ac9a0..6002da7b 100644 --- a/front/src/Phaser/Login/EnableCameraScene.ts +++ b/front/src/Phaser/Login/EnableCameraScene.ts @@ -10,6 +10,14 @@ import {PinchManager} from "../UserInput/PinchManager"; import Zone = Phaser.GameObjects.Zone; import { MenuScene } from "../Menu/MenuScene"; import {ResizableScene} from "./ResizableScene"; +import { + audioConstraintStore, + enableCameraSceneVisibilityStore, + localStreamStore, + mediaStreamConstraintsStore, + videoConstraintStore +} from "../../Stores/MediaStore"; +import type {Unsubscriber} from "svelte/store"; export const EnableCameraSceneName = "EnableCameraScene"; enum LoginTextures { @@ -40,6 +48,7 @@ export class EnableCameraScene extends ResizableScene { private enableCameraSceneElement!: Phaser.GameObjects.DOMElement; private mobileTapZone!: Zone; + private localStreamStoreUnsubscriber!: Unsubscriber; constructor() { super({ @@ -119,9 +128,20 @@ export class EnableCameraScene extends ResizableScene { HtmlUtils.getElementByIdOrFail('webRtcSetup').classList.add('active'); - const mediaPromise = mediaManager.getCamera(); + this.localStreamStoreUnsubscriber = localStreamStore.subscribe((result) => { + if (result.type === 'error') { + // TODO: proper handling of the error + throw result.error; + } + + this.getDevices(); + if (result.stream !== null) { + this.setupStream(result.stream); + } + }); + /*const mediaPromise = mediaManager.getCamera(); mediaPromise.then(this.getDevices.bind(this)); - mediaPromise.then(this.setupStream.bind(this)); + mediaPromise.then(this.setupStream.bind(this));*/ this.input.keyboard.on('keydown-RIGHT', this.nextCam.bind(this)); this.input.keyboard.on('keydown-LEFT', this.previousCam.bind(this)); @@ -133,6 +153,8 @@ export class EnableCameraScene extends ResizableScene { this.add.existing(this.soundMeterSprite); this.onResize(); + + enableCameraSceneVisibilityStore.showEnableCameraScene(); } private previousCam(): void { @@ -140,7 +162,9 @@ export class EnableCameraScene extends ResizableScene { return; } this.cameraSelected--; - mediaManager.setCamera(this.camerasList[this.cameraSelected].deviceId).then(this.setupStream.bind(this)); + videoConstraintStore.setDeviceId(this.camerasList[this.cameraSelected].deviceId); + + //mediaManager.setCamera(this.camerasList[this.cameraSelected].deviceId).then(this.setupStream.bind(this)); } private nextCam(): void { @@ -148,8 +172,10 @@ export class EnableCameraScene extends ResizableScene { return; } this.cameraSelected++; + videoConstraintStore.setDeviceId(this.camerasList[this.cameraSelected].deviceId); + // TODO: the change of camera should be OBSERVED (reactive) - mediaManager.setCamera(this.camerasList[this.cameraSelected].deviceId).then(this.setupStream.bind(this)); + //mediaManager.setCamera(this.camerasList[this.cameraSelected].deviceId).then(this.setupStream.bind(this)); } private previousMic(): void { @@ -157,7 +183,8 @@ export class EnableCameraScene extends ResizableScene { return; } this.microphoneSelected--; - mediaManager.setMicrophone(this.microphonesList[this.microphoneSelected].deviceId).then(this.setupStream.bind(this)); + audioConstraintStore.setDeviceId(this.microphonesList[this.microphoneSelected].deviceId); + //mediaManager.setMicrophone(this.microphonesList[this.microphoneSelected].deviceId).then(this.setupStream.bind(this)); } private nextMic(): void { @@ -165,8 +192,9 @@ export class EnableCameraScene extends ResizableScene { return; } this.microphoneSelected++; + audioConstraintStore.setDeviceId(this.microphonesList[this.microphoneSelected].deviceId); // TODO: the change of camera should be OBSERVED (reactive) - mediaManager.setMicrophone(this.microphonesList[this.microphoneSelected].deviceId).then(this.setupStream.bind(this)); + //mediaManager.setMicrophone(this.microphonesList[this.microphoneSelected].deviceId).then(this.setupStream.bind(this)); } /** @@ -260,15 +288,20 @@ export class EnableCameraScene extends ResizableScene { HtmlUtils.getElementByIdOrFail('webRtcSetup').style.display = 'none'; this.soundMeter.stop(); - mediaManager.stopCamera(); - mediaManager.stopMicrophone(); + enableCameraSceneVisibilityStore.hideEnableCameraScene(); + this.localStreamStoreUnsubscriber(); + //mediaManager.stopCamera(); + //mediaManager.stopMicrophone(); - this.scene.sleep(EnableCameraSceneName) + this.scene.sleep(EnableCameraSceneName); gameManager.goToStartingMap(this.scene); } private async getDevices() { + // TODO: switch this in a store. const mediaDeviceInfos = await navigator.mediaDevices.enumerateDevices(); + this.microphonesList = []; + this.camerasList = []; for (const mediaDeviceInfo of mediaDeviceInfos) { if (mediaDeviceInfo.kind === 'audioinput') { this.microphonesList.push(mediaDeviceInfo); diff --git a/front/src/Stores/MediaStore.ts b/front/src/Stores/MediaStore.ts new file mode 100644 index 00000000..e0f351f2 --- /dev/null +++ b/front/src/Stores/MediaStore.ts @@ -0,0 +1,360 @@ +import {derived, Readable, readable, writable, Writable} from "svelte/store"; +import {peerStore} from "./PeerStore"; +import {localUserStore} from "../Connexion/LocalUserStore"; +import {ITiledMapGroupLayer, ITiledMapObjectLayer, ITiledMapTileLayer} from "../Phaser/Map/ITiledMap"; + +/** + * A store that contains the camera state requested by the user (on or off). + */ +function createRequestedCameraState() { + const { subscribe, set, update } = writable(true); + + return { + subscribe, + enableWebcam: () => set(true), + disableWebcam: () => set(false), + }; +} + +/** + * A store that contains the microphone state requested by the user (on or off). + */ +function createRequestedMicrophoneState() { + const { subscribe, set, update } = writable(true); + + return { + subscribe, + enableMicrophone: () => set(true), + disableMicrophone: () => set(false), + }; +} + +/** + * A store containing whether the current page is visible or not. + */ +export const visibilityStore = readable(document.visibilityState === 'visible', function start(set) { + const onVisibilityChange = () => { + set(document.visibilityState === 'visible'); + }; + + document.addEventListener('visibilitychange', onVisibilityChange); + + return function stop() { + document.removeEventListener('visibilitychange', onVisibilityChange); + }; +}); + +/** + * A store that contains whether the game overlay is shown or not. + * Typically, the overlay is hidden when entering Jitsi meet. + */ +function createGameOverlayVisibilityStore() { + const { subscribe, set, update } = writable(false); + + return { + subscribe, + showGameOverlay: () => set(true), + hideGameOverlay: () => set(false), + }; +} + +/** + * A store that contains whether the EnableCameraScene is shown or not. + */ +function createEnableCameraSceneVisibilityStore() { + const { subscribe, set, update } = writable(false); + + return { + subscribe, + showEnableCameraScene: () => set(true), + hideEnableCameraScene: () => set(false), + }; +} + +export const requestedCameraState = createRequestedCameraState(); +export const requestedMicrophoneState = createRequestedMicrophoneState(); +export const gameOverlayVisibilityStore = createGameOverlayVisibilityStore(); +export const enableCameraSceneVisibilityStore = createEnableCameraSceneVisibilityStore(); + +/** + * A store that contains video constraints. + */ +function createVideoConstraintStore() { + const { subscribe, set, update } = writable({ + width: { min: 640, ideal: 1280, max: 1920 }, + height: { min: 400, ideal: 720 }, + frameRate: { ideal: localUserStore.getVideoQualityValue() }, + facingMode: "user", + resizeMode: 'crop-and-scale', + aspectRatio: 1.777777778 + } as boolean|MediaTrackConstraints); + + let selectedDeviceId = null; + + return { + subscribe, + setDeviceId: (deviceId: string) => update((constraints) => { + selectedDeviceId = deviceId; + + if (typeof(constraints) === 'boolean') { + constraints = {} + } + constraints.deviceId = { + exact: selectedDeviceId + }; + + return constraints; + }) + }; +} + +export const videoConstraintStore = createVideoConstraintStore(); + +/** + * A store that contains video constraints. + */ +function createAudioConstraintStore() { + const { subscribe, set, update } = writable({ + //TODO: make these values configurable in the game settings menu and store them in localstorage + autoGainControl: false, + echoCancellation: true, + noiseSuppression: true + } as boolean|MediaTrackConstraints); + + let selectedDeviceId = null; + + return { + subscribe, + setDeviceId: (deviceId: string) => update((constraints) => { + selectedDeviceId = deviceId; + + if (typeof(constraints) === 'boolean') { + constraints = {} + } + constraints.deviceId = { + exact: selectedDeviceId + }; + + return constraints; + }) + }; +} + +export const audioConstraintStore = createAudioConstraintStore(); + + +let timeout: NodeJS.Timeout; + +/** + * A store containing the media constraints we want to apply. + */ +export const mediaStreamConstraintsStore = derived( + [ + requestedCameraState, + requestedMicrophoneState, + visibilityStore, + gameOverlayVisibilityStore, + peerStore, + enableCameraSceneVisibilityStore, + videoConstraintStore, + audioConstraintStore, + ], ( + [ + $requestedCameraState, + $requestedMicrophoneState, + $visibilityStore, + $gameOverlayVisibilityStore, + $peerStore, + $enableCameraSceneVisibilityStore, + $videoConstraintStore, + $audioConstraintStore, + ], set + ) => { + let currentVideoConstraint: boolean|MediaTrackConstraints = $videoConstraintStore; + let currentAudioConstraint: boolean|MediaTrackConstraints = $audioConstraintStore; + + if ($enableCameraSceneVisibilityStore) { + set({ + video: currentVideoConstraint, + audio: currentAudioConstraint, + }); + return; + } + + // Disable webcam if the user requested so + if ($requestedCameraState === false) { + currentVideoConstraint = false; + } + + // Disable microphone if the user requested so + if ($requestedMicrophoneState === false) { + currentAudioConstraint = false; + } + + // Disable webcam and microphone when in a Jitsi + if ($gameOverlayVisibilityStore === false) { + currentVideoConstraint = false; + currentAudioConstraint = false; + } + + // Disable webcam if the game is not visible and we are talking to noone. + if ($visibilityStore === false && $peerStore.size === 0) { + currentVideoConstraint = false; + } + + if (timeout) { + clearTimeout(timeout); + } + + // Let's wait a little bit to avoid sending too many constraint changes. + timeout = setTimeout(() => { + set({ + video: currentVideoConstraint, + audio: currentAudioConstraint, + }); + }, 100) +}, { + video: false, + audio: false +} as MediaStreamConstraints); + +export type LocalStreamStoreValue = StreamSuccessValue | StreamErrorValue; + +interface StreamSuccessValue { + type: "success", + stream: MediaStream|null, + // The constraints that we got (and not the one that have been requested) + constraints: MediaStreamConstraints +} + +interface StreamErrorValue { + type: "error", + error: Error, + constraints: MediaStreamConstraints +} + +let currentStream : MediaStream|null = null; + +/** + * Stops the camera from filming + */ +function stopCamera(): void { + if (currentStream) { + for (const track of currentStream.getVideoTracks()) { + track.stop(); + } + } +} + +/** + * Stops the microphone from listening + */ +function stopMicrophone(): void { + if (currentStream) { + for (const track of currentStream.getAudioTracks()) { + track.stop(); + } + } +} + +/** + * A store containing the MediaStream object (or null if nothing requested, or Error if an error occurred) + */ +export const localStreamStore = derived, LocalStreamStoreValue>(mediaStreamConstraintsStore, ($mediaStreamConstraintsStore, set) => { + const constraints = { ...$mediaStreamConstraintsStore }; + + if (navigator.mediaDevices === undefined) { + if (window.location.protocol === 'http:') { + //throw new Error('Unable to access your camera or microphone. You need to use a HTTPS connection.'); + set({ + type: 'error', + error: new Error('Unable to access your camera or microphone. You need to use a HTTPS connection.'), + constraints + }); + } else { + //throw new Error('Unable to access your camera or microphone. Your browser is too old.'); + set({ + type: 'error', + error: new Error('Unable to access your camera or microphone. Your browser is too old.'), + constraints + }); + } + } + + if (constraints.audio === false) { + stopMicrophone(); + } + if (constraints.video === false) { + stopCamera(); + } + + if (constraints.audio === false && constraints.video === false) { + set({ + type: 'success', + stream: null, + constraints + }); + return; + } + + (async () => { + try { + currentStream = await navigator.mediaDevices.getUserMedia(constraints); + set({ + type: 'success', + stream: currentStream, + constraints + }); + return; + } catch (e) { + if (constraints.video !== false) { + console.info("Error. Unable to get microphone and/or camera access. Trying audio only.", $mediaStreamConstraintsStore, e); + // TODO: does it make sense to pop this error when retrying? + set({ + type: 'error', + error: e, + constraints + }); + // Let's try without video constraints + requestedCameraState.disableWebcam(); + } else { + console.info("Error. Unable to get microphone and/or camera access.", $mediaStreamConstraintsStore, e); + set({ + type: 'error', + error: e, + constraints + }); + } + + /*constraints.video = false; + if (constraints.audio === false) { + console.info("Error. Unable to get microphone and/or camera access.", $mediaStreamConstraintsStore, e); + set({ + type: 'error', + error: e, + constraints + }); + // Let's make as if the user did not ask. + requestedCameraState.disableWebcam(); + } else { + console.info("Error. Unable to get microphone and/or camera access. Trying audio only.", $mediaStreamConstraintsStore, e); + try { + currentStream = await navigator.mediaDevices.getUserMedia(constraints); + set({ + type: 'success', + stream: currentStream, + constraints + }); + return; + } catch (e2) { + console.info("Error. Unable to get microphone fallback access.", $mediaStreamConstraintsStore, e2); + set({ + type: 'error', + error: e, + constraints + }); + } + }*/ + } + })(); +}); diff --git a/front/src/Stores/PeerStore.ts b/front/src/Stores/PeerStore.ts new file mode 100644 index 00000000..14d14754 --- /dev/null +++ b/front/src/Stores/PeerStore.ts @@ -0,0 +1,32 @@ +import { derived, writable, Writable } from "svelte/store"; +import type {UserSimplePeerInterface} from "../WebRtc/SimplePeer"; +import type {SimplePeer} from "../WebRtc/SimplePeer"; + +/** + * A store that contains the camera state requested by the user (on or off). + */ +function createPeerStore() { + let users = new Map(); + + const { subscribe, set, update } = writable(users); + + return { + subscribe, + connectToSimplePeer: (simplePeer: SimplePeer) => { + users = new Map(); + set(users); + simplePeer.registerPeerConnectionListener({ + onConnect(user: UserSimplePeerInterface) { + users.set(user.userId, user); + set(users); + }, + onDisconnect(userId: number) { + users.delete(userId); + set(users); + } + }) + } + }; +} + +export const peerStore = createPeerStore(); diff --git a/front/src/WebRtc/JitsiFactory.ts b/front/src/WebRtc/JitsiFactory.ts index 8ddbba7b..4e70a4d2 100644 --- a/front/src/WebRtc/JitsiFactory.ts +++ b/front/src/WebRtc/JitsiFactory.ts @@ -1,6 +1,7 @@ import {JITSI_URL} from "../Enum/EnvironmentVariable"; import {mediaManager} from "./MediaManager"; import {coWebsiteManager} from "./CoWebsiteManager"; +import {requestedCameraState, requestedMicrophoneState} from "../Stores/MediaStore"; declare const window:any; // eslint-disable-line @typescript-eslint/no-explicit-any interface jitsiConfigInterface { @@ -138,14 +139,18 @@ class JitsiFactory { //restore previous config if(this.previousConfigMeet?.startWithAudioMuted){ await mediaManager.disableMicrophone(); + requestedMicrophoneState.disableMicrophone(); }else{ await mediaManager.enableMicrophone(); + requestedMicrophoneState.enableMicrophone(); } if(this.previousConfigMeet?.startWithVideoMuted){ await mediaManager.disableCamera(); + requestedCameraState.disableWebcam(); }else{ await mediaManager.enableCamera(); + requestedCameraState.enableWebcam(); } } diff --git a/front/src/WebRtc/MediaManager.ts b/front/src/WebRtc/MediaManager.ts index b7594670..e604c50f 100644 --- a/front/src/WebRtc/MediaManager.ts +++ b/front/src/WebRtc/MediaManager.ts @@ -6,6 +6,12 @@ import {localUserStore} from "../Connexion/LocalUserStore"; import type {UserSimplePeerInterface} from "./SimplePeer"; import {SoundMeter} from "../Phaser/Components/SoundMeter"; import {DISABLE_NOTIFICATIONS} from "../Enum/EnvironmentVariable"; +import { + gameOverlayVisibilityStore, + mediaStreamConstraintsStore, + requestedCameraState, + requestedMicrophoneState +} from "../Stores/MediaStore"; declare const navigator:any; // eslint-disable-line @typescript-eslint/no-explicit-any @@ -90,12 +96,14 @@ export class MediaManager { e.preventDefault(); this.enableMicrophone(); //update tracking + requestedMicrophoneState.enableMicrophone(); }); this.microphone = HtmlUtils.getElementByIdOrFail('microphone'); this.microphone.addEventListener('click', (e: MouseEvent) => { e.preventDefault(); this.disableMicrophone(); //update tracking + requestedMicrophoneState.disableMicrophone(); }); this.cinemaBtn = HtmlUtils.getElementByIdOrFail('btn-video'); @@ -105,12 +113,14 @@ export class MediaManager { e.preventDefault(); this.enableCamera(); //update tracking + requestedCameraState.enableWebcam(); }); this.cinema = HtmlUtils.getElementByIdOrFail('cinema'); this.cinema.addEventListener('click', (e: MouseEvent) => { e.preventDefault(); this.disableCamera(); //update tracking + requestedCameraState.disableWebcam(); }); this.monitorBtn = HtmlUtils.getElementByIdOrFail('btn-monitor'); @@ -214,6 +224,8 @@ export class MediaManager { this.triggerCloseJitsiFrameButton(); } buttonCloseFrame.removeEventListener('click', functionTrigger); + + gameOverlayVisibilityStore.showGameOverlay(); } public hideGameOverlay(): void { @@ -225,6 +237,8 @@ export class MediaManager { this.triggerCloseJitsiFrameButton(); } buttonCloseFrame.addEventListener('click', functionTrigger); + + gameOverlayVisibilityStore.hideGameOverlay(); } public isGameOverlayVisible(): boolean { diff --git a/front/style/style.css b/front/style/style.css index d95ac701..d6b5c433 100644 --- a/front/style/style.css +++ b/front/style/style.css @@ -346,6 +346,8 @@ video#myCamVideo{ #myCamVideoSetup { width: 100%; height: 100%; + -webkit-transform: scaleX(-1); + transform: scaleX(-1); } .webrtcsetup.active{ display: block; From 8af8ccd54b11f48361dee778b8bac6a1dd6a5509 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20N=C3=A9grier?= Date: Wed, 19 May 2021 11:17:43 +0200 Subject: [PATCH 14/20] Migrating MediaManager game part to Svelte store --- front/src/Phaser/Game/GameScene.ts | 26 -- .../Phaser/Menu/HelpCameraSettingsScene.ts | 4 +- front/src/Phaser/Menu/MenuScene.ts | 3 +- front/src/Stores/MediaStore.ts | 109 ++++++-- front/src/Stores/PeerStore.ts | 12 +- front/src/WebRtc/JitsiFactory.ts | 39 +-- front/src/WebRtc/MediaManager.ts | 264 +++--------------- front/src/WebRtc/SimplePeer.ts | 22 +- front/src/WebRtc/VideoPeer.ts | 4 +- 9 files changed, 161 insertions(+), 322 deletions(-) diff --git a/front/src/Phaser/Game/GameScene.ts b/front/src/Phaser/Game/GameScene.ts index 7bbf1226..95ec6689 100644 --- a/front/src/Phaser/Game/GameScene.ts +++ b/front/src/Phaser/Game/GameScene.ts @@ -190,7 +190,6 @@ export class GameScene extends DirtyScene implements CenterListener { private originalMapUrl: string|undefined; private pinchManager: PinchManager|undefined; private mapTransitioning: boolean = false; //used to prevent transitions happenning at the same time. - private onVisibilityChangeCallback: () => void; private emoteManager!: EmoteManager; constructor(private room: Room, MapUrlFile: string, customKey?: string|undefined) { @@ -211,7 +210,6 @@ export class GameScene extends DirtyScene implements CenterListener { this.connectionAnswerPromise = new Promise((resolve, reject): void => { this.connectionAnswerPromiseResolve = resolve; }); - this.onVisibilityChangeCallback = this.onVisibilityChange.bind(this); } //hook preload scene @@ -516,8 +514,6 @@ export class GameScene extends DirtyScene implements CenterListener { this.connect(); } - document.addEventListener('visibilitychange', this.onVisibilityChangeCallback); - this.emoteManager = new EmoteManager(this); } @@ -641,7 +637,6 @@ export class GameScene extends DirtyScene implements CenterListener { self.chatModeSprite.setVisible(false); self.openChatIcon.setVisible(false); audioManager.restoreVolume(); - self.onVisibilityChange(); } } }) @@ -946,8 +941,6 @@ ${escapedMessage} for(const iframeEvents of this.iframeSubscriptionList){ iframeEvents.unsubscribe(); } - - document.removeEventListener('visibilitychange', this.onVisibilityChangeCallback); } private removeAllRemotePlayers(): void { @@ -1507,8 +1500,6 @@ ${escapedMessage} mediaManager.addTriggerCloseJitsiFrameButton('close-jisi',() => { this.stopJitsi(); }); - - this.onVisibilityChange(); } public stopJitsi(): void { @@ -1517,7 +1508,6 @@ ${escapedMessage} mediaManager.showGameOverlay(); mediaManager.removeTriggerCloseJitsiFrameButton('close-jisi'); - this.onVisibilityChange(); } //todo: put this into an 'orchestrator' scene (EntryScene?) @@ -1557,20 +1547,4 @@ ${escapedMessage} waScaleManager.zoomModifier *= zoomFactor; this.updateCameraOffset(); } - - private onVisibilityChange(): void { - // If the overlay is not displayed, we are in Jitsi. We don't need the webcam. - if (!mediaManager.isGameOverlayVisible()) { - mediaManager.blurCamera(); - return; - } - - if (document.visibilityState === 'visible') { - mediaManager.focusCamera(); - } else { - if (this.simplePeer.getNbConnections() === 0) { - mediaManager.blurCamera(); - } - } - } } diff --git a/front/src/Phaser/Menu/HelpCameraSettingsScene.ts b/front/src/Phaser/Menu/HelpCameraSettingsScene.ts index 6e80b8d4..6bc520c0 100644 --- a/front/src/Phaser/Menu/HelpCameraSettingsScene.ts +++ b/front/src/Phaser/Menu/HelpCameraSettingsScene.ts @@ -2,6 +2,8 @@ import {mediaManager} from "../../WebRtc/MediaManager"; import {HtmlUtils} from "../../WebRtc/HtmlUtils"; import {localUserStore} from "../../Connexion/LocalUserStore"; import {DirtyScene} from "../Game/DirtyScene"; +import {get} from "svelte/store"; +import {requestedCameraState, requestedMicrophoneState} from "../../Stores/MediaStore"; export const HelpCameraSettingsSceneName = 'HelpCameraSettingsScene'; const helpCameraSettings = 'helpCameraSettings'; @@ -41,7 +43,7 @@ export class HelpCameraSettingsScene extends DirtyScene { } }); - if(!localUserStore.getHelpCameraSettingsShown() && (!mediaManager.constraintsMedia.audio || !mediaManager.constraintsMedia.video)){ + if(!localUserStore.getHelpCameraSettingsShown() && (!get(requestedMicrophoneState) || !get(requestedCameraState))){ this.openHelpCameraSettingsOpened(); localUserStore.setHelpCameraSettingsShown(); } diff --git a/front/src/Phaser/Menu/MenuScene.ts b/front/src/Phaser/Menu/MenuScene.ts index 76bf520f..54fa395a 100644 --- a/front/src/Phaser/Menu/MenuScene.ts +++ b/front/src/Phaser/Menu/MenuScene.ts @@ -10,6 +10,7 @@ import {GameConnexionTypes} from "../../Url/UrlManager"; import {WarningContainer, warningContainerHtml, warningContainerKey} from "../Components/WarningContainer"; import {worldFullWarningStream} from "../../Connexion/WorldFullWarningStream"; import {menuIconVisible} from "../../Stores/MenuStore"; +import {videoConstraintStore} from "../../Stores/MediaStore"; export const MenuSceneName = 'MenuScene'; const gameMenuKey = 'gameMenu'; @@ -324,7 +325,7 @@ export class MenuScene extends Phaser.Scene { if (valueVideo !== this.videoQualityValue) { this.videoQualityValue = valueVideo; localUserStore.setVideoQualityValue(valueVideo); - mediaManager.updateCameraQuality(valueVideo); + videoConstraintStore.setFrameRate(valueVideo); } this.closeGameQualityMenu(); } diff --git a/front/src/Stores/MediaStore.ts b/front/src/Stores/MediaStore.ts index e0f351f2..3945c01d 100644 --- a/front/src/Stores/MediaStore.ts +++ b/front/src/Stores/MediaStore.ts @@ -1,4 +1,4 @@ -import {derived, Readable, readable, writable, Writable} from "svelte/store"; +import {derived, get, Readable, readable, writable, Writable} from "svelte/store"; import {peerStore} from "./PeerStore"; import {localUserStore} from "../Connexion/LocalUserStore"; import {ITiledMapGroupLayer, ITiledMapObjectLayer, ITiledMapTileLayer} from "../Phaser/Map/ITiledMap"; @@ -76,6 +76,40 @@ export const requestedMicrophoneState = createRequestedMicrophoneState(); export const gameOverlayVisibilityStore = createGameOverlayVisibilityStore(); export const enableCameraSceneVisibilityStore = createEnableCameraSceneVisibilityStore(); +/** + * A store that contains "true" if the webcam should be stopped for privacy reasons - i.e. if the the user left the the page while not in a discussion. + */ +function createPrivacyShutdownStore() { + let privacyEnabled = false; + + const { subscribe, set, update } = writable(privacyEnabled); + + visibilityStore.subscribe((isVisible) => { + if (!isVisible && get(peerStore).size === 0) { + privacyEnabled = true; + set(true); + } + if (isVisible) { + privacyEnabled = false; + set(false); + } + }); + + peerStore.subscribe((peers) => { + if (peers.size === 0 && get(visibilityStore) === false) { + privacyEnabled = true; + set(true); + } + }); + + + return { + subscribe, + }; +} + +export const privacyShutdownStore = createPrivacyShutdownStore(); + /** * A store that contains video constraints. */ @@ -87,22 +121,20 @@ function createVideoConstraintStore() { facingMode: "user", resizeMode: 'crop-and-scale', aspectRatio: 1.777777778 - } as boolean|MediaTrackConstraints); - - let selectedDeviceId = null; + } as MediaTrackConstraints); return { subscribe, setDeviceId: (deviceId: string) => update((constraints) => { - selectedDeviceId = deviceId; - - if (typeof(constraints) === 'boolean') { - constraints = {} - } constraints.deviceId = { - exact: selectedDeviceId + exact: deviceId }; + return constraints; + }), + setFrameRate: (frameRate: number) => update((constraints) => { + constraints.frameRate = { ideal: frameRate }; + return constraints; }) }; @@ -145,6 +177,9 @@ export const audioConstraintStore = createAudioConstraintStore(); let timeout: NodeJS.Timeout; +let previousComputedVideoConstraint: boolean|MediaTrackConstraints = false; +let previousComputedAudioConstraint: boolean|MediaTrackConstraints = false; + /** * A store containing the media constraints we want to apply. */ @@ -152,24 +187,23 @@ export const mediaStreamConstraintsStore = derived( [ requestedCameraState, requestedMicrophoneState, - visibilityStore, gameOverlayVisibilityStore, - peerStore, enableCameraSceneVisibilityStore, videoConstraintStore, audioConstraintStore, + privacyShutdownStore, ], ( [ $requestedCameraState, $requestedMicrophoneState, - $visibilityStore, $gameOverlayVisibilityStore, - $peerStore, $enableCameraSceneVisibilityStore, $videoConstraintStore, $audioConstraintStore, + $privacyShutdownStore, ], set ) => { + let currentVideoConstraint: boolean|MediaTrackConstraints = $videoConstraintStore; let currentAudioConstraint: boolean|MediaTrackConstraints = $audioConstraintStore; @@ -197,22 +231,35 @@ export const mediaStreamConstraintsStore = derived( currentAudioConstraint = false; } - // Disable webcam if the game is not visible and we are talking to noone. - if ($visibilityStore === false && $peerStore.size === 0) { + // Disable webcam for privacy reasons (the game is not visible and we were talking to noone) + if ($privacyShutdownStore === true) { currentVideoConstraint = false; } - if (timeout) { - clearTimeout(timeout); - } + // Let's make the changes only if the new value is different from the old one. + if (previousComputedVideoConstraint != currentVideoConstraint || previousComputedAudioConstraint != currentAudioConstraint) { + previousComputedVideoConstraint = currentVideoConstraint; + previousComputedAudioConstraint = currentAudioConstraint; + // Let's copy the objects. + if (typeof previousComputedVideoConstraint !== 'boolean') { + previousComputedVideoConstraint = {...previousComputedVideoConstraint}; + } + if (typeof previousComputedAudioConstraint !== 'boolean') { + previousComputedAudioConstraint = {...previousComputedAudioConstraint}; + } - // Let's wait a little bit to avoid sending too many constraint changes. - timeout = setTimeout(() => { - set({ - video: currentVideoConstraint, - audio: currentAudioConstraint, - }); - }, 100) + if (timeout) { + clearTimeout(timeout); + } + + // Let's wait a little bit to avoid sending too many constraint changes. + timeout = setTimeout(() => { + set({ + video: currentVideoConstraint, + audio: currentAudioConstraint, + }); + }, 100); + } }, { video: false, audio: false @@ -289,6 +336,7 @@ export const localStreamStore = derived, LocalS } if (constraints.audio === false && constraints.video === false) { + currentStream = null; set({ type: 'success', stream: null, @@ -299,6 +347,8 @@ export const localStreamStore = derived, LocalS (async () => { try { + stopMicrophone(); + stopCamera(); currentStream = await navigator.mediaDevices.getUserMedia(constraints); set({ type: 'success', @@ -358,3 +408,10 @@ export const localStreamStore = derived, LocalS } })(); }); + +/** + * A store containing the real active media constrained (not the one requested by the user, but the one we got from the system) + */ +export const obtainedMediaConstraintStore = derived(localStreamStore, ($localStreamStore) => { + return $localStreamStore.constraints; +}); diff --git a/front/src/Stores/PeerStore.ts b/front/src/Stores/PeerStore.ts index 14d14754..a582e692 100644 --- a/front/src/Stores/PeerStore.ts +++ b/front/src/Stores/PeerStore.ts @@ -17,12 +17,16 @@ function createPeerStore() { set(users); simplePeer.registerPeerConnectionListener({ onConnect(user: UserSimplePeerInterface) { - users.set(user.userId, user); - set(users); + update(users => { + users.set(user.userId, user); + return users; + }); }, onDisconnect(userId: number) { - users.delete(userId); - set(users); + update(users => { + users.delete(userId); + return users; + }); } }) } diff --git a/front/src/WebRtc/JitsiFactory.ts b/front/src/WebRtc/JitsiFactory.ts index 4e70a4d2..d2b9ebdd 100644 --- a/front/src/WebRtc/JitsiFactory.ts +++ b/front/src/WebRtc/JitsiFactory.ts @@ -2,6 +2,7 @@ import {JITSI_URL} from "../Enum/EnvironmentVariable"; import {mediaManager} from "./MediaManager"; import {coWebsiteManager} from "./CoWebsiteManager"; import {requestedCameraState, requestedMicrophoneState} from "../Stores/MediaStore"; +import {get} from "svelte/store"; declare const window:any; // eslint-disable-line @typescript-eslint/no-explicit-any interface jitsiConfigInterface { @@ -11,10 +12,9 @@ interface jitsiConfigInterface { } const getDefaultConfig = () : jitsiConfigInterface => { - const constraints = mediaManager.getConstraintRequestedByUser(); return { - startWithAudioMuted: !constraints.audio, - startWithVideoMuted: constraints.video === false, + startWithAudioMuted: !get(requestedMicrophoneState), + startWithVideoMuted: !get(requestedCameraState), prejoinPageEnabled: false } } @@ -73,7 +73,6 @@ class JitsiFactory { private jitsiApi: any; // eslint-disable-line @typescript-eslint/no-explicit-any private audioCallback = this.onAudioChange.bind(this); private videoCallback = this.onVideoChange.bind(this); - private previousConfigMeet! : jitsiConfigInterface; private jitsiScriptLoaded: boolean = false; /** @@ -84,9 +83,6 @@ class JitsiFactory { } public start(roomName: string, playerName:string, jwt?: string, config?: object, interfaceConfig?: object, jitsiUrl?: string): void { - //save previous config - this.previousConfigMeet = getDefaultConfig(); - coWebsiteManager.insertCoWebsite((async cowebsiteDiv => { // Jitsi meet external API maintains some data in local storage // which is sent via the appData URL parameter when joining a @@ -135,31 +131,22 @@ class JitsiFactory { this.jitsiApi.removeListener('audioMuteStatusChanged', this.audioCallback); this.jitsiApi.removeListener('videoMuteStatusChanged', this.videoCallback); this.jitsiApi?.dispose(); - - //restore previous config - if(this.previousConfigMeet?.startWithAudioMuted){ - await mediaManager.disableMicrophone(); - requestedMicrophoneState.disableMicrophone(); - }else{ - await mediaManager.enableMicrophone(); - requestedMicrophoneState.enableMicrophone(); - } - - if(this.previousConfigMeet?.startWithVideoMuted){ - await mediaManager.disableCamera(); - requestedCameraState.disableWebcam(); - }else{ - await mediaManager.enableCamera(); - requestedCameraState.enableWebcam(); - } } private onAudioChange({muted}: {muted: boolean}): void { - this.previousConfigMeet.startWithAudioMuted = muted; + if (muted) { + requestedMicrophoneState.disableMicrophone(); + } else { + requestedMicrophoneState.enableMicrophone(); + } } private onVideoChange({muted}: {muted: boolean}): void { - this.previousConfigMeet.startWithVideoMuted = muted; + if (muted) { + requestedCameraState.disableWebcam(); + } else { + requestedCameraState.enableWebcam(); + } } private async loadJitsiScript(domain: string): Promise { diff --git a/front/src/WebRtc/MediaManager.ts b/front/src/WebRtc/MediaManager.ts index e604c50f..7c399e32 100644 --- a/front/src/WebRtc/MediaManager.ts +++ b/front/src/WebRtc/MediaManager.ts @@ -7,7 +7,7 @@ import type {UserSimplePeerInterface} from "./SimplePeer"; import {SoundMeter} from "../Phaser/Components/SoundMeter"; import {DISABLE_NOTIFICATIONS} from "../Enum/EnvironmentVariable"; import { - gameOverlayVisibilityStore, + gameOverlayVisibilityStore, localStreamStore, mediaStreamConstraintsStore, requestedCameraState, requestedMicrophoneState @@ -15,7 +15,7 @@ import { declare const navigator:any; // eslint-disable-line @typescript-eslint/no-explicit-any -let videoConstraint: boolean|MediaTrackConstraints = { +const videoConstraint: boolean|MediaTrackConstraints = { width: { min: 640, ideal: 1280, max: 1920 }, height: { min: 400, ideal: 720 }, frameRate: { ideal: localUserStore.getVideoQualityValue() }, @@ -37,7 +37,6 @@ export type ReportCallback = (message: string) => void; export type ShowReportCallBack = (userId: string, userName: string|undefined) => void; export type HelpCameraSettingsCallBack = () => void; -// TODO: Split MediaManager in 2 classes: MediaManagerUI (in charge of HTML) and MediaManager (singleton in charge of the camera only) export class MediaManager { localStream: MediaStream|null = null; localScreenCapture: MediaStream|null = null; @@ -53,10 +52,6 @@ export class MediaManager { //FIX ME SOUNDMETER: check stalability of sound meter calculation //mySoundMeterElement: HTMLDivElement; private webrtcOutAudio: HTMLAudioElement; - constraintsMedia : MediaStreamConstraints = { - audio: audioConstraint, - video: videoConstraint - }; updatedLocalStreamCallBacks : Set = new Set(); startScreenSharingCallBacks : Set = new Set(); stopScreenSharingCallBacks : Set = new Set(); @@ -67,11 +62,8 @@ export class MediaManager { private cinemaBtn: HTMLDivElement; private monitorBtn: HTMLDivElement; - private previousConstraint : MediaStreamConstraints; private focused : boolean = true; - private hasCamera = true; - private triggerCloseJistiFrame : Map = new Map(); private userInputManager?: UserInputManager; @@ -94,15 +86,11 @@ export class MediaManager { this.microphoneClose.style.display = "none"; this.microphoneClose.addEventListener('click', (e: MouseEvent) => { e.preventDefault(); - this.enableMicrophone(); - //update tracking requestedMicrophoneState.enableMicrophone(); }); this.microphone = HtmlUtils.getElementByIdOrFail('microphone'); this.microphone.addEventListener('click', (e: MouseEvent) => { e.preventDefault(); - this.disableMicrophone(); - //update tracking requestedMicrophoneState.disableMicrophone(); }); @@ -111,15 +99,11 @@ export class MediaManager { this.cinemaClose.style.display = "none"; this.cinemaClose.addEventListener('click', (e: MouseEvent) => { e.preventDefault(); - this.enableCamera(); - //update tracking requestedCameraState.enableWebcam(); }); this.cinema = HtmlUtils.getElementByIdOrFail('cinema'); this.cinema.addEventListener('click', (e: MouseEvent) => { e.preventDefault(); - this.disableCamera(); - //update tracking requestedCameraState.disableWebcam(); }); @@ -129,20 +113,17 @@ export class MediaManager { this.monitorClose.addEventListener('click', (e: MouseEvent) => { e.preventDefault(); this.enableScreenSharing(); - //update tracking }); this.monitor = HtmlUtils.getElementByIdOrFail('monitor'); this.monitor.style.display = "none"; this.monitor.addEventListener('click', (e: MouseEvent) => { e.preventDefault(); this.disableScreenSharing(); - //update tracking }); - this.previousConstraint = JSON.parse(JSON.stringify(this.constraintsMedia)); this.pingCameraStatus(); - //FIX ME SOUNDMETER: check stalability of sound meter calculation + //FIX ME SOUNDMETER: check stability of sound meter calculation /*this.mySoundMeterElement = (HtmlUtils.getElementByIdOrFail('mySoundMeter')); this.mySoundMeterElement.childNodes.forEach((value: ChildNode, index) => { this.mySoundMeterElement.children.item(index)?.classList.remove('active'); @@ -150,37 +131,40 @@ export class MediaManager { //Check of ask notification navigator permission this.getNotification(); + + localStreamStore.subscribe((result) => { + if (result.type === 'error') { + console.error(result.error); + layoutManager.addInformation('warning', 'Camera access denied. Click here and check navigators permissions.', () => { + this.showHelpCameraSettingsCallBack(); + }, this.userInputManager); + return; + } + + if (result.constraints.video !== false) { + this.enableCameraStyle(); + } else { + this.disableCameraStyle(); + } + if (result.constraints.audio !== false) { + this.enableMicrophoneStyle(); + } else { + this.disableMicrophoneStyle(); + } + + this.localStream = result.stream; + this.myCamVideo.srcObject = this.localStream; + + // TODO: migrate all listeners to the store directly. + this.triggerUpdatedLocalStreamCallbacks(result.stream); + }); } public updateScene(){ - //FIX ME SOUNDMETER: check stalability of sound meter calculation + //FIX ME SOUNDMETER: check stability of sound meter calculation //this.updateSoudMeter(); } - public blurCamera() { - if(!this.focused){ - return; - } - this.focused = false; - this.previousConstraint = JSON.parse(JSON.stringify(this.constraintsMedia)); - this.disableCamera(); - } - - /** - * Returns the constraint that the user wants (independently of the visibility / jitsi state...) - */ - public getConstraintRequestedByUser(): MediaStreamConstraints { - return this.previousConstraint ?? this.constraintsMedia; - } - - public focusCamera() { - if(this.focused){ - return; - } - this.focused = true; - this.applyPreviousConfig(); - } - public onUpdateLocalStream(callback: UpdatedLocalStreamCallback): void { this.updatedLocalStreamCallBacks.add(callback); } @@ -241,110 +225,6 @@ export class MediaManager { gameOverlayVisibilityStore.hideGameOverlay(); } - public isGameOverlayVisible(): boolean { - const gameOverlay = HtmlUtils.getElementByIdOrFail('game-overlay'); - return gameOverlay.classList.contains('active'); - } - - public updateCameraQuality(value: number) { - this.enableCameraStyle(); - const newVideoConstraint = JSON.parse(JSON.stringify(videoConstraint)); - newVideoConstraint.frameRate = {exact: value, ideal: value}; - videoConstraint = newVideoConstraint; - this.constraintsMedia.video = videoConstraint; - this.getCamera().then((stream: MediaStream) => { - this.triggerUpdatedLocalStreamCallbacks(stream); - }); - } - - public async enableCamera() { - this.constraintsMedia.video = videoConstraint; - - try { - const stream = await this.getCamera() - //TODO show error message tooltip upper of camera button - //TODO message : please check camera permission of your navigator - if(stream.getVideoTracks().length === 0) { - throw new Error('Video track is empty, please check camera permission of your navigator') - } - this.enableCameraStyle(); - this.triggerUpdatedLocalStreamCallbacks(stream); - } catch(err) { - console.error(err); - this.disableCameraStyle(); - this.stopCamera(); - - layoutManager.addInformation('warning', 'Camera access denied. Click here and check navigators permissions.', () => { - this.showHelpCameraSettingsCallBack(); - }, this.userInputManager); - } - } - - public async disableCamera() { - this.disableCameraStyle(); - this.stopCamera(); - - if (this.constraintsMedia.audio !== false) { - const stream = await this.getCamera(); - this.triggerUpdatedLocalStreamCallbacks(stream); - } else { - this.triggerUpdatedLocalStreamCallbacks(null); - } - } - - public async enableMicrophone() { - this.constraintsMedia.audio = audioConstraint; - - try { - const stream = await this.getCamera(); - - //TODO show error message tooltip upper of camera button - //TODO message : please check microphone permission of your navigator - if (stream.getAudioTracks().length === 0) { - throw Error('Audio track is empty, please check microphone permission of your navigator') - } - this.enableMicrophoneStyle(); - this.triggerUpdatedLocalStreamCallbacks(stream); - } catch(err) { - console.error(err); - this.disableMicrophoneStyle(); - - layoutManager.addInformation('warning', 'Microphone access denied. Click here and check navigators permissions.', () => { - this.showHelpCameraSettingsCallBack(); - }, this.userInputManager); - } - } - - public async disableMicrophone() { - this.disableMicrophoneStyle(); - this.stopMicrophone(); - - if (this.constraintsMedia.video !== false) { - const stream = await this.getCamera(); - this.triggerUpdatedLocalStreamCallbacks(stream); - } else { - this.triggerUpdatedLocalStreamCallbacks(null); - } - } - - private applyPreviousConfig() { - this.constraintsMedia = this.previousConstraint; - if(!this.constraintsMedia.video){ - this.disableCameraStyle(); - }else{ - this.enableCameraStyle(); - } - if(!this.constraintsMedia.audio){ - this.disableMicrophoneStyle() - }else{ - this.enableMicrophoneStyle() - } - - this.getCamera().then((stream: MediaStream) => { - this.triggerUpdatedLocalStreamCallbacks(stream); - }); - } - private enableCameraStyle(){ this.cinemaClose.style.display = "none"; this.cinemaBtn.classList.remove("disabled"); @@ -355,8 +235,6 @@ export class MediaManager { this.cinemaClose.style.display = "block"; this.cinema.style.display = "none"; this.cinemaBtn.classList.add("disabled"); - this.constraintsMedia.video = false; - this.myCamVideo.srcObject = null; } private enableMicrophoneStyle(){ @@ -369,7 +247,6 @@ export class MediaManager { this.microphoneClose.style.display = "block"; this.microphone.style.display = "none"; this.microphoneBtn.classList.add("disabled"); - this.constraintsMedia.audio = false; } private enableScreenSharing() { @@ -403,12 +280,12 @@ export class MediaManager { return; } const localScreenCapture = this.localScreenCapture; - this.getCamera().then((stream) => { + //this.getCamera().then((stream) => { this.triggerStoppedScreenSharingCallbacks(localScreenCapture); - }).catch((err) => { //catch error get camera + /*}).catch((err) => { //catch error get camera console.error(err); this.triggerStoppedScreenSharingCallbacks(localScreenCapture); - }); + });*/ this.localScreenCapture = null; } @@ -454,55 +331,6 @@ export class MediaManager { } } - //get camera - async getCamera(): Promise { - if (navigator.mediaDevices === undefined) { - if (window.location.protocol === 'http:') { - throw new Error('Unable to access your camera or microphone. You need to use a HTTPS connection.'); - } else { - throw new Error('Unable to access your camera or microphone. Your browser is too old.'); - } - } - - return this.getLocalStream().catch((err) => { - console.info('Error get camera, trying with video option at null =>', err); - this.disableCameraStyle(); - this.stopCamera(); - - return this.getLocalStream().then((stream : MediaStream) => { - this.hasCamera = false; - return stream; - }).catch((err) => { - this.disableMicrophoneStyle(); - 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; - - //FIX ME SOUNDMETER: check stalability of sound meter calculation - /*this.mySoundMeter = null; - if(this.constraintsMedia.audio){ - this.mySoundMeter = new SoundMeter(); - this.mySoundMeter.connectToSource(stream, new AudioContext()); - }*/ - return stream; - }).catch((err: Error) => { - throw err; - }); - } - /** * Stops the camera from filming */ @@ -526,30 +354,6 @@ export class MediaManager { //this.mySoundMeter?.stop(); } - setCamera(id: string): Promise { - let video = this.constraintsMedia.video; - if (typeof(video) === 'boolean' || video === undefined) { - video = {} - } - video.deviceId = { - exact: id - }; - - return this.getCamera(); - } - - setMicrophone(id: string): Promise { - let audio = this.constraintsMedia.audio; - if (typeof(audio) === 'boolean' || audio === undefined) { - audio = {} - } - audio.deviceId = { - exact: id - }; - - return this.getCamera(); - } - addActiveVideo(user: UserSimplePeerInterface, userName: string = ""){ this.webrtcInAudio.play(); const userId = ''+user.userId diff --git a/front/src/WebRtc/SimplePeer.ts b/front/src/WebRtc/SimplePeer.ts index 67e72c6d..4633374d 100644 --- a/front/src/WebRtc/SimplePeer.ts +++ b/front/src/WebRtc/SimplePeer.ts @@ -14,6 +14,8 @@ import type {RoomConnection} from "../Connexion/RoomConnection"; import {connectionManager} from "../Connexion/ConnectionManager"; import {GameConnexionTypes} from "../Url/UrlManager"; import {blackListManager} from "./BlackListManager"; +import {get} from "svelte/store"; +import {localStreamStore, obtainedMediaConstraintStore} from "../Stores/MediaStore"; export interface UserSimplePeerInterface{ userId: number; @@ -82,11 +84,10 @@ export class SimplePeer { }); mediaManager.showGameOverlay(); - mediaManager.getCamera().finally(() => { - //receive message start - this.Connection.receiveWebrtcStart((message: UserSimplePeerInterface) => { - this.receiveWebrtcStart(message); - }); + + //receive message start + this.Connection.receiveWebrtcStart((message: UserSimplePeerInterface) => { + this.receiveWebrtcStart(message); }); this.Connection.disconnectMessage((data: WebRtcDisconnectMessageInterface): void => { @@ -344,8 +345,15 @@ export class SimplePeer { if (!PeerConnection) { throw new Error('While adding media, cannot find user with ID ' + userId); } - const localStream: MediaStream | null = mediaManager.localStream; - PeerConnection.write(new Buffer(JSON.stringify({type: MESSAGE_TYPE_CONSTRAINT, ...mediaManager.constraintsMedia}))); + + const result = get(localStreamStore); + + PeerConnection.write(new Buffer(JSON.stringify({type: MESSAGE_TYPE_CONSTRAINT, ...result.constraints}))); + + if (result.type === 'error') { + return; + } + const localStream: MediaStream | null = result.stream; if(!localStream){ return; diff --git a/front/src/WebRtc/VideoPeer.ts b/front/src/WebRtc/VideoPeer.ts index 503ca0de..32e8e97f 100644 --- a/front/src/WebRtc/VideoPeer.ts +++ b/front/src/WebRtc/VideoPeer.ts @@ -5,6 +5,8 @@ import type {RoomConnection} from "../Connexion/RoomConnection"; import {blackListManager} from "./BlackListManager"; import type {Subscription} from "rxjs"; import type {UserSimplePeerInterface} from "./SimplePeer"; +import {get} from "svelte/store"; +import {obtainedMediaConstraintStore} from "../Stores/MediaStore"; const Peer: SimplePeerNamespace.SimplePeer = require('simple-peer'); @@ -191,7 +193,7 @@ export class VideoPeer extends Peer { private pushVideoToRemoteUser() { try { const localStream: MediaStream | null = mediaManager.localStream; - this.write(new Buffer(JSON.stringify({type: MESSAGE_TYPE_CONSTRAINT, ...mediaManager.constraintsMedia}))); + this.write(new Buffer(JSON.stringify({type: MESSAGE_TYPE_CONSTRAINT, ...get(obtainedMediaConstraintStore)}))); if(!localStream){ return; From d32df13f1ba891c0159ebb105e420d862ac1052e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20N=C3=A9grier?= Date: Thu, 20 May 2021 18:05:03 +0200 Subject: [PATCH 15/20] Camera now show up when someone is moving and hides 5 seconds after we stop moving. Also, added an animation to show/hide the webcam. --- front/dist/index.tmpl.html | 3 +- front/src/Phaser/Player/Player.ts | 8 ++- front/src/Stores/GameStore.ts | 3 + front/src/Stores/MediaStore.ts | 113 ++++++++++++++++++++++++++++++ front/src/WebRtc/MediaManager.ts | 23 ++++-- front/style/style.css | 6 +- 6 files changed, 146 insertions(+), 10 deletions(-) create mode 100644 front/src/Stores/GameStore.ts diff --git a/front/dist/index.tmpl.html b/front/dist/index.tmpl.html index adbbfe44..7ef44116 100644 --- a/front/dist/index.tmpl.html +++ b/front/dist/index.tmpl.html @@ -73,7 +73,6 @@ -
-