This commit is contained in:
David Négrier 2020-09-16 11:41:03 +02:00
commit 3a9196fb82
37 changed files with 1601 additions and 213 deletions

View file

@ -4,10 +4,10 @@ import * as http from "http";
import {MessageUserPosition, Point} from "../Model/Websocket/MessageUserPosition"; //TODO fix import by "_Model/.."
import {ExSocketInterface} from "../Model/Websocket/ExSocketInterface"; //TODO fix import by "_Model/.."
import Jwt, {JsonWebTokenError} from "jsonwebtoken";
import {SECRET_KEY, MINIMUM_DISTANCE, GROUP_RADIUS} from "../Enum/EnvironmentVariable"; //TODO fix import by "_Enum/..."
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,15 @@ import {isPointInterface, PointInterface} from "../Model/Websocket/PointInterfac
import {isWebRtcSignalMessageInterface} from "../Model/Websocket/WebRtcSignalMessage";
import {UserInGroupInterface} from "../Model/Websocket/UserInGroupInterface";
import {isItemEventMessageInterface} from "../Model/Websocket/ItemEventMessage";
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 +39,21 @@ enum SockerIoEvent {
GROUP_DELETE = "group-delete",
SET_PLAYER_DETAILS = "set-player-details",
ITEM_EVENT = 'item-event',
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 {
@ -61,10 +79,25 @@ export class IoSocketController {
// Authentication with token. it will be decoded and stored in the socket.
// Completely commented for now, as we do not use the "/login" route at all.
this.Io.use((socket: Socket, next) => {
console.log(socket.handshake.query.token);
if (!socket.handshake.query || !socket.handshake.query.token) {
console.error('An authentication error happened, a user tried to connect without a token.');
return next(new Error('Authentication error'));
}
if(socket.handshake.query.token === 'test'){
if (ALLOW_ARTILLERY) {
(socket as ExSocketInterface).token = socket.handshake.query.token;
(socket as ExSocketInterface).userId = uuid();
(socket as ExSocketInterface).isArtillery = true;
console.log((socket as ExSocketInterface).userId);
next();
return;
} else {
console.warn("In order to perform a load-testing test on this environment, you must set the ALLOW_ARTILLERY environment variable to 'true'");
next();
}
}
(socket as ExSocketInterface).isArtillery = false;
if(this.searchClientByToken(socket.handshake.query.token)){
console.error('An authentication error happened, a user tried to connect while its token is already connected.');
return next(new Error('Authentication error'));
@ -137,6 +170,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
@ -156,6 +194,7 @@ export class IoSocketController {
y: user y position on map
*/
socket.on(SockerIoEvent.JOIN_ROOM, (message: unknown, answerFn): void => {
console.log(SockerIoEvent.JOIN_ROOM, message);
try {
if (!isJoinRoomMessageInterface(message)) {
socket.emit(SockerIoEvent.MESSAGE_ERROR, {message: 'Invalid JOIN_ROOM message.'});
@ -176,28 +215,30 @@ 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);
const listOfItems: {[itemId: string]: unknown} = {};
for (const [itemId, item] of world.getItemsState().entries()) {
listOfItems[itemId] = item;
}
//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({
users: listOfUsers,
items: listOfItems
@ -208,28 +249,53 @@ export class IoSocketController {
}
});
socket.on(SockerIoEvent.USER_POSITION, (position: unknown): void => {
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);
@ -275,6 +341,7 @@ export class IoSocketController {
// Let's send the user id to the user
socket.on(SockerIoEvent.SET_PLAYER_DETAILS, (playerDetails: unknown, answerFn) => {
console.log(SockerIoEvent.SET_PLAYER_DETAILS, playerDetails);
if (!isSetPlayerDetailsMessage(playerDetails)) {
socket.emit(SockerIoEvent.MESSAGE_ERROR, {message: 'Invalid SET_PLAYER_DETAILS message.'});
console.warn('Invalid SET_PLAYER_DETAILS message received: ', playerDetails);
@ -283,7 +350,34 @@ export class IoSocketController {
const Client = (socket as ExSocketInterface);
Client.name = playerDetails.name;
Client.characterLayers = playerDetails.characterLayers;
answerFn(Client.userId);
// Artillery fails when receiving an acknowledgement that is not a JSON object
if (!Client.isArtillery) {
answerFn(Client.userId);
}
});
socket.on(SockerIoEvent.SET_SILENT, (silent: unknown) => {
console.log(SockerIoEvent.SET_SILENT, silent);
if (typeof silent !== "boolean") {
socket.emit(SockerIoEvent.MESSAGE_ERROR, {message: 'Invalid SET_SILENT message.'});
console.warn('Invalid SET_SILENT message received: ', silent);
return;
}
try {
const Client = (socket as ExSocketInterface);
// update position in the world
const world = this.Worlds.get(Client.roomId);
if (!world) {
console.error("In SET_SILENT, could not find world with id '", Client.roomId, "'");
return;
}
world.setSilent(Client, silent);
} catch (e) {
console.error('An error occurred on "SET_SILENT"');
console.error(e);
}
});
socket.on(SockerIoEvent.ITEM_EVENT, (itemEvent: unknown) => {
@ -359,8 +453,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) {
@ -396,6 +488,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);
}

View file

@ -2,10 +2,12 @@ const SECRET_KEY = process.env.SECRET_KEY || "THECODINGMACHINE_SECRET_KEY";
const URL_ROOM_STARTED = "/Floor0/floor0.json";
const MINIMUM_DISTANCE = process.env.MINIMUM_DISTANCE ? Number(process.env.MINIMUM_DISTANCE) : 64;
const GROUP_RADIUS = process.env.GROUP_RADIUS ? Number(process.env.GROUP_RADIUS) : 48;
const ALLOW_ARTILLERY = process.env.ALLOW_ARTILLERY ? process.env.ALLOW_ARTILLERY == 'true' : false;
export {
SECRET_KEY,
URL_ROOM_STARTED,
MINIMUM_DISTANCE,
GROUP_RADIUS
GROUP_RADIUS,
ALLOW_ARTILLERY
}

View file

@ -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<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));
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<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,8 +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
}
position: PointInterface,
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,4 +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;
}

View file

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

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,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;
@ -29,12 +32,17 @@ export class World {
private itemsState: Map<number, unknown> = new Map<number, unknown>();
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<string, UserInterface>();
this.groups = new Set<Group>();
@ -44,6 +52,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[] {
@ -57,7 +67,9 @@ export class World {
public join(socket : Identificable, userPosition: PointInterface): void {
this.users.set(socket.userId, {
id: socket.userId,
position: userPosition
position: userPosition,
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);
@ -72,6 +84,10 @@ export class World {
this.leaveGroup(userObj);
}
this.users.delete(user.userId);
if (userObj !== undefined) {
this.positionNotifier.leave(userObj);
}
}
public isEmpty(): boolean {
@ -84,8 +100,14 @@ export class World {
return;
}
this.positionNotifier.updatePosition(user, userPosition);
user.position = userPosition;
if (user.silent) {
return;
}
if (typeof user.group === 'undefined') {
// If the user is not part of a group:
// should he join a group?
@ -120,6 +142,26 @@ export class World {
}
}
setSilent(socket: Identificable, silent: boolean) {
const user = this.users.get(socket.userId);
if(typeof user === 'undefined') {
console.warn('In setSilent, could not find user with ID "'+socket.userId+'" in world.');
return;
}
if (user.silent === silent) {
return;
}
user.silent = silent;
if (silent && user.group !== undefined) {
this.leaveGroup(user);
}
if (!silent) {
// If we are back to life, let's trigger a position update to see if we can join some group.
this.updatePosition(socket, user.position);
}
}
/**
* Makes a user leave a group and closes and destroy the group if the group contains only one remaining person.
*
@ -147,6 +189,7 @@ export class World {
* Looks for the closest user that is:
* - close enough (distance <= minDistance)
* - not in a group
* - not silent
* OR
* - close enough to a group (distance <= groupRadius)
*/
@ -162,6 +205,9 @@ export class World {
if(currentUser === user) {
return;
}
if (currentUser.silent) {
return;
}
const distance = World.computeDistance(user, currentUser); // compute distance between peers.
@ -297,4 +343,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);
}
}

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

@ -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<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, 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<UserInterface> {
return this.players;
}
}

View file

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

View file

@ -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));

View file

@ -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. */

69
benchmark/README.md Normal file
View file

@ -0,0 +1,69 @@
# Load testing
Load testing is performed with Artillery.
Install:
```bash
cd benchmark
npm install
```
Running the tests (on one core):
```bash
cd benchmark
npm run start
```
You can adapt the `socketio-load-test.yaml` file to increase/decrease load.
Default settings are:
```yaml
phases:
- duration: 20
arrivalRate: 2
```
which means: during 20 seconds, 2 users will be added every second (peaking at 40 simultaneous users).
Important: don't go above 40 simultaneous users for Artillery, otherwise, it is Artillery that will fail to run the tests properly.
To know, simply run "top". The "node" process for Artillery should never reach 100%.
Reports are generated in `artillery_output.html`.
# Multicore tests
You will want to test with Artillery running on multiple cores.
You can use
```bash
./artillery_multi_core.sh
```
This will trigger 4 Artillery instances in parallel.
Beware, the report generated is generated for only one instance.
# How to test, what to track?
While testing, you can check:
- CPU load of WorkAdventure API node process (it should not reach 100%)
- Get metrics at the end of the run: `http://api.workadventure.localhost/metrics`
In particular, look for:
```
# HELP nodejs_eventloop_lag_max_seconds The maximum recorded event loop delay.
# TYPE nodejs_eventloop_lag_max_seconds gauge
nodejs_eventloop_lag_max_seconds 23.991418879
```
This is the maximum time it took Node to process an event (you need to restart node after each test to reset this counter)
- Generate a profiling using "node --prof" by switching the command in docker-compose.yaml:
```
#command: yarn dev
command: yarn run profile
```
Read https://nodejs.org/en/docs/guides/simple-profiling/ on how to generate a profile.

View file

@ -0,0 +1,17 @@
#!/bin/bash
npm run start &
pid1=$!
npm run start:nooutput &
pid2=$!
npm run start:nooutput &
pid3=$!
npm run start:nooutput &
pid4=$!
wait $pid1
wait $pid2
wait $pid3
wait $pid4

27
benchmark/package.json Normal file
View file

@ -0,0 +1,27 @@
{
"name": "workadventure-artillery",
"version": "1.0.0",
"description": "Load testing for WorkAdventure",
"scripts": {
"start": "artillery run socketio-load-test.yaml -o artillery_output.json && artillery report --output artillery_output.html artillery_output.json",
"start:nooutput": "artillery run socketio-load-test.yaml"
},
"contributors": [
{
"name": "Grégoire Parant",
"email": "g.parant@thecodingmachine.com"
},
{
"name": "David Négrier",
"email": "d.negrier@thecodingmachine.com"
},
{
"name": "Arthmaël Poly",
"email": "a.poly@thecodingmachine.com"
}
],
"license": "SEE LICENSE IN LICENSE.txt",
"dependencies": {
"artillery": "^1.6.1"
}
}

View file

@ -0,0 +1,54 @@
config:
target: "http://api.workadventure.localhost/"
socketio:
transports: ["websocket"]
query:
token: "test"
phases:
- duration: 20
arrivalRate: 2
processor: "./socketioLoadTest.js"
scenarios:
- name: "Connects and moves player for 20 seconds"
weight: 90
engine: "socketio"
flow:
- emit:
channel: "set-player-details"
data:
name: 'TEST'
characterLayers: ['male3']
- think: 1
- emit:
channel: "join-room"
data:
roomId: 'global__api.workadventure.localhost/map/files/Floor0/floor0'
position:
x: 783
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:
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

@ -0,0 +1,20 @@
'use strict';
module.exports = {
setYRandom
};
function setYRandom(context, events, done) {
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();
}

View file

@ -25,7 +25,11 @@
},
"ports": [80],
"env": {
"API_URL": "api."+url
"API_URL": "api."+url,
"JITSI_URL": "meet.jit.si",
"TURN_SERVER": "turn:coturn.workadventu.re:443,turns:coturn.workadventu.re:443",
"TURN_USER": "workadventure",
"TURN_PASSWORD": "WorkAdventure123"
}
},
"maps": {

View file

@ -22,10 +22,14 @@ services:
image: thecodingmachine/nodejs:14
environment:
DEBUG_MODE: "$DEBUG_MODE"
JITSI_URL: $JITSI_URL
HOST: "0.0.0.0"
NODE_ENV: development
API_URL: api.workadventure.localhost
STARTUP_COMMAND_1: yarn install
TURN_SERVER: "turn:coturn.workadventu.re:443,turns:coturn.workadventu.re:443"
TURN_USER: workadventure
TURN_PASSWORD: WorkAdventure123
command: yarn run start
volumes:
- ./front:/usr/src/app
@ -68,6 +72,7 @@ services:
environment:
STARTUP_COMMAND_1: yarn install
SECRET_KEY: yourSecretKey
ALLOW_ARTILLERY: "true"
volumes:
- ./back:/usr/src/app
labels:

View file

@ -256,6 +256,10 @@ body {
.sidebar > div {
max-height: 21%;
}
.sidebar > div:hover {
max-height: 25%;
}
}
@media (max-aspect-ratio: 1/1) {
.main-container {
@ -274,6 +278,10 @@ body {
.sidebar > div {
max-width: 21%;
}
.sidebar > div:hover {
max-width: 25%;
}
}
.game {
@ -291,12 +299,11 @@ body {
flex-basis: 100%;
}*/
.cowebsite iframe {
.cowebsite > iframe {
width: 100%;
height: 100%;
}
.game-overlay {
display: none;
position: absolute;
@ -324,9 +331,16 @@ body {
.main-section > div {
margin: 2%;
flex-basis: 96%;
transition: margin-left 0.2s, margin-right 0.2s, margin-bottom 0.2s, margin-top 0.2s, flex-basis 0.2s;
cursor: pointer;
/*flex-shrink: 2;*/
}
.main-section > div:hover {
margin: 0%;
flex-basis: 100%;
}
.sidebar {
flex: 0 0 25%;
display: flex;
@ -334,6 +348,12 @@ body {
.sidebar > div {
margin: 2%;
transition: margin-left 0.2s, margin-right 0.2s, margin-bottom 0.2s, margin-top 0.2s, max-height 0.2s, max-width 0.2s;
cursor: pointer;
}
.sidebar > div:hover {
margin: 0%;
}
/* Let's make sure videos are vertically centered if they need to be cropped */
@ -354,11 +374,16 @@ body {
padding: 1%;
}
.chat-mode div {
.chat-mode > div {
margin: 1%;
max-height: 96%;
transition: margin-left 0.2s, margin-right 0.2s, margin-bottom 0.2s, margin-top 0.2s;
cursor: pointer;
}
.chat-mode > div:hover {
margin: 0%;
}
.chat-mode.one-col > div {
flex-basis: 98%;
}

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,9 @@ enum EventMessage{
ITEM_EVENT = 'item-event',
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 +98,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 interface ItemEventMessageInterface {
itemId: number,
event: string,
@ -123,6 +143,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> {
@ -163,21 +195,33 @@ export class Connection implements Connection {
}
public joinARoom(roomId: string, startX: number, startY: number, direction: string, moving: boolean): Promise<RoomJoinedMessageInterface> {
public joinARoom(roomId: string, startX: number, startY: number, direction: string, moving: boolean, viewport: ViewportInterface): Promise<RoomJoinedMessageInterface> {
const promise = new Promise<RoomJoinedMessageInterface>((resolve, reject) => {
this.socket.emit(EventMessage.JOIN_ROOM, { roomId, position: {x: startX, y: startY, direction, moving }}, (roomJoinedMessage: RoomJoinedMessageInterface) => {
resolve(roomJoinedMessage);
});
this.socket.emit(EventMessage.JOIN_ROOM, {
roomId,
position: {x: startX, y: startY, direction, moving },
viewport,
}, (roomJoinedMessage: RoomJoinedMessageInterface) => {
resolve(roomJoinedMessage);
});
})
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 {

View file

@ -1,5 +1,9 @@
const DEBUG_MODE: boolean = process.env.DEBUG_MODE == "true";
const API_URL = (typeof(window) !== 'undefined' ? window.location.protocol : 'http:') + '//' + (process.env.API_URL || "api.workadventure.localhost");
const TURN_SERVER: string = process.env.TURN_SERVER || "turn:numb.viagenie.ca";
const TURN_USER: string = process.env.TURN_USER || 'g.parant@thecodingmachine.com';
const TURN_PASSWORD: string = process.env.TURN_PASSWORD || 'itcugcOHxle9Acqi$';
const JITSI_URL : string|undefined = (process.env.JITSI_URL === '') ? undefined : process.env.JITSI_URL;
const RESOLUTION = 3;
const ZOOM_LEVEL = 1/*3/4*/;
const POSITION_DELAY = 200; // Wait 200ms between sending position events
@ -11,5 +15,9 @@ export {
RESOLUTION,
ZOOM_LEVEL,
POSITION_DELAY,
MAX_EXTRAPOLATION_TIME
MAX_EXTRAPOLATION_TIME,
TURN_SERVER,
TURN_USER,
TURN_PASSWORD,
JITSI_URL
}

View file

@ -0,0 +1,97 @@
import {ITiledMap} from "../Map/ITiledMap";
export type PropertyChangeCallback = (newValue: string | number | boolean | undefined, oldValue: string | number | boolean | undefined) => void;
/**
* A wrapper around a ITiledMap interface to provide additional capabilities.
* It is used to handle layer properties.
*/
export class GameMap {
private key: number|undefined;
private lastProperties = new Map<string, string|boolean|number>();
private callbacks = new Map<string, Array<PropertyChangeCallback>>();
public constructor(private map: ITiledMap) {
}
/**
* Sets the position of the current player (in pixels)
* This will trigger events if properties are changing.
*/
public setPosition(x: number, y: number) {
const xMap = Math.floor(x / this.map.tilewidth);
const yMap = Math.floor(y / this.map.tileheight);
const key = xMap + yMap * this.map.width;
if (key === this.key) {
return;
}
this.key = key;
const newProps = this.getProperties(key);
const oldProps = this.lastProperties;
// Let's compare the 2 maps:
// First new properties vs oldProperties
for (const [newPropName, newPropValue] of newProps.entries()) {
const oldPropValue = oldProps.get(newPropName);
if (oldPropValue !== newPropValue) {
this.trigger(newPropName, oldPropValue, newPropValue);
}
}
for (const [oldPropName, oldPropValue] of oldProps.entries()) {
if (!newProps.has(oldPropName)) {
// We found a property that disappeared
this.trigger(oldPropName, oldPropValue, undefined);
}
}
this.lastProperties = newProps;
}
private getProperties(key: number): Map<string, string|boolean|number> {
const properties = new Map<string, string|boolean|number>();
for (const layer of this.map.layers) {
if (layer.type !== 'tilelayer') {
continue;
}
const tiles = layer.data as number[];
if (tiles[key] == 0) {
continue;
}
// There is a tile in this layer, let's embed the properties
if (layer.properties !== undefined) {
for (const layerProperty of layer.properties) {
if (layerProperty.value === undefined) {
continue;
}
properties.set(layerProperty.name, layerProperty.value);
}
}
}
return properties;
}
private trigger(propName: string, oldValue: string | number | boolean | undefined, newValue: string | number | boolean | undefined) {
const callbacksArray = this.callbacks.get(propName);
if (callbacksArray !== undefined) {
for (const callback of callbacksArray) {
callback(newValue, oldValue);
}
}
}
/**
* Registers a callback called when the user moves to a tile where the property propName is different from the last tile the user was on.
*/
public onPropertyChange(propName: string, callback: PropertyChangeCallback) {
let callbacksArray = this.callbacks.get(propName);
if (callbacksArray === undefined) {
callbacksArray = new Array<PropertyChangeCallback>();
this.callbacks.set(propName, callbacksArray);
}
callbacksArray.push(callback);
}
}

View file

@ -10,7 +10,7 @@ import {
RoomJoinedMessageInterface
} from "../../Connection";
import {CurrentGamerInterface, hasMovedEventName, Player} from "../Player/Player";
import {DEBUG_MODE, POSITION_DELAY, RESOLUTION, ZOOM_LEVEL} from "../../Enum/EnvironmentVariable";
import {DEBUG_MODE, JITSI_URL, POSITION_DELAY, RESOLUTION, ZOOM_LEVEL} from "../../Enum/EnvironmentVariable";
import {
ITiledMap,
ITiledMapLayer,
@ -33,6 +33,9 @@ import Sprite = Phaser.GameObjects.Sprite;
import CanvasTexture = Phaser.Textures.CanvasTexture;
import GameObject = Phaser.GameObjects.GameObject;
import FILE_LOAD_ERROR = Phaser.Loader.Events.FILE_LOAD_ERROR;
import {GameMap} from "./GameMap";
import {CoWebsiteManager} from "../../WebRtc/CoWebsiteManager";
import {mediaManager} from "../../WebRtc/MediaManager";
import {FourOFourSceneName} from "../Reconnecting/FourOFourScene";
import {ItemFactoryInterface} from "../Items/ItemFactoryInterface";
import {ActionableItem} from "../Items/ActionableItem";
@ -122,7 +125,8 @@ 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;
private actionableItems: Map<number, ActionableItem> = new Map<number, ActionableItem>();
// The item that can be selected by pressing the space key.
private outlinedItem: ActionableItem|null = null;
@ -247,7 +251,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);
})
connection.onActionableEvent((message => {
@ -398,6 +402,7 @@ export class GameScene extends Phaser.Scene implements CenterListener {
create(): void {
//initalise map
this.Map = this.add.tilemap(this.MapKey);
this.gameMap = new GameMap(this.mapFile);
const mapDirUrl = this.MapUrlFile.substr(0, this.MapUrlFile.lastIndexOf('/'));
this.mapFile.tilesets.forEach((tileset: ITiledTileSet) => {
this.Terrains.push(this.Map.addTilesetImage(tileset.name, `${mapDirUrl}/${tileset.image}`, tileset.tilewidth, tileset.tileheight, tileset.margin, tileset.spacing/*, tileset.firstgid*/));
@ -521,12 +526,14 @@ export class GameScene extends Phaser.Scene implements CenterListener {
this.presentationModeSprite.setOrigin(0, 1);
this.presentationModeSprite.setInteractive();
this.presentationModeSprite.setVisible(false);
this.presentationModeSprite.setDepth(99999);
this.presentationModeSprite.on('pointerup', this.switchLayoutMode.bind(this));
this.chatModeSprite = this.add.sprite(36, this.game.renderer.height - 2, 'layout_modes', 3);
this.chatModeSprite.setScrollFactor(0, 0);
this.chatModeSprite.setOrigin(0, 1);
this.chatModeSprite.setInteractive();
this.chatModeSprite.setVisible(false);
this.chatModeSprite.setDepth(99999);
this.chatModeSprite.on('pointerup', this.switchLayoutMode.bind(this));
// FIXME: change this to use the UserInputManager class for input
@ -534,12 +541,58 @@ 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
layoutManager.setListener(this);
this.gameMap.onPropertyChange('openWebsite', (newValue, oldValue) => {
if (newValue === undefined) {
CoWebsiteManager.closeCoWebsite();
} else {
CoWebsiteManager.loadCoWebsite(newValue as string);
}
});
let jitsiApi: any; // eslint-disable-line @typescript-eslint/no-explicit-any
this.gameMap.onPropertyChange('jitsiRoom', (newValue, oldValue) => {
if (newValue === undefined) {
this.connection.setSilent(false);
jitsiApi?.dispose();
CoWebsiteManager.closeCoWebsite();
mediaManager.showGameOverlay();
} else {
CoWebsiteManager.insertCoWebsite((cowebsiteDiv => {
const domain = JITSI_URL;
const options = {
roomName: this.instance + "-" + newValue,
width: "100%",
height: "100%",
parentNode: cowebsiteDiv,
configOverwrite: {
prejoinPageEnabled: false
},
interfaceConfigOverwrite: {
SHOW_CHROME_EXTENSION_BANNER: false,
MOBILE_APP_PROMO: false
}
};
jitsiApi = new (window as any).JitsiMeetExternalAPI(domain, options); // eslint-disable-line @typescript-eslint/no-explicit-any
jitsiApi.executeCommand('displayName', gameManager.getPlayerName());
}));
this.connection.setSilent(true);
mediaManager.hideGameOverlay();
}
})
this.gameMap.onPropertyChange('silent', (newValue, oldValue) => {
if (newValue === undefined || newValue === false || newValue === '') {
this.connection.setSilent(false);
} else {
this.connection.setSilent(true);
}
});
}
private switchLayoutMode(): void {
@ -713,7 +766,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((roomJoinedMessage: RoomJoinedMessageInterface) => {
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((roomJoinedMessage: RoomJoinedMessageInterface) => {
this.initUsersPosition(roomJoinedMessage.users);
this.connectionAnswerPromiseResolve(roomJoinedMessage);
});
@ -721,6 +784,9 @@ export class GameScene extends Phaser.Scene implements CenterListener {
//listen event to share position of user
this.CurrentPlayer.on(hasMovedEventName, this.pushPlayerPosition.bind(this))
this.CurrentPlayer.on(hasMovedEventName, this.outlineItem.bind(this))
this.CurrentPlayer.on(hasMovedEventName, (event: HasMovedEvent) => {
this.gameMap.setPosition(event.x, event.y);
})
});
}
@ -796,7 +862,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(){
@ -860,7 +932,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
});
@ -1058,6 +1130,19 @@ export class GameScene extends Phaser.Scene implements CenterListener {
this.connection.emitActionableEvent(itemId, eventName, state, parameters);
}
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);

View file

@ -266,6 +266,9 @@ export class EnableCameraScene extends Phaser.Scene {
this.soundMeter.stop();
window.removeEventListener('resize', this.repositionCallback);
mediaManager.stopCamera();
mediaManager.stopMicrophone();
// Do we have a start URL in the address bar? If so, let's redirect to this address
const instanceAndMapUrl = this.findMapUrl();
if (instanceAndMapUrl !== null) {

View file

@ -14,7 +14,24 @@ export class CoWebsiteManager {
iframe.id = 'cowebsite-iframe';
iframe.src = url;
cowebsiteDiv.appendChild(iframe);
//iframe.onload = () => {
// onload can be long to trigger. Maybe we should display the website, whatever happens, after 1 second?
CoWebsiteManager.fire();
//}
}
/**
* Just like loadCoWebsite but the div can be filled by the user.
*/
public static insertCoWebsite(callback: (cowebsite: HTMLDivElement) => void): void {
const cowebsiteDiv = HtmlUtils.getElementByIdOrFail<HTMLDivElement>("cowebsite");
cowebsiteDiv.innerHTML = '';
callback(cowebsiteDiv);
//iframe.onload = () => {
// onload can be long to trigger. Maybe we should display the website, whatever happens, after 1 second?
CoWebsiteManager.fire();
//}
}
public static closeCoWebsite(): void {
@ -24,8 +41,8 @@ export class CoWebsiteManager {
}
public static getGameSize(): {width: number, height: number} {
const iframe = document.getElementById('cowebsite-iframe');
if (iframe === null) {
const hasChildren = HtmlUtils.getElementByIdOrFail<HTMLDivElement>("cowebsite").children.length > 0;
if (hasChildren === false) {
return {
width: window.innerWidth,
height: window.innerHeight

View file

@ -42,6 +42,14 @@ class LayoutManager {
div.innerHTML = html;
div.id = "user-"+userId;
div.className = "media-container"
div.onclick = () => {
const parentId = div.parentElement?.id;
if (parentId === 'sidebar' || parentId === 'chat-mode') {
this.focusOn(userId);
} else {
this.removeFocusOn(userId);
}
}
if (importance === DivImportance.Important) {
this.importantDivs.set(userId, div);
@ -76,6 +84,48 @@ class LayoutManager {
}
}
/**
* Put the screen in presentation mode and move elem in presentation mode (and all other videos in normal mode)
*/
private focusOn(userId: string): void {
const focusedDiv = this.getDivByUserId(userId);
for (const [importantUserId, importantDiv] of this.importantDivs.entries()) {
//this.positionDiv(importantDiv, DivImportance.Normal);
this.importantDivs.delete(importantUserId);
this.normalDivs.set(importantUserId, importantDiv);
}
this.normalDivs.delete(userId);
this.importantDivs.set(userId, focusedDiv);
//this.positionDiv(focusedDiv, DivImportance.Important);
this.switchLayoutMode(LayoutMode.Presentation);
}
/**
* Removes userId from presentation mode
*/
private removeFocusOn(userId: string): void {
const importantDiv = this.importantDivs.get(userId);
if (importantDiv === undefined) {
throw new Error('Div with user id "'+userId+'" is not in important mode');
}
this.normalDivs.set(userId, importantDiv);
this.importantDivs.delete(userId);
this.positionDiv(importantDiv, DivImportance.Normal);
}
private getDivByUserId(userId: string): HTMLDivElement {
let div = this.importantDivs.get(userId);
if (div !== undefined) {
return div;
}
div = this.normalDivs.get(userId);
if (div !== undefined) {
return div;
}
throw new Error('Could not find media with user id '+userId);
}
/**
* Removes the DIV matching userId.
*/
@ -154,6 +204,7 @@ class LayoutManager {
* Tries to find the biggest available box of remaining space (this is a space where we can center the character)
*/
public findBiggestAvailableArray(): {xStart: number, yStart: number, xEnd: number, yEnd: number} {
const game = HtmlUtils.getElementByIdOrFail<HTMLDivElement>('game');
if (this.mode === LayoutMode.VideoChat) {
const children = document.querySelectorAll<HTMLDivElement>('div.chat-mode > div');
const htmlChildren = Array.from(children.values());
@ -163,27 +214,27 @@ class LayoutManager {
return {
xStart: 0,
yStart: 0,
xEnd: window.innerWidth,
yEnd: window.innerHeight
xEnd: game.offsetWidth,
yEnd: game.offsetHeight
}
}
const lastDiv = htmlChildren[htmlChildren.length - 1];
// Compute area between top right of the last div and bottom right of window
const area1 = (window.innerWidth - (lastDiv.offsetLeft + lastDiv.offsetWidth))
* (window.innerHeight - lastDiv.offsetTop);
const area1 = (game.offsetWidth - (lastDiv.offsetLeft + lastDiv.offsetWidth))
* (game.offsetHeight - lastDiv.offsetTop);
// Compute area between bottom of last div and bottom of the screen on whole width
const area2 = window.innerWidth
* (window.innerHeight - (lastDiv.offsetTop + lastDiv.offsetHeight));
const area2 = game.offsetWidth
* (game.offsetHeight - (lastDiv.offsetTop + lastDiv.offsetHeight));
if (area1 < 0 && area2 < 0) {
// If screen is full, let's not attempt something foolish and simply center character in the middle.
return {
xStart: 0,
yStart: 0,
xEnd: window.innerWidth,
yEnd: window.innerHeight
xEnd: game.offsetWidth,
yEnd: game.offsetHeight
}
}
if (area1 <= area2) {
@ -191,16 +242,16 @@ class LayoutManager {
return {
xStart: 0,
yStart: lastDiv.offsetTop + lastDiv.offsetHeight,
xEnd: window.innerWidth,
yEnd: window.innerHeight
xEnd: game.offsetWidth,
yEnd: game.offsetHeight
}
} else {
console.log('lastDiv', lastDiv.offsetTop);
return {
xStart: lastDiv.offsetLeft + lastDiv.offsetWidth,
yStart: lastDiv.offsetTop,
xEnd: window.innerWidth,
yEnd: window.innerHeight
xEnd: game.offsetWidth,
yEnd: game.offsetHeight
}
}
} else {
@ -213,15 +264,15 @@ class LayoutManager {
return {
xStart: 0,
yStart: 0,
xEnd: window.innerWidth,
yEnd: window.innerHeight
xEnd: game.offsetWidth,
yEnd: game.offsetHeight
}
}
// At this point, we know we have at least one element in the main section.
const lastPresentationDiv = mainSectionChildren[mainSectionChildren.length-1];
const presentationArea = (window.innerHeight - (lastPresentationDiv.offsetTop + lastPresentationDiv.offsetHeight))
const presentationArea = (game.offsetHeight - (lastPresentationDiv.offsetTop + lastPresentationDiv.offsetHeight))
* (lastPresentationDiv.offsetLeft + lastPresentationDiv.offsetWidth);
let leftSideBar: number;
@ -234,22 +285,22 @@ class LayoutManager {
leftSideBar = lastSideBarChildren.offsetLeft;
bottomSideBar = lastSideBarChildren.offsetTop + lastSideBarChildren.offsetHeight;
}
const sideBarArea = (window.innerWidth - leftSideBar)
* (window.innerHeight - bottomSideBar);
const sideBarArea = (game.offsetWidth - leftSideBar)
* (game.offsetHeight - bottomSideBar);
if (presentationArea <= sideBarArea) {
return {
xStart: leftSideBar,
yStart: bottomSideBar,
xEnd: window.innerWidth,
yEnd: window.innerHeight
xEnd: game.offsetWidth,
yEnd: game.offsetHeight
}
} else {
return {
xStart: 0,
yStart: lastPresentationDiv.offsetTop + lastPresentationDiv.offsetHeight,
xEnd: /*lastPresentationDiv.offsetLeft + lastPresentationDiv.offsetWidth*/ window.innerWidth , // To avoid flickering when a chat start, we center on the center of the screen, not the center of the main content area
yEnd: window.innerHeight
xEnd: /*lastPresentationDiv.offsetLeft + lastPresentationDiv.offsetWidth*/ game.offsetWidth , // To avoid flickering when a chat start, we center on the center of the screen, not the center of the main content area
yEnd: game.offsetHeight
}
}
}

View file

@ -7,9 +7,9 @@ const videoConstraint: boolean|MediaTrackConstraints = {
facingMode: "user"
};
type UpdatedLocalStreamCallback = (media: MediaStream) => void;
type StartScreenSharingCallback = (media: MediaStream) => void;
type StopScreenSharingCallback = (media: MediaStream) => void;
export type UpdatedLocalStreamCallback = (media: MediaStream|null) => void;
export type StartScreenSharingCallback = (media: MediaStream) => void;
export type StopScreenSharingCallback = (media: MediaStream) => void;
// TODO: Split MediaManager in 2 classes: MediaManagerUI (in charge of HTML) and MediaManager (singleton in charge of the camera only)
// TODO: verify that microphone event listeners are not triggered plenty of time NOW (since MediaManager is created many times!!!!)
@ -109,7 +109,7 @@ export class MediaManager {
this.updatedLocalStreamCallBacks.delete(callback);
}
private triggerUpdatedLocalStreamCallbacks(stream: MediaStream): void {
private triggerUpdatedLocalStreamCallbacks(stream: MediaStream|null): void {
for (const callback of this.updatedLocalStreamCallBacks) {
callback(stream);
}
@ -127,11 +127,16 @@ export class MediaManager {
}
}
showGameOverlay(){
public showGameOverlay(){
const gameOverlay = this.getElementByIdOrFail('game-overlay');
gameOverlay.classList.add('active');
}
public hideGameOverlay(){
const gameOverlay = this.getElementByIdOrFail('game-overlay');
gameOverlay.classList.remove('active');
}
private enableCamera() {
this.cinemaClose.style.display = "none";
this.cinemaBtn.classList.remove("disabled");
@ -142,20 +147,20 @@ export class MediaManager {
});
}
private disableCamera() {
private async disableCamera() {
this.cinemaClose.style.display = "block";
this.cinema.style.display = "none";
this.cinemaBtn.classList.add("disabled");
this.constraintsMedia.video = false;
this.myCamVideo.srcObject = null;
if (this.localStream) {
this.localStream.getVideoTracks().forEach((MediaStreamTrack: MediaStreamTrack) => {
MediaStreamTrack.stop();
});
}
this.getCamera().then((stream) => {
this.stopCamera();
if (this.constraintsMedia.audio !== false) {
const stream = await this.getCamera();
this.triggerUpdatedLocalStreamCallbacks(stream);
});
} else {
this.triggerUpdatedLocalStreamCallbacks(null);
}
}
private enableMicrophone() {
@ -163,24 +168,25 @@ export class MediaManager {
this.microphone.style.display = "block";
this.microphoneBtn.classList.remove("disabled");
this.constraintsMedia.audio = true;
this.getCamera().then((stream) => {
this.triggerUpdatedLocalStreamCallbacks(stream);
});
}
private disableMicrophone() {
private async disableMicrophone() {
this.microphoneClose.style.display = "block";
this.microphone.style.display = "none";
this.microphoneBtn.classList.add("disabled");
this.constraintsMedia.audio = false;
if(this.localStream) {
this.localStream.getAudioTracks().forEach((MediaStreamTrack: MediaStreamTrack) => {
MediaStreamTrack.stop();
});
}
this.getCamera().then((stream) => {
this.stopMicrophone();
if (this.constraintsMedia.video !== false) {
const stream = await this.getCamera();
this.triggerUpdatedLocalStreamCallbacks(stream);
});
} else {
this.triggerUpdatedLocalStreamCallbacks(null);
}
}
private enableScreenSharing() {
@ -287,6 +293,28 @@ export class MediaManager {
}
}
/**
* Stops the camera from filming
*/
public stopCamera(): void {
if (this.localStream) {
for (const track of this.localStream.getVideoTracks()) {
track.stop();
}
}
}
/**
* Stops the microphone from listening
*/
public stopMicrophone(): void {
if (this.localStream) {
for (const track of this.localStream.getAudioTracks()) {
track.stop();
}
}
}
setCamera(id: string): Promise<MediaStream> {
let video = this.constraintsMedia.video;
if (typeof(video) === 'boolean' || video === undefined) {

View file

@ -1,6 +1,7 @@
import * as SimplePeerNamespace from "simple-peer";
import {mediaManager} from "./MediaManager";
import {Connection} from "../Connection";
import {TURN_SERVER, TURN_USER, TURN_PASSWORD} from "../Enum/EnvironmentVariable";
const Peer: SimplePeerNamespace.SimplePeer = require('simple-peer');
@ -23,9 +24,9 @@ export class ScreenSharingPeer extends Peer {
urls: 'stun:stun.l.google.com:19302'
},
{
urls: 'turn:numb.viagenie.ca',
username: 'g.parant@thecodingmachine.com',
credential: 'itcugcOHxle9Acqi$'
urls: TURN_SERVER.split(','),
username: TURN_USER,
credential: TURN_PASSWORD
},
]
}
@ -70,7 +71,7 @@ export class ScreenSharingPeer extends Peer {
}
private sendWebrtcScreenSharingSignal(data: unknown) {
console.log("sendWebrtcScreenSharingSignal", data);
//console.log("sendWebrtcScreenSharingSignal", data);
try {
this.connection.sendWebrtcScreenSharingSignal(data, this.userId);
}catch (e) {
@ -82,8 +83,8 @@ export class ScreenSharingPeer extends Peer {
* Sends received stream to screen.
*/
private stream(stream?: MediaStream) {
console.log(`ScreenSharingPeer::stream => ${this.userId}`, stream);
console.log(`stream => ${this.userId} => `, stream);
//console.log(`ScreenSharingPeer::stream => ${this.userId}`, stream);
//console.log(`stream => ${this.userId} => `, stream);
if(!stream){
mediaManager.removeActiveScreenSharingVideo(this.userId);
this.isReceivingStream = false;

View file

@ -4,7 +4,12 @@ import {
WebRtcSignalReceivedMessageInterface,
WebRtcStartMessageInterface
} from "../Connection";
import { mediaManager } from "./MediaManager";
import {
mediaManager,
StartScreenSharingCallback,
StopScreenSharingCallback,
UpdatedLocalStreamCallback
} from "./MediaManager";
import * as SimplePeerNamespace from "simple-peer";
import {ScreenSharingPeer} from "./ScreenSharingPeer";
import {VideoPeer} from "./VideoPeer";
@ -32,9 +37,9 @@ export class SimplePeer {
private PeerScreenSharingConnectionArray: Map<string, ScreenSharingPeer> = new Map<string, ScreenSharingPeer>();
private PeerConnectionArray: Map<string, VideoPeer> = new Map<string, VideoPeer>();
private readonly sendLocalVideoStreamCallback: (media: MediaStream) => void;
private readonly sendLocalScreenSharingStreamCallback: (media: MediaStream) => void;
private readonly stopLocalScreenSharingStreamCallback: (media: MediaStream) => void;
private readonly sendLocalVideoStreamCallback: UpdatedLocalStreamCallback;
private readonly sendLocalScreenSharingStreamCallback: StartScreenSharingCallback;
private readonly stopLocalScreenSharingStreamCallback: StopScreenSharingCallback;
private readonly peerConnectionListeners: Array<PeerConnectionListener> = new Array<PeerConnectionListener>();
constructor(Connection: Connection, WebRtcRoomId: string = "test-webrtc") {
@ -326,9 +331,9 @@ export class SimplePeer {
}
public sendLocalVideoStream(){
this.Users.forEach((user: UserSimplePeerInterface) => {
for (const user of this.Users) {
this.pushVideoToRemoteUser(user.userId);
})
}
}
/**

View file

@ -1,6 +1,7 @@
import * as SimplePeerNamespace from "simple-peer";
import {mediaManager} from "./MediaManager";
import {Connection} from "../Connection";
import {TURN_PASSWORD, TURN_SERVER, TURN_USER} from "../Enum/EnvironmentVariable";
const Peer: SimplePeerNamespace.SimplePeer = require('simple-peer');
@ -18,14 +19,31 @@ export class VideoPeer extends Peer {
urls: 'stun:stun.l.google.com:19302'
},
{
urls: 'turn:numb.viagenie.ca',
username: 'g.parant@thecodingmachine.com',
credential: 'itcugcOHxle9Acqi$'
urls: TURN_SERVER.split(','),
username: TURN_USER,
credential: TURN_PASSWORD
},
]
}
});
console.log('PEER SETUP ', {
initiator: initiator ? initiator : false,
reconnectTimer: 10000,
config: {
iceServers: [
{
urls: 'stun:stun.l.google.com:19302'
},
{
urls: TURN_SERVER,
username: TURN_USER,
credential: TURN_PASSWORD
},
]
}
})
//start listen signal for the peer connection
this.on('signal', (data: unknown) => {
this.sendWebrtcSignal(data);
@ -85,7 +103,7 @@ export class VideoPeer extends Peer {
* Sends received stream to screen.
*/
private stream(stream?: MediaStream) {
console.log(`VideoPeer::stream => ${this.userId}`, stream);
//console.log(`VideoPeer::stream => ${this.userId}`, stream);
if(!stream){
mediaManager.disabledVideoByUserId(this.userId);
mediaManager.disabledMicrophoneByUserId(this.userId);

View file

@ -1,6 +1,6 @@
import 'phaser';
import GameConfig = Phaser.Types.Core.GameConfig;
import {DEBUG_MODE, RESOLUTION} from "./Enum/EnvironmentVariable";
import {DEBUG_MODE, JITSI_URL, RESOLUTION} from "./Enum/EnvironmentVariable";
import {cypressAsserter} from "./Cypress/CypressAsserter";
import {LoginScene} from "./Phaser/Login/LoginScene";
import {ReconnectingScene} from "./Phaser/Reconnecting/ReconnectingScene";
@ -15,6 +15,13 @@ import {CoWebsiteManager} from "./WebRtc/CoWebsiteManager";
//CoWebsiteManager.loadCoWebsite('https://thecodingmachine.com');
// Load Jitsi if the environment variable is set.
if (JITSI_URL) {
const jitsiScript = document.createElement('script');
jitsiScript.src = 'https://' + JITSI_URL + '/external_api.js';
document.head.appendChild(jitsiScript);
}
const {width, height} = CoWebsiteManager.getGameSize();
const config: GameConfig = {

View file

@ -45,7 +45,7 @@ module.exports = {
new webpack.ProvidePlugin({
Phaser: 'phaser'
}),
new webpack.EnvironmentPlugin(['API_URL', 'DEBUG_MODE'])
new webpack.EnvironmentPlugin(['API_URL', 'DEBUG_MODE', 'TURN_SERVER', 'TURN_USER', 'TURN_PASSWORD', 'JITSI_URL'])
],
};

File diff suppressed because one or more lines are too long

View file

@ -21,6 +21,60 @@
"x":0,
"y":0
},
{
"data":[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4861, 4861, 4861, 4861, 4861, 4861, 4861, 4861, 4861, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4861, 4861, 4861, 4861, 4861, 4861, 4861, 4861, 4861, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4861, 4861, 4861, 4861, 4861, 4861, 4861, 4861, 4861, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4861, 4861, 4861, 4861, 4861, 4861, 4861, 4861, 4861, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4861, 4861, 4861, 4861, 4861, 4861, 4861, 4861, 4861, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
"height":18,
"id":14,
"name":"radiant_meeting_room",
"opacity":1,
"properties":[
{
"name":"jitsiRoom",
"type":"string",
"value":"tcm-radiant"
}],
"type":"tilelayer",
"visible":true,
"width":46,
"x":0,
"y":0
},
{
"data":[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4861, 4861, 4861, 4861, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4861, 4861, 4861, 4861, 4861, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4861, 4861, 4861, 4861, 4861, 4861, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4861, 4861, 4861, 4861, 4861, 4861, 4861, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4861, 4861, 4861, 4861, 4861, 4861, 4861, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4861, 4861, 4861, 4861, 4861, 4861, 4861, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
"height":18,
"id":16,
"name":"white-meeting-room",
"opacity":1,
"properties":[
{
"name":"jitsiRoom",
"type":"string",
"value":"tcm-white-room"
}],
"type":"tilelayer",
"visible":true,
"width":46,
"x":0,
"y":0
},
{
"data":[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4861, 4861, 4861, 4861, 4861, 4861, 4861, 4861, 4861, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4861, 4861, 4861, 4861, 4861, 4861, 4861, 4861, 4861, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4861, 4861, 4861, 4861, 4861, 4861, 4861, 4861, 4861, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4861, 4861, 4861, 4861, 4861, 4861, 4861, 4861, 4861, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4861, 4861, 4861, 4861, 4861, 4861, 4861, 4861, 4861, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4861, 4861, 4861, 4861, 4861, 4861, 4861, 4861, 4861, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
"height":18,
"id":15,
"name":"dire-meeting-room",
"opacity":1,
"properties":[
{
"name":"jitsiRoom",
"type":"string",
"value":"tcm-dire"
}],
"type":"tilelayer",
"visible":true,
"width":46,
"x":0,
"y":0
},
{
"data":[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98, 99, 100, 101, 102, 103, 104, 105, 106, 107, 108, 109, 110, 111, 112, 133, 134, 135, 136, 137, 138, 139, 140, 141, 142, 143, 144, 145, 146, 147, 148, 149, 150, 151, 152, 153, 154, 155, 156, 157, 158, 159, 160, 161, 162, 163, 164, 165, 166, 167, 168, 169, 170, 171, 172, 173, 174, 175, 176, 177, 178, 199, 200, 201, 202, 203, 204, 205, 206, 207, 208, 209, 210, 211, 212, 213, 214, 215, 216, 217, 218, 219, 220, 221, 222, 223, 224, 225, 226, 227, 228, 229, 230, 231, 232, 233, 234, 235, 236, 237, 238, 239, 240, 241, 242, 243, 244, 265, 266, 267, 268, 269, 270, 271, 272, 273, 274, 275, 276, 277, 278, 279, 280, 281, 282, 283, 284, 285, 286, 287, 288, 289, 290, 291, 292, 293, 294, 295, 296, 297, 298, 299, 300, 301, 302, 303, 304, 305, 306, 307, 308, 309, 310, 331, 332, 333, 334, 335, 336, 337, 338, 339, 340, 341, 342, 343, 344, 345, 346, 347, 348, 349, 350, 351, 352, 353, 354, 355, 356, 357, 358, 359, 360, 361, 362, 363, 364, 365, 366, 367, 368, 369, 370, 371, 372, 373, 374, 375, 376, 397, 398, 399, 400, 401, 402, 403, 404, 405, 406, 407, 408, 409, 410, 411, 412, 413, 414, 415, 416, 417, 418, 419, 420, 421, 422, 423, 424, 425, 426, 427, 428, 429, 430, 431, 432, 433, 434, 435, 436, 437, 438, 439, 440, 441, 442, 463, 464, 465, 466, 467, 468, 469, 470, 471, 472, 473, 474, 475, 476, 477, 478, 479, 480, 481, 482, 483, 484, 485, 486, 487, 488, 489, 490, 491, 492, 493, 494, 495, 496, 497, 498, 499, 500, 501, 502, 503, 504, 505, 506, 507, 508, 529, 530, 531, 532, 533, 534, 535, 536, 537, 538, 539, 540, 541, 542, 543, 544, 545, 546, 547, 548, 549, 550, 551, 552, 553, 554, 555, 556, 557, 558, 559, 560, 561, 562, 563, 564, 565, 566, 567, 568, 569, 570, 571, 572, 573, 574, 595, 596, 597, 598, 599, 600, 601, 602, 603, 604, 605, 606, 607, 608, 609, 610, 611, 612, 613, 614, 615, 616, 617, 618, 619, 620, 621, 622, 623, 624, 625, 626, 627, 628, 629, 630, 631, 632, 633, 634, 635, 636, 637, 638, 639, 640, 661, 662, 663, 664, 665, 666, 667, 668, 669, 670, 671, 672, 673, 674, 675, 676, 677, 678, 679, 680, 681, 682, 683, 684, 685, 686, 687, 688, 689, 690, 691, 692, 693, 694, 695, 696, 697, 698, 699, 700, 701, 702, 703, 704, 705, 706, 727, 728, 729, 730, 731, 732, 733, 734, 735, 736, 737, 738, 739, 740, 741, 742, 743, 744, 745, 746, 747, 748, 749, 750, 751, 752, 753, 754, 755, 756, 757, 758, 759, 760, 761, 762, 763, 764, 765, 766, 767, 768, 769, 770, 771, 772, 793, 794, 795, 796, 797, 798, 799, 800, 801, 802, 803, 804, 805, 806, 807, 808, 809, 810, 811, 812, 813, 814, 815, 816, 817, 818, 819, 820, 821, 822, 823, 824, 825, 826, 827, 828, 829, 830, 831, 832, 833, 834, 835, 836, 837, 838, 859, 860, 861, 862, 863, 864, 865, 866, 867, 868, 869, 870, 871, 872, 873, 874, 875, 876, 877, 878, 879, 880, 881, 882, 883, 884, 885, 886, 887, 888, 889, 890, 891, 892, 893, 894, 895, 896, 897, 898, 899, 900, 901, 902, 903, 904, 925, 926, 927, 928, 929, 930, 931, 932, 933, 934, 935, 936, 937, 938, 939, 940, 941, 942, 943, 944, 945, 946, 947, 948, 949, 950, 951, 952, 953, 954, 955, 956, 957, 958, 959, 960, 961, 962, 963, 964, 965, 966, 967, 968, 969, 970, 991, 992, 993, 994, 995, 996, 997, 998, 999, 1000, 1001, 1002, 1003, 1004, 1005, 1006, 1007, 1008, 1009, 1010, 1011, 1012, 1013, 1014, 1015, 1016, 1017, 1018, 1019, 1020, 1021, 1022, 1023, 1024, 1025, 1026, 1027, 1028, 1029, 1030, 1031, 1032, 1033, 1034, 1035, 1036, 1057, 1058, 1059, 1060, 1061, 1062, 1063, 1064, 1065, 1066, 1067, 1068, 1069, 1070, 1071, 1072, 1073, 1074, 1075, 1076, 1077, 1078, 1079, 1080, 1081, 1082, 1083, 1084, 1085, 1086, 1087, 1088, 1089, 1090, 1091, 1092, 1093, 1094, 1095, 1096, 1097, 1098, 1099, 1100, 1101, 1102, 1123, 1124, 1125, 1126, 1127, 1128, 1129, 1130, 1131, 1132, 1133, 1134, 1135, 1136, 1137, 1138, 1139, 1140, 1141, 1142, 1143, 1144, 1145, 1146, 1147, 1148, 1149, 1150, 1151, 1152, 1153, 1154, 1155, 1156, 1157, 1158, 1159, 1160, 1161, 1162, 1163, 1164, 1165, 1166, 1167, 1168],
"height":18,
@ -134,7 +188,7 @@
"x":0,
"y":0
}],
"nextlayerid":14,
"nextlayerid":17,
"nextobjectid":1,
"orientation":"orthogonal",
"renderorder":"right-down",
@ -505,6 +559,29 @@
}]
}],
"tilewidth":32
},
{
"columns":8,
"firstgid":4809,
"image":"..\/Floor0\/floortileset.png",
"imageheight":256,
"imagewidth":256,
"margin":0,
"name":"floortileset",
"spacing":0,
"tilecount":64,
"tileheight":32,
"tiles":[
{
"id":37,
"properties":[
{
"name":"collides",
"type":"bool",
"value":true
}]
}],
"tilewidth":32
}],
"tilewidth":32,
"type":"map",

View file

@ -150,10 +150,32 @@
<li>You can of course use the &quot;#&quot; notation in an exit scene URL (so an exit scene URL will point to a given entry scene URL)</li>
</ul>
<h3 id="opening-a-website-when-walking-on-the-map" class="pixel-title">Opening a website when walking on the map</h3>
<p>On your map, you can define special zones. When a player will pass over these zones, a website will open
(as an iframe on the right side of the screen)</p>
<p>In order to create a zone that opens websites:</p>
<ul>
<li>You must create a specific layer.</li>
<li>In layer properties, you MUST add a &quot;openWebsite&quot; property (of type "string"). The value of the property is the URL of the website to open (the URL must start with "https://")</li>
</ul>
<h3 id="opening-a-jitsi-meet-when-walking-on-the-map" class="pixel-title">Opening a Jitsi meet when walking on the map</h3>
<p>On your map, you can define special zones (meeting rooms) that will trigger the opening of a Jitsi meet. When a player will pass over these zones, a Jitsi meet will open
(as an iframe on the right side of the screen)</p>
<p>In order to create Jitsi meet zones:</p>
<ul>
<li>You must create a specific layer.</li>
<li>In layer properties, you MUST add a boolean &quot;jitsiRoom&quot; property (of type "string"). The value of the property is the name of the room in Jitsi. Note: the name of the room will be prepended with the name of the instance of the map (so that different instances of the map have different rooms)</li>
</ul>
<h3 id="making-a-silent-zone" class="pixel-title">Making a "silent" zone</h3>
<p>On your map, you can define special silent zones where nobody is allowed to talk.
In these zones, users will not speak to each others, even if they are next to each others.</p>
<p>In order to create a silent zone:</p>
<ul>
<li>You must create a specific layer.</li>
<li>In layer properties, you MUST add a boolean &quot;silent&quot; property. If the silent property is checked, the users are entering the silent zone when they walk on any tile of the layer.</li>
</ul>
<h3 id="pushing-the-map" class="pixel-title">Pushing the map</h3>
<p>When your changes are ready, you need to &quot;commit&quot; and &quot;push&quot; (i.e. "upload") the changes back to GitHub.