From a1d52b42655a954ab3f159a1b09f312a439f62af Mon Sep 17 00:00:00 2001 From: kharhamel Date: Wed, 31 Mar 2021 11:21:06 +0200 Subject: [PATCH 1/5] 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 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 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 2/5] 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 3/5] 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 4/5] 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 5/5] 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 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 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,