diff --git a/back/src/Controller/IoSocketController.ts b/back/src/Controller/IoSocketController.ts index 34e0dbc8..c1c00761 100644 --- a/back/src/Controller/IoSocketController.ts +++ b/back/src/Controller/IoSocketController.ts @@ -6,8 +6,8 @@ import {ExSocketInterface} from "../Model/Websocket/ExSocketInterface"; //TODO f import Jwt, {JsonWebTokenError} from "jsonwebtoken"; import {SECRET_KEY, MINIMUM_DISTANCE, GROUP_RADIUS, ALLOW_ARTILLERY} from "../Enum/EnvironmentVariable"; //TODO fix import by "_Enum/..." import {World} from "../Model/World"; -import {Group} from "_Model/Group"; -import {UserInterface} from "_Model/UserInterface"; +import {Group} from "../Model/Group"; +import {UserInterface} from "../Model/UserInterface"; import {isSetPlayerDetailsMessage,} from "../Model/Websocket/SetPlayerDetailsMessage"; import {MessageUserJoined} from "../Model/Websocket/MessageUserJoined"; import {MessageUserMoved} from "../Model/Websocket/MessageUserMoved"; @@ -19,12 +19,14 @@ import {isPointInterface, PointInterface} from "../Model/Websocket/PointInterfac import {isWebRtcSignalMessageInterface} from "../Model/Websocket/WebRtcSignalMessage"; import {UserInGroupInterface} from "../Model/Websocket/UserInGroupInterface"; import {uuid} from 'uuidv4'; +import {isUserMovesInterface} from "../Model/Websocket/UserMovesMessage"; +import {isViewport} from "../Model/Websocket/ViewportMessage"; enum SockerIoEvent { CONNECTION = "connection", DISCONNECT = "disconnect", JOIN_ROOM = "join-room", // bi-directional - USER_POSITION = "user-position", // bi-directional + USER_POSITION = "user-position", // From client to server USER_MOVED = "user-moved", // From server to client USER_LEFT = "user-left", // From server to client WEBRTC_SIGNAL = "webrtc-signal", @@ -36,6 +38,20 @@ enum SockerIoEvent { GROUP_DELETE = "group-delete", SET_PLAYER_DETAILS = "set-player-details", SET_SILENT = "set_silent", // Set or unset the silent mode for this user. + SET_VIEWPORT = "set-viewport", + BATCH = "batch", +} + +function emitInBatch(socket: ExSocketInterface, event: string | symbol, payload: unknown): void { + socket.batchedMessages.push({ event, payload}); + + if (socket.batchTimeout === null) { + socket.batchTimeout = setTimeout(() => { + socket.emit(SockerIoEvent.BATCH, socket.batchedMessages); + socket.batchedMessages = []; + socket.batchTimeout = null; + }, 100); + } } export class IoSocketController { @@ -152,6 +168,11 @@ export class IoSocketController { ioConnection() { this.Io.on(SockerIoEvent.CONNECTION, (socket: Socket) => { const client : ExSocketInterface = socket as ExSocketInterface; + client.batchedMessages = []; + client.batchTimeout = null; + client.emitInBatch = (event: string | symbol, payload: unknown): void => { + emitInBatch(client, event, payload); + } this.sockets.set(client.userId, client); // Let's log server load when a user joins @@ -192,22 +213,24 @@ export class IoSocketController { //join new previous room const world = this.joinRoom(Client, roomId, message.position); - //add function to refresh position user in real time. - //this.refreshUserPosition(Client); - - const messageUserJoined = new MessageUserJoined(Client.userId, Client.name, Client.characterLayers, Client.position); - - socket.to(roomId).emit(SockerIoEvent.JOIN_ROOM, messageUserJoined); - - // The answer shall contain the list of all users of the room with their positions: - const listOfUsers = Array.from(world.getUsers(), ([key, user]) => { + const users = world.setViewport(Client, message.viewport); + const listOfUsers = users.map((user: UserInterface) => { const player: ExSocketInterface|undefined = this.sockets.get(user.id); if (player === undefined) { console.warn('Something went wrong. The World contains a user "'+user.id+"' but this user does not exist in the sockets list!"); return null; } return new MessageUserPosition(user.id, player.name, player.characterLayers, player.position); - }).filter((item: MessageUserPosition|null) => item !== null); + }, users); + + //console.warn('ANSWER PLAYER POSITIONS', listOfUsers); + if (answerFn === undefined && ALLOW_ARTILLERY === true) { + /*console.error("TYPEOF answerFn", typeof(answerFn)); + console.error("answerFn", answerFn); + process.exit(1)*/ + // For some reason, answerFn can be undefined if we use Artillery (?) + return; + } answerFn(listOfUsers); } catch (e) { console.error('An error occurred on "join_room" event'); @@ -215,29 +238,53 @@ export class IoSocketController { } }); - socket.on(SockerIoEvent.USER_POSITION, (position: unknown): void => { - console.log(SockerIoEvent.USER_POSITION, position); + socket.on(SockerIoEvent.SET_VIEWPORT, (message: unknown): void => { try { - if (!isPointInterface(position)) { + //console.log('SET_VIEWPORT') + if (!isViewport(message)) { + socket.emit(SockerIoEvent.MESSAGE_ERROR, {message: 'Invalid SET_VIEWPORT message.'}); + console.warn('Invalid SET_VIEWPORT message received: ', message); + return; + } + + const Client = (socket as ExSocketInterface); + Client.viewport = message; + + const world = this.Worlds.get(Client.roomId); + if (!world) { + console.error("In SET_VIEWPORT, could not find world with id '", Client.roomId, "'"); + return; + } + world.setViewport(Client, Client.viewport); + } catch (e) { + console.error('An error occurred on "SET_VIEWPORT" event'); + console.error(e); + } + }); + + socket.on(SockerIoEvent.USER_POSITION, (userMovesMessage: unknown): void => { + //console.log(SockerIoEvent.USER_POSITION, userMovesMessage); + try { + if (!isUserMovesInterface(userMovesMessage)) { socket.emit(SockerIoEvent.MESSAGE_ERROR, {message: 'Invalid USER_POSITION message.'}); - console.warn('Invalid USER_POSITION message received: ', position); + console.warn('Invalid USER_POSITION message received: ', userMovesMessage); return; } const Client = (socket as ExSocketInterface); // sending to all clients in room except sender - Client.position = position; + Client.position = userMovesMessage.position; + Client.viewport = userMovesMessage.viewport; // update position in the world const world = this.Worlds.get(Client.roomId); if (!world) { - console.error("Could not find world with id '", Client.roomId, "'"); + console.error("In USER_POSITION, could not find world with id '", Client.roomId, "'"); return; } - world.updatePosition(Client, position); - - socket.to(Client.roomId).emit(SockerIoEvent.USER_MOVED, new MessageUserMoved(Client.userId, Client.position)); + world.updatePosition(Client, Client.position); + world.setViewport(Client, Client.viewport); } catch (e) { console.error('An error occurred on "user_position" event'); console.error(e); @@ -312,7 +359,7 @@ export class IoSocketController { // update position in the world const world = this.Worlds.get(Client.roomId); if (!world) { - console.error("Could not find world with id '", Client.roomId, "'"); + console.error("In SET_SILENT, could not find world with id '", Client.roomId, "'"); return; } world.setSilent(Client, silent); @@ -372,8 +419,6 @@ export class IoSocketController { // leave previous room and world if(Client.roomId){ try { - Client.to(Client.roomId).emit(SockerIoEvent.USER_LEFT, Client.userId); - //user leave previous world const world: World | undefined = this.Worlds.get(Client.roomId); if (world) { @@ -409,6 +454,25 @@ export class IoSocketController { this.sendUpdateGroupEvent(group); }, (groupUuid: string, lastUser: UserInterface) => { this.sendDeleteGroupEvent(groupUuid, lastUser); + }, (user, listener) => { + const clientUser = this.searchClientByIdOrFail(user.id); + const clientListener = this.searchClientByIdOrFail(listener.id); + const messageUserJoined = new MessageUserJoined(clientUser.userId, clientUser.name, clientUser.characterLayers, clientUser.position); + + clientListener.emit(SockerIoEvent.JOIN_ROOM, messageUserJoined); + //console.log("Sending JOIN_ROOM event"); + }, (user, position, listener) => { + const clientUser = this.searchClientByIdOrFail(user.id); + const clientListener = this.searchClientByIdOrFail(listener.id); + + clientListener.emitInBatch(SockerIoEvent.USER_MOVED, new MessageUserMoved(clientUser.userId, clientUser.position)); + //console.log("Sending USER_MOVED event"); + }, (user, listener) => { + const clientUser = this.searchClientByIdOrFail(user.id); + const clientListener = this.searchClientByIdOrFail(listener.id); + + clientListener.emit(SockerIoEvent.USER_LEFT, clientUser.userId); + //console.log("Sending USER_LEFT event"); }); this.Worlds.set(roomId, world); } diff --git a/back/src/Model/PositionNotifier.ts b/back/src/Model/PositionNotifier.ts new file mode 100644 index 00000000..9d6975e3 --- /dev/null +++ b/back/src/Model/PositionNotifier.ts @@ -0,0 +1,120 @@ +/** + * Tracks the position of every player on the map, and sends notifications to the players interested in knowing about the move + * (i.e. players that are looking at the zone the player is currently in) + * + * Internally, the PositionNotifier works with Zones. A zone is a square area of a map. + * Each player is in a given zone, and each player tracks one or many zones (depending on the player viewport) + * + * 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 {UserEntersCallback, UserLeavesCallback, UserMovesCallback, Zone} from "./Zone"; +import {PointInterface} from "_Model/Websocket/PointInterface"; +import {UserInterface} from "_Model/UserInterface"; +import {ViewportInterface} from "_Model/Websocket/ViewportMessage"; + +interface ZoneDescriptor { + i: number; + j: number; +} + +export class PositionNotifier { + + // TODO: we need a way to clean the zones if noone is in the zone and noone listening (to free memory!) + + private zones: Zone[][] = []; + + constructor(private zoneWidth: number, private zoneHeight: number, private onUserEnters: UserEntersCallback, private onUserMoves: UserMovesCallback, private onUserLeaves: UserLeavesCallback) { + } + + private getZoneDescriptorFromCoordinates(x: number, y: number): ZoneDescriptor { + return { + i: Math.floor(x / this.zoneWidth), + j: Math.floor(y / this.zoneHeight), + } + } + + /** + * Sets the viewport coordinates. + * Returns the list of new users to add + */ + public setViewport(user: UserInterface, viewport: ViewportInterface): UserInterface[] { + if (viewport.left > viewport.right || viewport.top > viewport.bottom) { + console.warn('Invalid viewport received: ', viewport); + return []; + } + + const oldZones = user.listenedZones; + const newZones = new Set(); + + const topLeftDesc = this.getZoneDescriptorFromCoordinates(viewport.left, viewport.top); + const bottomRightDesc = this.getZoneDescriptorFromCoordinates(viewport.right, viewport.bottom); + + for (let j = topLeftDesc.j; j <= bottomRightDesc.j; j++) { + for (let i = topLeftDesc.i; i <= bottomRightDesc.i; i++) { + newZones.add(this.getZone(i, j)); + } + } + + const addedZones = [...newZones].filter(x => !oldZones.has(x)); + const removedZones = [...oldZones].filter(x => !newZones.has(x)); + + + let users: UserInterface[] = []; + for (const zone of addedZones) { + zone.startListening(user); + users = users.concat(Array.from(zone.getPlayers())) + } + for (const zone of removedZones) { + zone.stopListening(user); + } + + return users; + } + + public updatePosition(user: UserInterface, userPosition: PointInterface): void { + // Did we change zone? + const oldZoneDesc = this.getZoneDescriptorFromCoordinates(user.position.x, user.position.y); + const newZoneDesc = this.getZoneDescriptorFromCoordinates(userPosition.x, userPosition.y); + + if (oldZoneDesc.i != newZoneDesc.i || oldZoneDesc.j != newZoneDesc.j) { + const oldZone = this.getZone(oldZoneDesc.i, oldZoneDesc.j); + const newZone = this.getZone(newZoneDesc.i, newZoneDesc.j); + + // Leave old zone + oldZone.leave(user, newZone); + + // Enter new zone + newZone.enter(user, oldZone, userPosition); + } else { + const zone = this.getZone(oldZoneDesc.i, oldZoneDesc.j); + zone.move(user, userPosition); + } + } + + public leave(user: UserInterface): void { + const oldZoneDesc = this.getZoneDescriptorFromCoordinates(user.position.x, user.position.y); + const oldZone = this.getZone(oldZoneDesc.i, oldZoneDesc.j); + oldZone.leave(user, null); + + // Also, let's stop listening on viewports + for (const zone of user.listenedZones) { + zone.stopListening(user); + } + } + + private getZone(i: number, j: number): Zone { + let zoneRow = this.zones[j]; + if (zoneRow === undefined) { + zoneRow = new Array(); + this.zones[j] = zoneRow; + } + + let zone = this.zones[j][i]; + if (zone === undefined) { + zone = new Zone(this.onUserEnters, this.onUserMoves, this.onUserLeaves); + this.zones[j][i] = zone; + } + return zone; + } +} diff --git a/back/src/Model/UserInterface.ts b/back/src/Model/UserInterface.ts index 89994a31..d19ecd6f 100644 --- a/back/src/Model/UserInterface.ts +++ b/back/src/Model/UserInterface.ts @@ -1,9 +1,11 @@ import { Group } from "./Group"; import { PointInterface } from "./Websocket/PointInterface"; +import {Zone} from "_Model/Zone"; export interface UserInterface { id: string, group?: Group, position: PointInterface, - silent: boolean + silent: boolean, + listenedZones: Set } diff --git a/back/src/Model/Websocket/ExSocketInterface.ts b/back/src/Model/Websocket/ExSocketInterface.ts index 974fe63d..bbe18cbb 100644 --- a/back/src/Model/Websocket/ExSocketInterface.ts +++ b/back/src/Model/Websocket/ExSocketInterface.ts @@ -2,6 +2,7 @@ import {Socket} from "socket.io"; import {PointInterface} from "./PointInterface"; import {Identificable} from "./Identificable"; import {TokenInterface} from "../../Controller/AuthenticateController"; +import {ViewportInterface} from "_Model/Websocket/ViewportMessage"; export interface ExSocketInterface extends Socket, Identificable { token: string; @@ -11,5 +12,12 @@ export interface ExSocketInterface extends Socket, Identificable { name: string; characterLayers: string[]; position: PointInterface; + viewport: ViewportInterface; isArtillery: boolean; // Whether this socket is opened by Artillery for load testing (hack) + /** + * Pushes an event that will be sent in the next batch of events + */ + emitInBatch: (event: string | symbol, payload: unknown) => void; + batchedMessages: Array<{ event: string | symbol, payload: unknown }>; + batchTimeout: NodeJS.Timeout|null; } diff --git a/back/src/Model/Websocket/JoinRoomMessage.ts b/back/src/Model/Websocket/JoinRoomMessage.ts index 16613488..2036a441 100644 --- a/back/src/Model/Websocket/JoinRoomMessage.ts +++ b/back/src/Model/Websocket/JoinRoomMessage.ts @@ -1,9 +1,11 @@ import * as tg from "generic-type-guard"; import {isPointInterface} from "./PointInterface"; +import {isViewport} from "./ViewportMessage"; export const isJoinRoomMessageInterface = new tg.IsInterface().withProperties({ roomId: tg.isString, position: isPointInterface, + viewport: isViewport }).get(); export type JoinRoomMessageInterface = tg.GuardedType; diff --git a/back/src/Model/Websocket/UserMovesMessage.ts b/back/src/Model/Websocket/UserMovesMessage.ts new file mode 100644 index 00000000..2277d4c4 --- /dev/null +++ b/back/src/Model/Websocket/UserMovesMessage.ts @@ -0,0 +1,11 @@ +import * as tg from "generic-type-guard"; +import {isPointInterface} from "./PointInterface"; +import {isViewport} from "./ViewportMessage"; + + +export const isUserMovesInterface = + new tg.IsInterface().withProperties({ + position: isPointInterface, + viewport: isViewport, + }).get(); +export type UserMovesInterface = tg.GuardedType; diff --git a/back/src/Model/Websocket/ViewportMessage.ts b/back/src/Model/Websocket/ViewportMessage.ts new file mode 100644 index 00000000..62e2fc81 --- /dev/null +++ b/back/src/Model/Websocket/ViewportMessage.ts @@ -0,0 +1,10 @@ +import * as tg from "generic-type-guard"; + +export const isViewport = + new tg.IsInterface().withProperties({ + left: tg.isNumber, + top: tg.isNumber, + right: tg.isNumber, + bottom: tg.isNumber, + }).get(); +export type ViewportInterface = tg.GuardedType; diff --git a/back/src/Model/World.ts b/back/src/Model/World.ts index 8855702e..4422e95f 100644 --- a/back/src/Model/World.ts +++ b/back/src/Model/World.ts @@ -6,6 +6,9 @@ import {UserInterface} from "./UserInterface"; import {ExSocketInterface} from "_Model/Websocket/ExSocketInterface"; import {PositionInterface} from "_Model/PositionInterface"; import {Identificable} from "_Model/Websocket/Identificable"; +import {UserEntersCallback, UserLeavesCallback, UserMovesCallback, Zone} from "_Model/Zone"; +import {PositionNotifier} from "./PositionNotifier"; +import {ViewportInterface} from "_Model/Websocket/ViewportMessage"; export type ConnectCallback = (user: string, group: Group) => void; export type DisconnectCallback = (user: string, group: Group) => void; @@ -27,12 +30,17 @@ export class World { private readonly groupUpdatedCallback: GroupUpdatedCallback; private readonly groupDeletedCallback: GroupDeletedCallback; + private readonly positionNotifier: PositionNotifier; + constructor(connectCallback: ConnectCallback, disconnectCallback: DisconnectCallback, minDistance: number, groupRadius: number, groupUpdatedCallback: GroupUpdatedCallback, - groupDeletedCallback: GroupDeletedCallback) + groupDeletedCallback: GroupDeletedCallback, + onUserEnters: UserEntersCallback, + onUserMoves: UserMovesCallback, + onUserLeaves: UserLeavesCallback) { this.users = new Map(); this.groups = new Set(); @@ -42,6 +50,8 @@ export class World { this.groupRadius = groupRadius; this.groupUpdatedCallback = groupUpdatedCallback; this.groupDeletedCallback = groupDeletedCallback; + // A zone is 10 sprites wide. + this.positionNotifier = new PositionNotifier(320, 320, onUserEnters, onUserMoves, onUserLeaves); } public getGroups(): Group[] { @@ -56,7 +66,8 @@ export class World { this.users.set(socket.userId, { id: socket.userId, position: userPosition, - silent: false // FIXME: silent should be set at the correct value when joining a room. + silent: false, // FIXME: silent should be set at the correct value when joining a room. + listenedZones: new Set() }); // Let's call update position to trigger the join / leave room this.updatePosition(socket, userPosition); @@ -71,6 +82,10 @@ export class World { this.leaveGroup(userObj); } this.users.delete(user.userId); + + if (userObj !== undefined) { + this.positionNotifier.leave(userObj); + } } public isEmpty(): boolean { @@ -83,6 +98,8 @@ export class World { return; } + this.positionNotifier.updatePosition(user, userPosition); + user.position = userPosition; if (user.silent) { @@ -316,4 +333,12 @@ export class World { } return 0; }*/ + setViewport(socket : Identificable, viewport: ViewportInterface): UserInterface[] { + const user = this.users.get(socket.userId); + if(typeof user === 'undefined') { + console.warn('In setViewport, could not find user with ID "'+socket.userId+'" in world.'); + return []; + } + return this.positionNotifier.setViewport(user, viewport); + } } diff --git a/back/src/Model/Zone.ts b/back/src/Model/Zone.ts new file mode 100644 index 00000000..bd748b0f --- /dev/null +++ b/back/src/Model/Zone.ts @@ -0,0 +1,96 @@ +import {UserInterface} from "./UserInterface"; +import {PointInterface} from "_Model/Websocket/PointInterface"; +import {PositionInterface} from "_Model/PositionInterface"; + +export type UserEntersCallback = (user: UserInterface, listener: UserInterface) => void; +export type UserMovesCallback = (user: UserInterface, position: PointInterface, listener: UserInterface) => void; +export type UserLeavesCallback = (user: UserInterface, listener: UserInterface) => void; + +export class Zone { + private players: Set = new Set(); + private listeners: Set = new Set(); + + constructor(private onUserEnters: UserEntersCallback, private onUserMoves: UserMovesCallback, private onUserLeaves: UserLeavesCallback) { + } + + /** + * A user leaves the zone + */ + public leave(user: UserInterface, newZone: Zone|null) { + this.players.delete(user); + this.notifyUserLeft(user, newZone); + } + + /** + * Notify listeners of this zone that this user left + */ + private notifyUserLeft(user: UserInterface, newZone: Zone|null) { + for (const listener of this.listeners) { + if (listener !== user && (newZone === null || !listener.listenedZones.has(newZone))) { + this.onUserLeaves(user, listener); + } + } + } + + public enter(user: UserInterface, oldZone: Zone|null, position: PointInterface) { + this.players.add(user); + this.notifyUserEnter(user, oldZone, position); + } + + /** + * Notify listeners of this zone that this user entered + */ + private notifyUserEnter(user: UserInterface, oldZone: Zone|null, position: PointInterface) { + for (const listener of this.listeners) { + if (listener === user) { + continue; + } + if (oldZone === null || !listener.listenedZones.has(oldZone)) { + this.onUserEnters(user, listener); + } else { + this.onUserMoves(user, position, listener); + } + } + } + + public move(user: UserInterface, position: PointInterface) { + if (!this.players.has(user)) { + this.players.add(user); + const foo = this.players; + this.notifyUserEnter(user, null, position); + return; + } + + for (const listener of this.listeners) { + if (listener !== user) { + this.onUserMoves(user,position, listener); + } + } + } + + public startListening(listener: UserInterface): void { + for (const player of this.players) { + if (player !== listener) { + this.onUserEnters(player, listener); + } + } + + this.listeners.add(listener); + listener.listenedZones.add(this); + } + + public stopListening(listener: UserInterface): void { + for (const player of this.players) { + if (player !== listener) { + this.onUserLeaves(player, listener); + } + } + + this.listeners.delete(listener); + listener.listenedZones.delete(this); + } + + public getPlayers(): Set { + return this.players; + } +} diff --git a/back/tests/PositionNotifierTest.ts b/back/tests/PositionNotifierTest.ts new file mode 100644 index 00000000..0b8b466f --- /dev/null +++ b/back/tests/PositionNotifierTest.ts @@ -0,0 +1,196 @@ +import "jasmine"; +import {World, ConnectCallback, DisconnectCallback } from "../src/Model/World"; +import {Point} from "../src/Model/Websocket/MessageUserPosition"; +import { Group } from "../src/Model/Group"; +import {PositionNotifier} from "../src/Model/PositionNotifier"; +import {UserInterface} from "../src/Model/UserInterface"; +import {PointInterface} from "../src/Model/Websocket/PointInterface"; +import {Zone} from "_Model/Zone"; + +function move(user: UserInterface, x: number, y: number, positionNotifier: PositionNotifier): void { + positionNotifier.updatePosition(user, { + x, + y, + moving: false, + direction: 'down' + }); + user.position.x = x; + user.position.y = y; +} + +describe("PositionNotifier", () => { + it("should receive notifications when player moves", () => { + let enterTriggered = false; + let moveTriggered = false; + let leaveTriggered = false; + + const positionNotifier = new PositionNotifier(300, 300, (user: UserInterface) => { + enterTriggered = true; + }, (user: UserInterface, position: PointInterface) => { + moveTriggered = true; + }, (user: UserInterface) => { + leaveTriggered = true; + }); + + const user1 = { + id: "1", + position: { + x: 500, + y: 500, + moving: false, + direction: 'down' + }, + listenedZones: new Set(), + } as UserInterface; + + const user2 = { + id: "2", + position: { + x: -9999, + y: -9999, + moving: false, + direction: 'down' + }, + listenedZones: new Set(), + } as UserInterface; + + positionNotifier.setViewport(user1, { + left: 200, + right: 600, + top: 100, + bottom: 500 + }); + + move(user2, 500, 500, positionNotifier); + + expect(enterTriggered).toBe(true); + expect(moveTriggered).toBe(false); + enterTriggered = false; + + // Move inside the zone + move(user2, 501, 500, positionNotifier); + + expect(enterTriggered).toBe(false); + expect(moveTriggered).toBe(true); + moveTriggered = false; + + // Move out of the zone in a zone that we don't track + move(user2, 901, 500, positionNotifier); + + expect(enterTriggered).toBe(false); + expect(moveTriggered).toBe(false); + expect(leaveTriggered).toBe(true); + leaveTriggered = false; + + // Move back in + move(user2, 500, 500, positionNotifier); + expect(enterTriggered).toBe(true); + expect(moveTriggered).toBe(false); + expect(leaveTriggered).toBe(false); + enterTriggered = false; + + // Move out of the zone in a zone that we do track + move(user2, 200, 500, positionNotifier); + expect(enterTriggered).toBe(false); + expect(moveTriggered).toBe(true); + expect(leaveTriggered).toBe(false); + moveTriggered = false; + + // Leave the room + positionNotifier.leave(user2); + expect(enterTriggered).toBe(false); + expect(moveTriggered).toBe(false); + expect(leaveTriggered).toBe(true); + leaveTriggered = false; + }); + + it("should receive notifications when camera moves", () => { + let enterTriggered = false; + let moveTriggered = false; + let leaveTriggered = false; + + const positionNotifier = new PositionNotifier(300, 300, (user: UserInterface) => { + enterTriggered = true; + }, (user: UserInterface, position: PointInterface) => { + moveTriggered = true; + }, (user: UserInterface) => { + leaveTriggered = true; + }); + + const user1 = { + id: "1", + position: { + x: 500, + y: 500, + moving: false, + direction: 'down' + }, + listenedZones: new Set(), + } as UserInterface; + + const user2 = { + id: "2", + position: { + x: -9999, + y: -9999, + moving: false, + direction: 'down' + }, + listenedZones: new Set(), + } as UserInterface; + + let newUsers = positionNotifier.setViewport(user1, { + left: 200, + right: 600, + top: 100, + bottom: 500 + }); + + expect(newUsers.length).toBe(0); + + move(user2, 500, 500, positionNotifier); + + expect(enterTriggered).toBe(true); + expect(moveTriggered).toBe(false); + enterTriggered = false; + + // Move the viewport but the user stays inside. + positionNotifier.setViewport(user1, { + left: 201, + right: 601, + top: 100, + bottom: 500 + }); + + expect(enterTriggered).toBe(false); + expect(moveTriggered).toBe(false); + expect(leaveTriggered).toBe(false); + + // Move the viewport out of the user. + positionNotifier.setViewport(user1, { + left: 901, + right: 1001, + top: 100, + bottom: 500 + }); + + expect(enterTriggered).toBe(false); + expect(moveTriggered).toBe(false); + expect(leaveTriggered).toBe(true); + leaveTriggered = false; + + // Move the viewport back on the user. + newUsers = positionNotifier.setViewport(user1, { + left: 200, + right: 600, + top: 100, + bottom: 500 + }); + + expect(enterTriggered).toBe(true); + expect(moveTriggered).toBe(false); + expect(leaveTriggered).toBe(false); + enterTriggered = false; + expect(newUsers.length).toBe(1); + }); +}) diff --git a/back/tests/WorldTest.ts b/back/tests/WorldTest.ts index c436eed7..580677c7 100644 --- a/back/tests/WorldTest.ts +++ b/back/tests/WorldTest.ts @@ -13,7 +13,7 @@ describe("World", () => { } - const world = new World(connect, disconnect, 160, 160, () => {}, () => {}); + const world = new World(connect, disconnect, 160, 160, () => {}, () => {}, () => {}, () => {}, () => {}); world.join({ userId: "foo" }, new Point(100, 100)); @@ -40,7 +40,7 @@ describe("World", () => { } - const world = new World(connect, disconnect, 160, 160, () => {}, () => {}); + const world = new World(connect, disconnect, 160, 160, () => {}, () => {}, () => {}, () => {}, () => {}); world.join({ userId: "foo" }, new Point(100, 100)); @@ -69,7 +69,7 @@ describe("World", () => { disconnectCallNumber++; } - const world = new World(connect, disconnect, 160, 160, () => {}, () => {}); + const world = new World(connect, disconnect, 160, 160, () => {}, () => {}, () => {}, () => {}, () => {}); world.join({ userId: "foo" }, new Point(100, 100)); diff --git a/back/tsconfig.json b/back/tsconfig.json index 397bb8a2..de6314a3 100644 --- a/back/tsconfig.json +++ b/back/tsconfig.json @@ -12,7 +12,7 @@ // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ // "declaration": true, /* Generates corresponding '.d.ts' file. */ // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ - // "sourceMap": true, /* Generates corresponding '.map' file. */ + "sourceMap": true, /* Generates corresponding '.map' file. */ // "outFile": "./", /* Concatenate and emit output to single file. */ "outDir": "./dist", /* Redirect output structure to the directory. */ // "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ diff --git a/benchmark/socketio-load-test.yaml b/benchmark/socketio-load-test.yaml index 2f9f689d..df2f580b 100644 --- a/benchmark/socketio-load-test.yaml +++ b/benchmark/socketio-load-test.yaml @@ -28,16 +28,27 @@ scenarios: y: 170 direction: 'down' moving: false + viewport: + left: 500 + top: 0 + right: 800 + bottom: 200 - think: 1 - loop: - function: "setYRandom" - emit: channel: "user-position" data: - x: "{{ x }}" - y: "{{ y }}" - direction: 'down' - moving: false + position: + x: "{{ x }}" + y: "{{ y }}" + direction: 'down' + moving: false + viewport: + left: "{{ left }}" + top: "{{ top }}" + right: "{{ right }}" + bottom: "{{ bottom }}" - think: 0.2 count: 100 - think: 10 diff --git a/benchmark/socketioLoadTest.js b/benchmark/socketioLoadTest.js index 907982b2..f898d7b9 100644 --- a/benchmark/socketioLoadTest.js +++ b/benchmark/socketioLoadTest.js @@ -5,7 +5,16 @@ module.exports = { }; function setYRandom(context, events, done) { - context.vars.x = (883 + Math.round(Math.random() * 300)); - context.vars.y = (270 + Math.round(Math.random() * 300)); + if (context.angle === undefined) { + context.angle = Math.random() * Math.PI * 2; + } + context.angle += 0.05; + + context.vars.x = 320 + 1472/2 * (1 + Math.sin(context.angle)); + context.vars.y = 200 + 1090/2 * (1 + Math.cos(context.angle)); + context.vars.left = context.vars.x - 320; + context.vars.top = context.vars.y - 200; + context.vars.right = context.vars.x + 320; + context.vars.bottom = context.vars.y + 200; return done(); } diff --git a/front/src/Connection.ts b/front/src/Connection.ts index 4a184c52..dee27ae5 100644 --- a/front/src/Connection.ts +++ b/front/src/Connection.ts @@ -14,7 +14,7 @@ enum EventMessage{ WEBRTC_SCREEN_SHARING_SIGNAL = "webrtc-screen-sharing-signal", WEBRTC_START = "webrtc-start", JOIN_ROOM = "join-room", // bi-directional - USER_POSITION = "user-position", // bi-directional + USER_POSITION = "user-position", // From client to server USER_MOVED = "user-moved", // From server to client USER_LEFT = "user-left", // From server to client MESSAGE_ERROR = "message-error", @@ -25,6 +25,8 @@ enum EventMessage{ CONNECT_ERROR = "connect_error", SET_SILENT = "set_silent", // Set or unset the silent mode for this user. + SET_VIEWPORT = "set-viewport", + BATCH = "batch", } export interface PointInterface { @@ -95,6 +97,23 @@ export interface StartMapInterface { startInstance: string } +export interface ViewportInterface { + left: number, + top: number, + right: number, + bottom: number, +} + +export interface UserMovesInterface { + position: PositionInterface, + viewport: ViewportInterface, +} + +export interface BatchedMessageInterface { + event: string, + payload: unknown +} + export class Connection implements Connection { private readonly socket: Socket; private userId: string|null = null; @@ -111,6 +130,18 @@ export class Connection implements Connection { this.socket.on(EventMessage.MESSAGE_ERROR, (message: string) => { console.error(EventMessage.MESSAGE_ERROR, message); }) + + /** + * Messages inside batched messages are extracted and sent to listeners directly. + */ + this.socket.on(EventMessage.BATCH, (batchedMessages: BatchedMessageInterface[]) => { + for (const message of batchedMessages) { + const listeners = this.socket.listeners(message.event); + for (const listener of listeners) { + listener(message.payload); + } + } + }) } public static createConnection(name: string, characterLayersSelected: string[]): Promise { @@ -151,27 +182,35 @@ export class Connection implements Connection { } - public joinARoom(roomId: string, startX: number, startY: number, direction: string, moving: boolean): Promise { + public joinARoom(roomId: string, startX: number, startY: number, direction: string, moving: boolean, viewport: ViewportInterface): Promise { const promise = new Promise((resolve, reject) => { - this.socket.emit(EventMessage.JOIN_ROOM, { roomId, position: {x: startX, y: startY, direction, moving }}, (userPositions: MessageUserPositionInterface[]) => { - resolve(userPositions); - }); + this.socket.emit(EventMessage.JOIN_ROOM, { + roomId, + position: {x: startX, y: startY, direction, moving }, + viewport, + }, (userPositions: MessageUserPositionInterface[]) => { + resolve(userPositions); + }); }) return promise; } - public sharePosition(x : number, y : number, direction : string, moving: boolean) : void{ + public sharePosition(x : number, y : number, direction : string, moving: boolean, viewport: ViewportInterface) : void{ if(!this.socket){ return; } const point = new Point(x, y, direction, moving); - this.socket.emit(EventMessage.USER_POSITION, point); + this.socket.emit(EventMessage.USER_POSITION, { position: point, viewport } as UserMovesInterface); } public setSilent(silent: boolean): void { this.socket.emit(EventMessage.SET_SILENT, silent); } + public setViewport(viewport: ViewportInterface): void { + this.socket.emit(EventMessage.SET_VIEWPORT, viewport); + } + public onUserJoins(callback: (message: MessageUserJoined) => void): void { this.socket.on(EventMessage.JOIN_ROOM, callback); } diff --git a/front/src/Phaser/Game/GameScene.ts b/front/src/Phaser/Game/GameScene.ts index 8a5630dc..6a6656c2 100644 --- a/front/src/Phaser/Game/GameScene.ts +++ b/front/src/Phaser/Game/GameScene.ts @@ -111,7 +111,7 @@ export class GameScene extends Phaser.Scene implements CenterListener { private startLayerName: string|undefined; private presentationModeSprite!: Sprite; private chatModeSprite!: Sprite; - private repositionCallback!: (this: Window, ev: UIEvent) => void; + private onResizeCallback!: (this: Window, ev: UIEvent) => void; private gameMap!: GameMap; static createFromUrl(mapUrlFile: string, instance: string, key: string|null = null): GameScene { @@ -226,7 +226,7 @@ export class GameScene extends Phaser.Scene implements CenterListener { this.scene.stop(this.scene.key); this.scene.remove(this.scene.key); - window.removeEventListener('resize', this.repositionCallback); + window.removeEventListener('resize', this.onResizeCallback); }) // When connection is performed, let's connect SimplePeer @@ -412,8 +412,8 @@ export class GameScene extends Phaser.Scene implements CenterListener { this.switchLayoutMode(); }); - this.repositionCallback = this.reposition.bind(this); - window.addEventListener('resize', this.repositionCallback); + this.onResizeCallback = this.onResize.bind(this); + window.addEventListener('resize', this.onResizeCallback); this.reposition(); // From now, this game scene will be notified of reposition events @@ -636,7 +636,17 @@ export class GameScene extends Phaser.Scene implements CenterListener { //join room this.connectionPromise.then((connection: Connection) => { - connection.joinARoom(this.RoomId, this.startX, this.startY, PlayerAnimationNames.WalkDown, false).then((userPositions: MessageUserPositionInterface[]) => { + const camera = this.cameras.main; + connection.joinARoom(this.RoomId, + this.startX, + this.startY, + PlayerAnimationNames.WalkDown, + false, { + left: camera.scrollX, + top: camera.scrollY, + right: camera.scrollX + camera.width, + bottom: camera.scrollY + camera.height, + }).then((userPositions: MessageUserPositionInterface[]) => { this.initUsersPosition(userPositions); }); @@ -677,7 +687,13 @@ export class GameScene extends Phaser.Scene implements CenterListener { private doPushPlayerPosition(event: HasMovedEvent): void { this.lastMoveEventSent = event; this.lastSentTick = this.currentTick; - this.connection.sharePosition(event.x, event.y, event.direction, event.moving); + const camera = this.cameras.main; + this.connection.sharePosition(event.x, event.y, event.direction, event.moving, { + left: camera.scrollX, + top: camera.scrollY, + right: camera.scrollX + camera.width, + bottom: camera.scrollY + camera.height, + }); } EventToClickOnTile(){ @@ -741,7 +757,7 @@ export class GameScene extends Phaser.Scene implements CenterListener { this.simplePeer.unregister(); this.scene.stop(); this.scene.remove(this.scene.key); - window.removeEventListener('resize', this.repositionCallback); + window.removeEventListener('resize', this.onResizeCallback); this.scene.start(nextSceneKey.key, { startLayerName: nextSceneKey.hash }); @@ -930,6 +946,19 @@ export class GameScene extends Phaser.Scene implements CenterListener { return mapUrlStart.substring(startPos, endPos); } + private onResize(): void { + this.reposition(); + + // Send new viewport to server + const camera = this.cameras.main; + this.connection.setViewport({ + left: camera.scrollX, + top: camera.scrollY, + right: camera.scrollX + camera.width, + bottom: camera.scrollY + camera.height, + }); + } + private reposition(): void { this.presentationModeSprite.setY(this.game.renderer.height - 2); this.chatModeSprite.setY(this.game.renderer.height - 2);