Adding batched messages + the notion of notifier / zones (not plugged in the system yet)

This commit is contained in:
David Négrier 2020-09-15 10:06:11 +02:00
parent 1879c550c4
commit 9b702c75e3
13 changed files with 518 additions and 22 deletions

View file

@ -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,13 @@ 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";
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 +37,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: any): 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 +167,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: any): void => {
emitInBatch(client, event, payload);
}
this.sockets.set(client.userId, client);
// Let's log server load when a user joins
@ -215,19 +235,20 @@ export class IoSocketController {
}
});
socket.on(SockerIoEvent.USER_POSITION, (position: unknown): void => {
console.log(SockerIoEvent.USER_POSITION, position);
socket.on(SockerIoEvent.USER_POSITION, (userMovesMessage: unknown): void => {
console.log(SockerIoEvent.USER_POSITION, userMovesMessage);
try {
if (!isPointInterface(position)) {
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);
@ -235,9 +256,20 @@ export class IoSocketController {
console.error("Could not find world with id '", Client.roomId, "'");
return;
}
world.updatePosition(Client, position);
world.updatePosition(Client, Client.position);
socket.to(Client.roomId).emit(SockerIoEvent.USER_MOVED, new MessageUserMoved(Client.userId, Client.position));
const clientsInRoom = this.Io.sockets.adapter.rooms[Client.roomId];
console.log('clientsInRoom', clientsInRoom);
for (const clientId in clientsInRoom.sockets) {
console.log('client: %s', clientId);
const targetSocket = this.Io.sockets.connected[clientId] as ExSocketInterface;
if (socket === targetSocket) {
continue;
}
//targetSocket.emit(SockerIoEvent.USER_MOVED, new MessageUserMoved(Client.userId, Client.position));
targetSocket.emitInBatch(SockerIoEvent.USER_MOVED, new MessageUserMoved(Client.userId, Client.position));
}
//socket.to(Client.roomId).emit(SockerIoEvent.USER_MOVED, new MessageUserMoved(Client.userId, Client.position));
} catch (e) {
console.error('An error occurred on "user_position" event');
console.error(e);

View file

@ -0,0 +1,106 @@
/**
* 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),
}
}
public setViewport(user: UserInterface, viewport: ViewportInterface): void {
if (viewport.left > viewport.right || viewport.top > viewport.bottom) {
console.warn('Invalid viewport received: ', viewport);
return;
}
const oldZones = user.listenedZones;
const newZones = new Set<Zone>();
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));
for (const zone of addedZones) {
zone.startListening(user);
}
for (const zone of removedZones) {
zone.stopListening(user);
}
}
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);
}
private getZone(i: number, j: number): Zone {
let zoneRow = this.zones[j];
if (zoneRow === undefined) {
zoneRow = new Array<Zone>();
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;
}
}

View file

@ -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<Zone>
}

View file

@ -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: any) => void;
batchedMessages: Array<{ event: string | symbol, payload: any }>;
batchTimeout: NodeJS.Timeout|null;
}

View file

@ -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<typeof isUserMovesInterface>;

View file

@ -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<typeof isViewport>;

View file

@ -6,6 +6,7 @@ import {UserInterface} from "./UserInterface";
import {ExSocketInterface} from "_Model/Websocket/ExSocketInterface";
import {PositionInterface} from "_Model/PositionInterface";
import {Identificable} from "_Model/Websocket/Identificable";
import {Zone} from "_Model/Zone";
export type ConnectCallback = (user: string, group: Group) => void;
export type DisconnectCallback = (user: string, group: Group) => void;
@ -56,7 +57,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<Zone>()
});
// Let's call update position to trigger the join / leave room
this.updatePosition(socket, userPosition);

85
back/src/Model/Zone.ts Normal file
View file

@ -0,0 +1,85 @@
import {UserInterface} from "./UserInterface";
import {PointInterface} from "_Model/Websocket/PointInterface";
import {PositionInterface} from "_Model/PositionInterface";
export type UserEntersCallback = (user: UserInterface) => void;
export type UserMovesCallback = (user: UserInterface, position: PointInterface) => void;
export type UserLeavesCallback = (user: UserInterface) => void;
export class Zone {
private players: Set<UserInterface> = new Set<UserInterface>();
private listeners: Set<UserInterface> = new Set<UserInterface>();
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);
}
}
}
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);
} else {
this.onUserMoves(user, position);
}
}
}
public move(user: UserInterface, position: PointInterface) {
for (const listener of this.listeners) {
if (listener !== user) {
this.onUserMoves(user,position);
}
}
}
public startListening(user: UserInterface): void {
for (const player of this.players) {
if (player !== user) {
this.onUserEnters(user);
}
}
this.listeners.add(user);
user.listenedZones.add(this);
}
public stopListening(user: UserInterface): void {
for (const player of this.players) {
if (player !== user) {
this.onUserLeaves(user);
}
}
this.listeners.delete(user);
user.listenedZones.delete(this);
}
}

View file

@ -0,0 +1,193 @@
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;
});
let user1 = {
id: "1",
position: {
x: 500,
y: 500,
moving: false,
direction: 'down'
},
listenedZones: new Set<Zone>(),
} as UserInterface;
let user2 = {
id: "2",
position: {
x: -9999,
y: -9999,
moving: false,
direction: 'down'
},
listenedZones: new Set<Zone>(),
} 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;
});
let user1 = {
id: "1",
position: {
x: 500,
y: 500,
moving: false,
direction: 'down'
},
listenedZones: new Set<Zone>(),
} as UserInterface;
let user2 = {
id: "2",
position: {
x: -9999,
y: -9999,
moving: false,
direction: 'down'
},
listenedZones: new Set<Zone>(),
} 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 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.
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;
});
})

View file

@ -34,10 +34,16 @@ scenarios:
- 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

View file

@ -5,7 +5,11 @@ 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));
context.vars.x = (0 + Math.round(Math.random() * 1472));
context.vars.y = (0 + Math.round(Math.random() * 1090));
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();
}

View file

@ -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: any
}
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<Connection> {
@ -160,12 +191,12 @@ export class Connection implements Connection {
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 {

View file

@ -677,7 +677,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(){