diff --git a/back/src/Model/GameRoom.ts b/back/src/Model/GameRoom.ts index f9f1b391..aefade43 100644 --- a/back/src/Model/GameRoom.ts +++ b/back/src/Model/GameRoom.ts @@ -21,6 +21,7 @@ import { SubToPusherRoomMessage, VariableMessage, VariableWithTagMessage, + ServerToClientMessage, } from "../Messages/generated/messages_pb"; import { ProtobufUtils } from "../Model/Websocket/ProtobufUtils"; import { RoomSocket, ZoneSocket } from "src/RoomManager"; @@ -110,10 +111,6 @@ export class GameRoom { return gameRoom; } - public getGroups(): Group[] { - return Array.from(this.groups.values()); - } - public getUsers(): Map { return this.users; } @@ -176,6 +173,14 @@ export class GameRoom { if (userObj !== undefined && typeof userObj.group !== "undefined") { this.leaveGroup(userObj); } + + if (user.hasFollowers()) { + user.stopLeading(); + } + if (user.following) { + user.following.delFollower(user); + } + this.users.delete(user.id); this.usersByUuid.delete(user.uuid); @@ -214,8 +219,8 @@ export class GameRoom { if (user.silent) { return; } - - if (user.group === undefined) { + const group = user.group; + if (group === undefined) { // If the user is not part of a group: // should he join a group? @@ -246,13 +251,40 @@ export class GameRoom { } else { // If the user is part of a group: // should he leave the group? - const distance = GameRoom.computeDistanceBetweenPositions(user.getPosition(), user.group.getPosition()); - if (distance > this.groupRadius) { - this.leaveGroup(user); + let noOneOutOfBounds = true; + group.getUsers().forEach((foreignUser: User) => { + if (foreignUser.group === undefined) { + return; + } + const usrPos = foreignUser.getPosition(); + const grpPos = foreignUser.group.getPosition(); + const distance = GameRoom.computeDistanceBetweenPositions(usrPos, grpPos); + + if (distance > this.groupRadius) { + if (foreignUser.hasFollowers() || foreignUser.following) { + // If one user is out of the group bounds BUT following, the group still exists... but should be hidden. + // We put it in 'outOfBounds' mode + group.setOutOfBounds(true); + noOneOutOfBounds = false; + } else { + this.leaveGroup(foreignUser); + } + } + }); + if (noOneOutOfBounds && !user.group?.isEmpty()) { + group.setOutOfBounds(false); } } } + public sendToOthersInGroupIncludingUser(user: User, message: ServerToClientMessage): void { + user.group?.getUsers().forEach((currentUser: User) => { + if (currentUser.id !== user.id) { + currentUser.socket.write(message); + } + }); + } + setSilent(user: User, silent: boolean) { if (user.silent === silent) { return; @@ -280,7 +312,6 @@ export class GameRoom { } group.leave(user); if (group.isEmpty()) { - this.positionNotifier.leave(group); group.destroy(); if (!this.groups.has(group)) { throw new Error(`Could not find group ${group.getId()} referenced by user ${user.id} in World.`); diff --git a/back/src/Model/Group.ts b/back/src/Model/Group.ts index 519a2526..0782bd1b 100644 --- a/back/src/Model/Group.ts +++ b/back/src/Model/Group.ts @@ -16,6 +16,10 @@ export class Group implements Movable { private wasDestroyed: boolean = false; private roomId: string; private currentZone: Zone | null = null; + /** + * When outOfBounds = true, a user if out of the bounds of the group BUT still considered inside it (because we are in following mode) + */ + private outOfBounds = false; constructor( roomId: string, @@ -78,6 +82,10 @@ export class Group implements Movable { this.x = x; this.y = y; + if (this.outOfBounds) { + return; + } + if (oldX === undefined) { this.currentZone = this.positionNotifier.enter(this); } else { @@ -133,6 +141,10 @@ export class Group implements Movable { * Usually used when there is only one user left. */ destroy(): void { + if (!this.outOfBounds) { + this.positionNotifier.leave(this); + } + for (const user of this.users) { this.leave(user); } @@ -142,4 +154,26 @@ export class Group implements Movable { get getSize() { return this.users.size; } + + /** + * A group can have at most one person leading the way in it. + */ + get leader(): User | undefined { + for (const user of this.users) { + if (user.hasFollowers()) { + return user; + } + } + return undefined; + } + + setOutOfBounds(outOfBounds: boolean): void { + if (this.outOfBounds === true && outOfBounds === false) { + this.positionNotifier.enter(this); + this.outOfBounds = false; + } else if (this.outOfBounds === false && outOfBounds === true) { + this.positionNotifier.leave(this); + this.outOfBounds = true; + } + } } diff --git a/back/src/Model/User.ts b/back/src/Model/User.ts index a02ffde9..c6abf52a 100644 --- a/back/src/Model/User.ts +++ b/back/src/Model/User.ts @@ -7,6 +7,8 @@ import { ServerDuplexStream } from "grpc"; import { BatchMessage, CompanionMessage, + FollowAbortMessage, + FollowConfirmationMessage, PusherToBackMessage, ServerToClientMessage, SetPlayerDetailsMessage, @@ -19,6 +21,8 @@ export type UserSocket = ServerDuplexStream; public group?: Group; + private _following: User | undefined; + private followedBy: Set = new Set(); public constructor( public id: number, @@ -50,6 +54,45 @@ export class User implements Movable { this.positionNotifier.updatePosition(this, position, oldPosition); } + public addFollower(follower: User): void { + this.followedBy.add(follower); + follower._following = this; + + const message = new FollowConfirmationMessage(); + message.setFollower(follower.id); + message.setLeader(this.id); + const clientMessage = new ServerToClientMessage(); + clientMessage.setFollowconfirmationmessage(message); + this.socket.write(clientMessage); + } + + public delFollower(follower: User): void { + this.followedBy.delete(follower); + follower._following = undefined; + + const message = new FollowAbortMessage(); + message.setFollower(follower.id); + message.setLeader(this.id); + const clientMessage = new ServerToClientMessage(); + clientMessage.setFollowabortmessage(message); + this.socket.write(clientMessage); + follower.socket.write(clientMessage); + } + + public hasFollowers(): boolean { + return this.followedBy.size !== 0; + } + + get following(): User | undefined { + return this._following; + } + + public stopLeading(): void { + for (const follower of this.followedBy) { + this.delFollower(follower); + } + } + private batchedMessages: BatchMessage = new BatchMessage(); private batchTimeout: NodeJS.Timeout | null = null; diff --git a/back/src/RoomManager.ts b/back/src/RoomManager.ts index d72e3068..3bb425b7 100644 --- a/back/src/RoomManager.ts +++ b/back/src/RoomManager.ts @@ -9,6 +9,9 @@ import { BatchToPusherMessage, BatchToPusherRoomMessage, EmotePromptMessage, + FollowRequestMessage, + FollowConfirmationMessage, + FollowAbortMessage, EmptyMessage, ItemEventMessage, JoinRoomMessage, @@ -119,6 +122,24 @@ const roomManager: IRoomManagerServer = { user, message.getEmotepromptmessage() as EmotePromptMessage ); + } else if (message.hasFollowrequestmessage()) { + socketManager.handleFollowRequestMessage( + room, + user, + message.getFollowrequestmessage() as FollowRequestMessage + ); + } else if (message.hasFollowconfirmationmessage()) { + socketManager.handleFollowConfirmationMessage( + room, + user, + message.getFollowconfirmationmessage() as FollowConfirmationMessage + ); + } else if (message.hasFollowabortmessage()) { + socketManager.handleFollowAbortMessage( + room, + user, + message.getFollowabortmessage() as FollowAbortMessage + ); } else if (message.hasSendusermessage()) { const sendUserMessage = message.getSendusermessage(); socketManager.handleSendUserMessage(user, sendUserMessage as SendUserMessage); diff --git a/back/src/Services/SocketManager.ts b/back/src/Services/SocketManager.ts index 9fde5898..c9da7c96 100644 --- a/back/src/Services/SocketManager.ts +++ b/back/src/Services/SocketManager.ts @@ -30,6 +30,9 @@ import { BanUserMessage, RefreshRoomMessage, EmotePromptMessage, + FollowRequestMessage, + FollowConfirmationMessage, + FollowAbortMessage, VariableMessage, BatchToPusherRoomMessage, SubToPusherRoomMessage, @@ -842,6 +845,39 @@ export class SocketManager { emoteEventMessage.setActoruserid(user.id); room.emitEmoteEvent(user, emoteEventMessage); } + + handleFollowRequestMessage(room: GameRoom, user: User, message: FollowRequestMessage) { + const clientMessage = new ServerToClientMessage(); + clientMessage.setFollowrequestmessage(message); + room.sendToOthersInGroupIncludingUser(user, clientMessage); + } + + handleFollowConfirmationMessage(room: GameRoom, user: User, message: FollowConfirmationMessage) { + const leader = room.getUserById(message.getLeader()); + if (!leader) { + const message = `Could not follow user "{message.getLeader()}" in room "{room.roomUrl}".`; + console.info(message, "Maybe the user just left."); + return; + } + + // By security, we look at the group leader. If the group leader is NOT the leader in the message, + // everybody should stop following the group leader (to avoid having 2 group leaders) + if (user?.group?.leader && user?.group?.leader !== leader) { + user?.group?.leader?.stopLeading(); + } + + leader.addFollower(user); + } + + handleFollowAbortMessage(room: GameRoom, user: User, message: FollowAbortMessage) { + if (user.id === message.getLeader()) { + user?.group?.leader?.stopLeading(); + } else { + // Forward message + const leader = room.getUserById(message.getLeader()); + leader?.delFollower(user); + } + } } export const socketManager = new SocketManager(); diff --git a/front/src/Components/App.svelte b/front/src/Components/App.svelte index 817b22c1..e9db2b77 100644 --- a/front/src/Components/App.svelte +++ b/front/src/Components/App.svelte @@ -42,6 +42,9 @@ import AudioManager from "./AudioManager/AudioManager.svelte"; import { showReportScreenStore, userReportEmpty } from "../Stores/ShowReportScreenStore"; import ReportMenu from "./ReportMenu/ReportMenu.svelte"; + import { followStateStore } from "../Stores/FollowStore"; + import { peerStore } from "../Stores/PeerStore"; + import FollowMenu from "./FollowMenu/FollowMenu.svelte"; export let game: Game; @@ -101,6 +104,11 @@ {/if} + {#if $followStateStore !== "off" || $peerStore.size > 0} +
+ +
+ {/if} {#if $menuIconVisiblilityStore}
diff --git a/front/src/Components/FollowMenu/FollowMenu.svelte b/front/src/Components/FollowMenu/FollowMenu.svelte new file mode 100644 index 00000000..264b27ed --- /dev/null +++ b/front/src/Components/FollowMenu/FollowMenu.svelte @@ -0,0 +1,208 @@ + + + + + +{#if $followStateStore === "requesting"} +
+ {#if $followRoleStore === "follower"} +
+

Do you want to follow {name($followUsersStore[0])}?

+
+
+ + +
+ {:else if $followRoleStore === "leader"} +
+

Should never be displayed

+
+ {/if} +
+{/if} + +{#if $followStateStore === "ending"} +
+
+

Interaction

+
+ {#if $followRoleStore === "follower"} +
+

Do you want to stop following {name($followUsersStore[0])}?

+
+ {:else if $followRoleStore === "leader"} +
+

Do you want to stop leading the way?

+
+ {/if} +
+ + +
+
+{/if} + +{#if $followStateStore === "active" || $followStateStore === "ending"} +
+
+ {#if $followRoleStore === "follower"} +

Following {name($followUsersStore[0])}

+ {:else if $followUsersStore.length === 0} +

Waiting for followers' confirmation

+ {:else if $followUsersStore.length === 1} +

{name($followUsersStore[0])} is following you

+ {:else if $followUsersStore.length === 2} +

{name($followUsersStore[0])} and {name($followUsersStore[1])} are following you

+ {:else} +

+ {$followUsersStore.slice(0, -1).map(name).join(", ")} and {name( + $followUsersStore[$followUsersStore.length - 1] + )} are following you +

+ {/if} +
+
+{/if} + +{#if $followStateStore === "off"} + +{/if} + +{#if $followStateStore === "active" || $followStateStore === "ending"} + {#if $followRoleStore === "follower"} + + {:else} + + {/if} +{/if} + + diff --git a/front/src/Components/Menu/SettingsSubMenu.svelte b/front/src/Components/Menu/SettingsSubMenu.svelte index 93d3eaa9..1db14036 100644 --- a/front/src/Components/Menu/SettingsSubMenu.svelte +++ b/front/src/Components/Menu/SettingsSubMenu.svelte @@ -8,6 +8,7 @@ let fullscreen: boolean = localUserStore.getFullscreen(); let notification: boolean = localUserStore.getNotification() === "granted"; let forceCowebsiteTrigger: boolean = localUserStore.getForceCowebsiteTrigger(); + let ignoreFollowRequests: boolean = localUserStore.getIgnoreFollowRequests(); let valueGame: number = localUserStore.getGameQualityValue(); let valueVideo: number = localUserStore.getVideoQualityValue(); let previewValueGame = valueGame; @@ -59,6 +60,10 @@ localUserStore.setForceCowebsiteTrigger(forceCowebsiteTrigger); } + function changeIgnoreFollowRequests() { + localUserStore.setIgnoreFollowRequests(ignoreFollowRequests); + } + function closeMenu() { menuVisiblilityStore.set(false); } @@ -123,6 +128,15 @@ /> Always ask before opening websites and Jitsi Meet rooms +
diff --git a/front/src/Components/images/follow.svg b/front/src/Components/images/follow.svg new file mode 100644 index 00000000..d965d35f --- /dev/null +++ b/front/src/Components/images/follow.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/front/src/Connexion/ConnectionManager.ts b/front/src/Connexion/ConnectionManager.ts index 6f1e1f50..026cc20a 100644 --- a/front/src/Connexion/ConnectionManager.ts +++ b/front/src/Connexion/ConnectionManager.ts @@ -1,5 +1,5 @@ import Axios from "axios"; -import { PUSHER_URL, START_ROOM_URL } from "../Enum/EnvironmentVariable"; +import { PUSHER_URL } from "../Enum/EnvironmentVariable"; import { RoomConnection } from "./RoomConnection"; import type { OnConnectInterface, PositionInterface, ViewportInterface } from "./ConnexionModels"; import { GameConnexionTypes, urlManager } from "../Url/UrlManager"; diff --git a/front/src/Connexion/LocalUserStore.ts b/front/src/Connexion/LocalUserStore.ts index 30755034..4dce6924 100644 --- a/front/src/Connexion/LocalUserStore.ts +++ b/front/src/Connexion/LocalUserStore.ts @@ -14,6 +14,7 @@ const audioPlayerMuteKey = "audioMute"; const helpCameraSettingsShown = "helpCameraSettingsShown"; const fullscreenKey = "fullscreen"; const forceCowebsiteTriggerKey = "forceCowebsiteTrigger"; +const ignoreFollowRequests = "ignoreFollowRequests"; const lastRoomUrl = "lastRoomUrl"; const authToken = "authToken"; const state = "state"; @@ -128,6 +129,13 @@ class LocalUserStore { return localStorage.getItem(forceCowebsiteTriggerKey) === "true"; } + setIgnoreFollowRequests(value: boolean): void { + localStorage.setItem(ignoreFollowRequests, value.toString()); + } + getIgnoreFollowRequests(): boolean { + return localStorage.getItem(ignoreFollowRequests) === "true"; + } + setLastRoomUrl(roomUrl: string): void { localStorage.setItem(lastRoomUrl, roomUrl.toString()); if ("caches" in window) { diff --git a/front/src/Connexion/RoomConnection.ts b/front/src/Connexion/RoomConnection.ts index 9c861293..328f1aec 100644 --- a/front/src/Connexion/RoomConnection.ts +++ b/front/src/Connexion/RoomConnection.ts @@ -30,6 +30,9 @@ import { PingMessage, EmoteEventMessage, EmotePromptMessage, + FollowRequestMessage, + FollowConfirmationMessage, + FollowAbortMessage, SendUserMessage, BanUserMessage, VariableMessage, @@ -59,7 +62,10 @@ import { adminMessagesService } from "./AdminMessagesService"; import { worldFullMessageStream } from "./WorldFullMessageStream"; import { connectionManager } from "./ConnectionManager"; import { emoteEventStream } from "./EmoteEventStream"; +import { get } from "svelte/store"; import { warningContainerStore } from "../Stores/MenuStore"; +import { followStateStore, followRoleStore, followUsersStore } from "../Stores/FollowStore"; +import { localUserStore } from "./LocalUserStore"; const manualPingDelay = 20000; @@ -262,6 +268,21 @@ export class RoomConnection implements RoomConnection { warningContainerStore.activateWarningContainer(); } else if (message.hasRefreshroommessage()) { //todo: implement a way to notify the user the room was refreshed. + } else if (message.hasFollowrequestmessage()) { + const requestMessage = message.getFollowrequestmessage() as FollowRequestMessage; + if (!localUserStore.getIgnoreFollowRequests()) { + followUsersStore.addFollowRequest(requestMessage.getLeader()); + } + } else if (message.hasFollowconfirmationmessage()) { + const responseMessage = message.getFollowconfirmationmessage() as FollowConfirmationMessage; + followUsersStore.addFollower(responseMessage.getFollower()); + } else if (message.hasFollowabortmessage()) { + const abortMessage = message.getFollowabortmessage() as FollowAbortMessage; + if (get(followRoleStore) === "follower") { + followUsersStore.stopFollowing(); + } else { + followUsersStore.removeFollower(abortMessage.getFollower()); + } } else if (message.hasErrormessage()) { const errorMessage = message.getErrormessage() as ErrorMessage; console.error("An error occurred server side: " + errorMessage.getMessage()); @@ -746,6 +767,43 @@ export class RoomConnection implements RoomConnection { this.socket.send(clientToServerMessage.serializeBinary().buffer); } + public emitFollowRequest(): void { + if (!this.userId) { + return; + } + const message = new FollowRequestMessage(); + message.setLeader(this.userId); + const clientToServerMessage = new ClientToServerMessage(); + clientToServerMessage.setFollowrequestmessage(message); + this.socket.send(clientToServerMessage.serializeBinary().buffer); + } + + public emitFollowConfirmation(): void { + if (!this.userId) { + return; + } + const message = new FollowConfirmationMessage(); + message.setLeader(get(followUsersStore)[0]); + message.setFollower(this.userId); + const clientToServerMessage = new ClientToServerMessage(); + clientToServerMessage.setFollowconfirmationmessage(message); + this.socket.send(clientToServerMessage.serializeBinary().buffer); + } + + public emitFollowAbort(): void { + const isLeader = get(followRoleStore) === "leader"; + const hasFollowers = get(followUsersStore).length > 0; + if (!this.userId || (isLeader && !hasFollowers)) { + return; + } + const message = new FollowAbortMessage(); + message.setLeader(isLeader ? this.userId : get(followUsersStore)[0]); + message.setFollower(isLeader ? 0 : this.userId); + const clientToServerMessage = new ClientToServerMessage(); + clientToServerMessage.setFollowabortmessage(message); + this.socket.send(clientToServerMessage.serializeBinary().buffer); + } + public getAllTags(): string[] { return this.tags; } diff --git a/front/src/Phaser/Entity/Character.ts b/front/src/Phaser/Entity/Character.ts index 98154e37..a7e9c99b 100644 --- a/front/src/Phaser/Entity/Character.ts +++ b/front/src/Phaser/Entity/Character.ts @@ -33,7 +33,7 @@ export abstract class Character extends Container { private readonly playerName: Text; public PlayerValue: string; public sprites: Map; - private lastDirection: PlayerAnimationDirections = PlayerAnimationDirections.Down; + protected lastDirection: PlayerAnimationDirections = PlayerAnimationDirections.Down; //private teleportation: Sprite; private invisible: boolean; public companion?: Companion; @@ -277,24 +277,20 @@ export abstract class Character extends Container { body.setVelocity(x, y); - // up or down animations are prioritized over left and right - if (body.velocity.y < 0) { - //moving up - this.lastDirection = PlayerAnimationDirections.Up; - this.playAnimation(PlayerAnimationDirections.Up, true); - } else if (body.velocity.y > 0) { - //moving down - this.lastDirection = PlayerAnimationDirections.Down; - this.playAnimation(PlayerAnimationDirections.Down, true); - } else if (body.velocity.x > 0) { - //moving right - this.lastDirection = PlayerAnimationDirections.Right; - this.playAnimation(PlayerAnimationDirections.Right, true); - } else if (body.velocity.x < 0) { - //moving left - this.lastDirection = PlayerAnimationDirections.Left; - this.playAnimation(PlayerAnimationDirections.Left, true); + if (Math.abs(body.velocity.x) > Math.abs(body.velocity.y)) { + if (body.velocity.x < 0) { + this.lastDirection = PlayerAnimationDirections.Left; + } else if (body.velocity.x > 0) { + this.lastDirection = PlayerAnimationDirections.Right; + } + } else { + if (body.velocity.y < 0) { + this.lastDirection = PlayerAnimationDirections.Up; + } else if (body.velocity.y > 0) { + this.lastDirection = PlayerAnimationDirections.Down; + } } + this.playAnimation(this.lastDirection, true); this.setDepth(this.y); diff --git a/front/src/Phaser/Game/GameScene.ts b/front/src/Phaser/Game/GameScene.ts index b31ed83b..4800e259 100644 --- a/front/src/Phaser/Game/GameScene.ts +++ b/front/src/Phaser/Game/GameScene.ts @@ -1,7 +1,7 @@ import type { Subscription } from "rxjs"; import AnimatedTiles from "phaser-animated-tiles"; import { Queue } from "queue-typescript"; -import { get } from "svelte/store"; +import { get, Unsubscriber } from "svelte/store"; import { userMessageManager } from "../../Administration/UserMessageManager"; import { connectionManager } from "../../Connexion/ConnectionManager"; @@ -91,6 +91,8 @@ import { deepCopy } from "deep-copy-ts"; import FILE_LOAD_ERROR = Phaser.Loader.Events.FILE_LOAD_ERROR; import { MapStore } from "../../Stores/Utils/MapStore"; import { SetPlayerDetailsMessage } from "../../Messages/generated/messages_pb"; +import { followUsersColorStore, followUsersStore } from "../../Stores/FollowStore"; +import { getColorRgbFromHue } from "../../WebRtc/ColorGenerator"; export interface GameSceneInitInterface { initPosition: PointInterface | null; @@ -165,9 +167,11 @@ export class GameScene extends DirtyScene { private createPromise: Promise; private createPromiseResolve!: (value?: void | PromiseLike) => void; private iframeSubscriptionList!: Array; - private peerStoreUnsubscribe!: () => void; - private emoteUnsubscribe!: () => void; - private emoteMenuUnsubscribe!: () => void; + private peerStoreUnsubscribe!: Unsubscriber; + private emoteUnsubscribe!: Unsubscriber; + private emoteMenuUnsubscribe!: Unsubscriber; + private followUsersColorStoreUnsubscribe!: Unsubscriber; + private biggestAvailableAreaStoreUnsubscribe!: () => void; MapUrlFile: string; roomUrl: string; @@ -646,6 +650,16 @@ export class GameScene extends DirtyScene { } }); + this.followUsersColorStoreUnsubscribe = followUsersColorStore.subscribe((color) => { + if (color !== undefined) { + this.CurrentPlayer.setOutlineColor(color); + this.connection?.emitPlayerOutlineColor(color); + } else { + this.CurrentPlayer.removeOutlineColor(); + this.connection?.emitPlayerOutlineColor(null); + } + }); + Promise.all([this.connectionAnswerPromise as Promise, ...scriptPromises]).then(() => { this.scene.wake(); }); @@ -1443,6 +1457,7 @@ ${escapedMessage} this.peerStoreUnsubscribe(); this.emoteUnsubscribe(); this.emoteMenuUnsubscribe(); + this.followUsersColorStoreUnsubscribe(); this.biggestAvailableAreaStoreUnsubscribe(); iframeListener.unregisterAnswerer("getState"); iframeListener.unregisterAnswerer("loadTileset"); diff --git a/front/src/Phaser/Game/PlayerMovement.ts b/front/src/Phaser/Game/PlayerMovement.ts index 7758f010..274cbee1 100644 --- a/front/src/Phaser/Game/PlayerMovement.ts +++ b/front/src/Phaser/Game/PlayerMovement.ts @@ -41,7 +41,7 @@ export class PlayerMovement { oldX: this.startPosition.x, oldY: this.startPosition.y, direction: this.endPosition.direction, - moving: true, + moving: this.endPosition.moving, }; } } diff --git a/front/src/Phaser/Player/Player.ts b/front/src/Phaser/Player/Player.ts index a1924457..946bb6c4 100644 --- a/front/src/Phaser/Player/Player.ts +++ b/front/src/Phaser/Player/Player.ts @@ -1,16 +1,17 @@ import { PlayerAnimationDirections } from "./Animation"; import type { GameScene } from "../Game/GameScene"; -import { UserInputEvent, UserInputManager } from "../UserInput/UserInputManager"; +import { ActiveEventList, UserInputEvent, UserInputManager } from "../UserInput/UserInputManager"; import { Character } from "../Entity/Character"; +import type { RemotePlayer } from "../Entity/RemotePlayer"; + +import { get } from "svelte/store"; import { userMovingStore } from "../../Stores/GameStore"; +import { followStateStore, followRoleStore, followUsersStore } from "../../Stores/FollowStore"; export const hasMovedEventName = "hasMoved"; export const requestEmoteEventName = "requestEmote"; export class Player extends Character { - private previousDirection: string = PlayerAnimationDirections.Down; - private wasMoving: boolean = false; - constructor( Scene: GameScene, x: number, @@ -29,71 +30,99 @@ export class Player extends Character { this.getBody().setImmovable(false); } - moveUser(delta: number): void { - //if user client on shift, camera and player speed - let direction = null; - let moving = false; - - const activeEvents = this.userInputManager.getEventListForGameTick(); - const speedMultiplier = activeEvents.get(UserInputEvent.SpeedUp) ? 25 : 9; - const moveAmount = speedMultiplier * 20; - - let x = 0; - let y = 0; + private inputStep(activeEvents: ActiveEventList, x: number, y: number) { + // Process input events if (activeEvents.get(UserInputEvent.MoveUp)) { - y = -moveAmount; - direction = PlayerAnimationDirections.Up; - moving = true; + y = y - 1; } else if (activeEvents.get(UserInputEvent.MoveDown)) { - y = moveAmount; - direction = PlayerAnimationDirections.Down; - moving = true; + y = y + 1; } + if (activeEvents.get(UserInputEvent.MoveLeft)) { - x = -moveAmount; - direction = PlayerAnimationDirections.Left; - moving = true; + x = x - 1; } else if (activeEvents.get(UserInputEvent.MoveRight)) { - x = moveAmount; - direction = PlayerAnimationDirections.Right; - moving = true; + x = x + 1; } - moving = moving || activeEvents.get(UserInputEvent.JoystickMove); - if (x !== 0 || y !== 0) { + // Compute movement deltas + const followMode = get(followStateStore) !== "off"; + const speedup = activeEvents.get(UserInputEvent.SpeedUp) && !followMode ? 25 : 9; + const moveAmount = speedup * 20; + x = x * moveAmount; + y = y * moveAmount; + + // Compute moving state + const joystickMovement = activeEvents.get(UserInputEvent.JoystickMove); + const moving = x !== 0 || y !== 0 || joystickMovement; + + // Compute direction + let direction = this.lastDirection; + if (moving && !joystickMovement) { + if (Math.abs(x) > Math.abs(y)) { + direction = x < 0 ? PlayerAnimationDirections.Left : PlayerAnimationDirections.Right; + } else { + direction = y < 0 ? PlayerAnimationDirections.Up : PlayerAnimationDirections.Down; + } + } + + // Send movement events + const emit = () => this.emit(hasMovedEventName, { moving, direction, x: this.x, y: this.y }); + if (moving) { this.move(x, y); - this.emit(hasMovedEventName, { moving, direction, x: this.x, y: this.y, oldX: x, oldY: y }); - } else if (this.wasMoving && moving) { - // slow joystick movement - this.move(0, 0); - this.emit(hasMovedEventName, { - moving, - direction: this.previousDirection, - x: this.x, - y: this.y, - oldX: x, - oldY: y, - }); - } else if (this.wasMoving && !moving) { + emit(); + } else if (get(userMovingStore)) { this.stop(); - this.emit(hasMovedEventName, { - moving, - direction: this.previousDirection, - x: this.x, - y: this.y, - oldX: x, - oldY: y, - }); + emit(); } - if (direction !== null) { - this.previousDirection = direction; - } - this.wasMoving = moving; + // Update state userMovingStore.set(moving); } - public isMoving(): boolean { - return this.wasMoving; + private computeFollowMovement(): number[] { + // Find followed WOKA and abort following if we lost it + const player = this.scene.MapPlayersByKey.get(get(followUsersStore)[0]); + if (!player) { + this.scene.connection?.emitFollowAbort(); + followStateStore.set("off"); + return [0, 0]; + } + + // Compute movement direction + const xDistance = player.x - this.x; + const yDistance = player.y - this.y; + const distance = Math.pow(xDistance, 2) + Math.pow(yDistance, 2); + if (distance < 2000) { + return [0, 0]; + } + const xMovement = xDistance / Math.sqrt(distance); + const yMovement = yDistance / Math.sqrt(distance); + return [xMovement, yMovement]; + } + + public enableFollowing() { + followStateStore.set("active"); + } + + public moveUser(delta: number): void { + const activeEvents = this.userInputManager.getEventListForGameTick(); + const state = get(followStateStore); + const role = get(followRoleStore); + + if (activeEvents.get(UserInputEvent.Follow)) { + if (state === "off" && this.scene.groups.size > 0) { + followStateStore.set("requesting"); + followRoleStore.set("leader"); + } else if (state === "active") { + followStateStore.set("ending"); + } + } + + let x = 0; + let y = 0; + if ((state === "active" || state === "ending") && role === "follower") { + [x, y] = this.computeFollowMovement(); + } + this.inputStep(activeEvents, x, y); } } diff --git a/front/src/Phaser/UserInput/UserInputManager.ts b/front/src/Phaser/UserInput/UserInputManager.ts index 28b87e69..e00e1e86 100644 --- a/front/src/Phaser/UserInput/UserInputManager.ts +++ b/front/src/Phaser/UserInput/UserInputManager.ts @@ -16,6 +16,7 @@ export enum UserInputEvent { MoveDown, SpeedUp, Interact, + Follow, Shout, JoystickMove, } @@ -147,6 +148,10 @@ export class UserInputManager { event: UserInputEvent.Interact, keyInstance: this.Scene.input.keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.SPACE, false), }, + { + event: UserInputEvent.Follow, + keyInstance: this.Scene.input.keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.F, false), + }, { event: UserInputEvent.Shout, keyInstance: this.Scene.input.keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.F, false), diff --git a/front/src/Stores/FollowStore.ts b/front/src/Stores/FollowStore.ts new file mode 100644 index 00000000..ab1e61d1 --- /dev/null +++ b/front/src/Stores/FollowStore.ts @@ -0,0 +1,90 @@ +import { derived, writable } from "svelte/store"; +import { getColorRgbFromHue } from "../WebRtc/ColorGenerator"; +import { gameManager } from "../Phaser/Game/GameManager"; + +type FollowState = "off" | "requesting" | "active" | "ending"; +type FollowRole = "leader" | "follower"; + +export const followStateStore = writable("off"); +export const followRoleStore = writable("leader"); + +function createFollowUsersStore() { + const { subscribe, update, set } = writable([]); + + return { + subscribe, + addFollowRequest(leader: number): void { + followStateStore.set("requesting"); + followRoleStore.set("follower"); + set([leader]); + }, + addFollower(user: number): void { + update((followers) => { + followers.push(user); + return followers; + }); + }, + /** + * Removes the follower from the store. + * Will update followStateStore and followRoleStore if nobody is following anymore. + * @param user + */ + removeFollower(user: number): void { + update((followers) => { + const oldFollowerCount = followers.length; + followers = followers.filter((id) => id !== user); + + if (followers.length === 0 && oldFollowerCount > 0) { + followStateStore.set("off"); + followRoleStore.set("leader"); + } + + return followers; + }); + }, + stopFollowing(): void { + set([]); + followStateStore.set("off"); + followRoleStore.set("leader"); + }, + }; +} + +export const followUsersStore = createFollowUsersStore(); + +/** + * This store contains the color of the follow group. It is derived from the ID of the leader. + */ +export const followUsersColorStore = derived( + [followStateStore, followRoleStore, followUsersStore], + ([$followStateStore, $followRoleStore, $followUsersStore]) => { + console.log($followStateStore); + if ($followStateStore !== "active") { + return undefined; + } + + if ($followUsersStore.length === 0) { + return undefined; + } + + let leaderId: number; + if ($followRoleStore === "leader") { + // Let's get my ID by a quite complicated way.... + leaderId = gameManager.getCurrentGameScene().connection?.getUserId() ?? 0; + } else { + leaderId = $followUsersStore[0]; + } + + // Let's compute a random hue between 0 and 1 that varies enough to be interesting + const hue = ((leaderId * 197) % 255) / 255; + + let { r, g, b } = getColorRgbFromHue(hue); + if ($followRoleStore === "follower") { + // Let's make the followers very slightly darker + r *= 0.9; + g *= 0.9; + b *= 0.9; + } + return (Math.round(r * 255) << 16) | (Math.round(g * 255) << 8) | Math.round(b * 255); + } +); diff --git a/front/src/WebRtc/ColorGenerator.ts b/front/src/WebRtc/ColorGenerator.ts index be192f9f..f78671e6 100644 --- a/front/src/WebRtc/ColorGenerator.ts +++ b/front/src/WebRtc/ColorGenerator.ts @@ -1,13 +1,29 @@ export function getRandomColor(): string { + const { r, g, b } = getColorRgbFromHue(Math.random()); + return toHexa(r, g, b); +} + +function toHexa(r: number, g: number, b: number): string { + return "#" + Math.floor(r * 256).toString(16) + Math.floor(g * 256).toString(16) + Math.floor(b * 256).toString(16); +} + +export function getColorRgbFromHue(hue: number): { r: number; g: number; b: number } { const golden_ratio_conjugate = 0.618033988749895; - let hue = Math.random(); hue += golden_ratio_conjugate; hue %= 1; return hsv_to_rgb(hue, 0.5, 0.95); } +function stringToDouble(string: string): number { + let num = 1; + for (const char of string.split("")) { + num *= char.charCodeAt(0); + } + return (num % 255) / 255; +} + //todo: test this. -function hsv_to_rgb(hue: number, saturation: number, brightness: number): string { +function hsv_to_rgb(hue: number, saturation: number, brightness: number): { r: number; g: number; b: number } { const h_i = Math.floor(hue * 6); const f = hue * 6 - h_i; const p = brightness * (1 - saturation); @@ -48,5 +64,9 @@ function hsv_to_rgb(hue: number, saturation: number, brightness: number): string default: throw "h_i cannot be " + h_i; } - return "#" + Math.floor(r * 256).toString(16) + Math.floor(g * 256).toString(16) + Math.floor(b * 256).toString(16); + return { + r, + g, + b, + }; } diff --git a/front/tests/Phaser/Game/PlayerMovementTest.ts b/front/tests/Phaser/Game/PlayerMovementTest.ts index 70f7b95d..bd5f40b4 100644 --- a/front/tests/Phaser/Game/PlayerMovementTest.ts +++ b/front/tests/Phaser/Game/PlayerMovementTest.ts @@ -74,7 +74,7 @@ describe("Interpolation / Extrapolation", () => { }); }); - it("should should keep moving until it stops", () => { + it("should keep moving until it stops", () => { const playerMovement = new PlayerMovement({ x: 100, y: 200 }, 42000, @@ -95,7 +95,7 @@ describe("Interpolation / Extrapolation", () => { oldX: 100, oldY: 200, direction: 'up', - moving: true + moving: false }); }); }) diff --git a/messages/protos/messages.proto b/messages/protos/messages.proto index 76a0373c..3c05037a 100644 --- a/messages/protos/messages.proto +++ b/messages/protos/messages.proto @@ -71,12 +71,12 @@ message ReportPlayerMessage { } message EmotePromptMessage { - string emote = 2; + string emote = 2; } message EmoteEventMessage { - int32 actorUserId = 1; - string emote = 2; + int32 actorUserId = 1; + string emote = 2; } message QueryJitsiJwtMessage { @@ -84,6 +84,20 @@ message QueryJitsiJwtMessage { string tag = 2; // FIXME: rather than reading the tag from the query, we should read it from the current map! } +message FollowRequestMessage { + int32 leader = 1; +} + +message FollowConfirmationMessage { + int32 leader = 1; + int32 follower = 2; +} + +message FollowAbortMessage { + int32 leader = 1; + int32 follower = 2; +} + message ClientToServerMessage { oneof message { UserMovesMessage userMovesMessage = 2; @@ -99,6 +113,9 @@ message ClientToServerMessage { QueryJitsiJwtMessage queryJitsiJwtMessage = 12; EmotePromptMessage emotePromptMessage = 13; VariableMessage variableMessage = 14; + FollowRequestMessage followRequestMessage = 15; + FollowConfirmationMessage followConfirmationMessage = 16; + FollowAbortMessage followAbortMessage = 17; } } @@ -243,14 +260,14 @@ message SendUserMessage{ message WorldFullWarningMessage{ } message WorldFullWarningToRoomMessage{ - string roomId = 1; + string roomId = 1; } message RefreshRoomPromptMessage{ - string roomId = 1; + string roomId = 1; } message RefreshRoomMessage{ - string roomId = 1; - int32 versionNumber = 2; + string roomId = 1; + int32 versionNumber = 2; } message WorldFullMessage{ @@ -292,6 +309,9 @@ message ServerToClientMessage { WorldConnexionMessage worldConnexionMessage = 18; //EmoteEventMessage emoteEventMessage = 19; TokenExpiredMessage tokenExpiredMessage = 20; + FollowRequestMessage followRequestMessage = 21; + FollowConfirmationMessage followConfirmationMessage = 22; + FollowAbortMessage followAbortMessage = 23; } } @@ -378,6 +398,9 @@ message PusherToBackMessage { BanUserMessage banUserMessage = 13; EmotePromptMessage emotePromptMessage = 14; VariableMessage variableMessage = 15; + FollowRequestMessage followRequestMessage = 16; + FollowConfirmationMessage followConfirmationMessage = 17; + FollowAbortMessage followAbortMessage = 18; } } diff --git a/pusher/src/Controller/IoSocketController.ts b/pusher/src/Controller/IoSocketController.ts index 072fe5dd..9d1f3887 100644 --- a/pusher/src/Controller/IoSocketController.ts +++ b/pusher/src/Controller/IoSocketController.ts @@ -17,6 +17,9 @@ import { ServerToClientMessage, CompanionMessage, EmotePromptMessage, + FollowRequestMessage, + FollowConfirmationMessage, + FollowAbortMessage, VariableMessage, } from "../Messages/generated/messages_pb"; import { UserMovesMessage } from "../Messages/generated/messages_pb"; @@ -477,6 +480,18 @@ export class IoSocketController { client, message.getEmotepromptmessage() as EmotePromptMessage ); + } else if (message.hasFollowrequestmessage()) { + socketManager.handleFollowRequest( + client, + message.getFollowrequestmessage() as FollowRequestMessage + ); + } else if (message.hasFollowconfirmationmessage()) { + socketManager.handleFollowConfirmation( + client, + message.getFollowconfirmationmessage() as FollowConfirmationMessage + ); + } else if (message.hasFollowabortmessage()) { + socketManager.handleFollowAbort(client, message.getFollowabortmessage() as FollowAbortMessage); } /* Ok is false if backpressure was built up, wait for drain */ diff --git a/pusher/src/Services/SocketManager.ts b/pusher/src/Services/SocketManager.ts index 5cce5a6e..43e85b88 100644 --- a/pusher/src/Services/SocketManager.ts +++ b/pusher/src/Services/SocketManager.ts @@ -8,6 +8,9 @@ import { CharacterLayerMessage, EmoteEventMessage, EmotePromptMessage, + FollowRequestMessage, + FollowConfirmationMessage, + FollowAbortMessage, GroupDeleteMessage, ItemEventMessage, JoinRoomMessage, @@ -271,6 +274,24 @@ export class SocketManager implements ZoneEventListener { this.handleViewport(client, viewport.toObject()); } + handleFollowRequest(client: ExSocketInterface, message: FollowRequestMessage): void { + const pusherToBackMessage = new PusherToBackMessage(); + pusherToBackMessage.setFollowrequestmessage(message); + client.backConnection.write(pusherToBackMessage); + } + + handleFollowConfirmation(client: ExSocketInterface, message: FollowConfirmationMessage): void { + const pusherToBackMessage = new PusherToBackMessage(); + pusherToBackMessage.setFollowconfirmationmessage(message); + client.backConnection.write(pusherToBackMessage); + } + + handleFollowAbort(client: ExSocketInterface, message: FollowAbortMessage): void { + const pusherToBackMessage = new PusherToBackMessage(); + pusherToBackMessage.setFollowabortmessage(message); + client.backConnection.write(pusherToBackMessage); + } + onEmote(emoteMessage: EmoteEventMessage, listener: ExSocketInterface): void { const subMessage = new SubMessage(); subMessage.setEmoteeventmessage(emoteMessage);