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