Merge pull request #316 from thecodingmachine/develop

Deploy to prod
This commit is contained in:
David Négrier 2020-10-14 14:55:24 +02:00 committed by GitHub
commit 5e54083f34
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
142 changed files with 10892 additions and 2520 deletions

View file

@ -1 +1,3 @@
DEBUG_MODE=false
DEBUG_MODE=false
JITSI_URL=meet.jit.si
ADMIN_API_TOKEN=123

View file

@ -26,7 +26,7 @@ jobs:
uses: docker/build-push-action@v1
with:
dockerfile: front/Dockerfile
path: front/
path: ./
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
repository: thecodingmachine/workadventure-front
@ -49,7 +49,7 @@ jobs:
uses: docker/build-push-action@v1
with:
dockerfile: back/Dockerfile
path: back/
path: ./
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
repository: thecodingmachine/workadventure-back
@ -79,6 +79,30 @@ jobs:
tags: ${{ env.GITHUB_REF_SLUG }}
add_git_labels: true
build-maps:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v2
# Create a slugified value of the branch
- uses: rlespinasse/github-slug-action@master
- name: "Build and push front image"
uses: docker/build-push-action@v1
with:
dockerfile: maps/Dockerfile
path: maps/
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
repository: thecodingmachine/workadventure-maps
tags: ${{ env.GITHUB_REF_SLUG }}
add_git_labels: true
deeploy:
needs:
- build-front
@ -96,6 +120,7 @@ jobs:
uses: thecodingmachine/deeployer@master
env:
KUBE_CONFIG_FILE: ${{ secrets.KUBE_CONFIG_FILE }}
ADMIN_API_TOKEN: ${{ secrets.ADMIN_API_TOKEN }}
with:
namespace: workadventure-${{ env.GITHUB_REF_SLUG }}

View file

@ -20,12 +20,25 @@ jobs:
- name: "Setup NodeJS"
uses: actions/setup-node@v1
with:
node-version: '12.x'
node-version: '14.x'
- name: Install Protoc
uses: arduino/setup-protoc@v1
with:
version: '3.x'
- name: "Install dependencies"
run: yarn install
working-directory: "front"
- name: "Install messages dependencies"
run: yarn install
working-directory: "messages"
- name: "Build proto messages"
run: yarn run proto && yarn run copy-to-front
working-directory: "messages"
- name: "Build"
run: yarn run build
env:
@ -54,10 +67,23 @@ jobs:
with:
node-version: '12.x'
- name: Install Protoc
uses: arduino/setup-protoc@v1
with:
version: '3.x'
- name: "Install dependencies"
run: yarn install
working-directory: "back"
- name: "Install messages dependencies"
run: yarn install
working-directory: "messages"
- name: "Build proto messages"
run: yarn run proto && yarn run copy-to-back
working-directory: "messages"
- name: "Build"
run: yarn run tsc
working-directory: "back"

View file

@ -1,6 +1,12 @@
FROM thecodingmachine/workadventure-back-base:latest as builder
WORKDIR /var/www/messages
COPY --chown=docker:docker messages .
RUN yarn install && yarn proto
FROM thecodingmachine/nodejs:12
COPY --chown=docker:docker . .
COPY --chown=docker:docker back .
COPY --from=builder --chown=docker:docker /var/www/messages/generated /usr/src/app/src/Messages/generated
RUN yarn install
ENV NODE_ENV=production

View file

@ -5,7 +5,7 @@
"main": "index.js",
"scripts": {
"tsc": "tsc",
"dev": "ts-node-dev --respawn --transpileOnly ./server.ts",
"dev": "ts-node-dev --respawn ./server.ts",
"prod": "tsc && node ./dist/server.js",
"profile": "tsc && node --prof ./dist/server.js",
"test": "ts-node node_modules/jasmine/bin/jasmine --config=jasmine.json",
@ -36,25 +36,34 @@
},
"homepage": "https://github.com/thecodingmachine/workadventure#readme",
"dependencies": {
"@types/express": "^4.17.4",
"@types/http-status-codes": "^1.2.0",
"@types/jsonwebtoken": "^8.3.8",
"@types/socket.io": "^2.1.4",
"@types/uuidv4": "^5.0.0",
"axios": "^0.20.0",
"body-parser": "^1.19.0",
"express": "^4.17.1",
"busboy": "^0.3.1",
"circular-json": "^0.5.9",
"generic-type-guard": "^3.2.0",
"google-protobuf": "^3.13.0",
"http-status-codes": "^1.4.0",
"iterall": "^1.3.0",
"jsonwebtoken": "^8.5.1",
"mkdirp": "^1.0.4",
"multer": "^1.4.2",
"prom-client": "^12.0.0",
"socket.io": "^2.3.0",
"query-string": "^6.13.3",
"systeminformation": "^4.26.5",
"ts-node-dev": "^1.0.0-pre.44",
"typescript": "^3.8.3",
"uWebSockets.js": "uNetworking/uWebSockets.js#v18.5.0",
"uuidv4": "^6.0.7"
},
"devDependencies": {
"@types/busboy": "^0.2.3",
"@types/circular-json": "^0.4.0",
"@types/google-protobuf": "^3.7.3",
"@types/http-status-codes": "^1.2.0",
"@types/jasmine": "^3.5.10",
"@types/jsonwebtoken": "^8.3.8",
"@types/mkdirp": "^1.0.1",
"@types/uuidv4": "^5.0.0",
"@typescript-eslint/eslint-plugin": "^2.26.0",
"@typescript-eslint/parser": "^2.26.0",
"eslint": "^6.8.0",

View file

@ -1,3 +1,3 @@
// lib/server.ts
import App from "./src/App";
App.listen(8080, () => console.log(`Example app listening on port 8080!`))
App.listen(8080, () => console.log(`WorkAdventure starting on port 8080!`))

View file

@ -1,55 +1,32 @@
// lib/app.ts
import {IoSocketController} from "./Controller/IoSocketController"; //TODO fix import by "_Controller/..."
import {AuthenticateController} from "./Controller/AuthenticateController"; //TODO fix import by "_Controller/..."
import express from "express";
import {Application, Request, Response} from 'express';
import bodyParser = require('body-parser');
import * as http from "http";
import {MapController} from "./Controller/MapController";
import {PrometheusController} from "./Controller/PrometheusController";
import {FileController} from "./Controller/FileController";
import {DebugController} from "./Controller/DebugController";
import {App as uwsApp} from "./Server/sifrr.server";
class App {
public app: Application;
public server: http.Server;
public app: uwsApp;
public ioSocketController: IoSocketController;
public authenticateController: AuthenticateController;
public fileController: FileController;
public mapController: MapController;
public prometheusController: PrometheusController;
private debugController: DebugController;
constructor() {
this.app = express();
//config server http
this.server = http.createServer(this.app);
this.config();
this.crossOrigin();
//TODO add middleware with access token to secure api
this.app = new uwsApp();
//create socket controllers
this.ioSocketController = new IoSocketController(this.server);
this.ioSocketController = new IoSocketController(this.app);
this.authenticateController = new AuthenticateController(this.app);
this.fileController = new FileController(this.app);
this.mapController = new MapController(this.app);
this.prometheusController = new PrometheusController(this.app, this.ioSocketController);
}
// TODO add session user
private config(): void {
this.app.use(bodyParser.json());
this.app.use(bodyParser.urlencoded({extended: false}));
}
private crossOrigin(){
this.app.use((req: Request, res: Response, next) => {
res.setHeader("Access-Control-Allow-Origin", "*"); // update to match the domain you will make the request from
// Request methods you wish to allow
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS, PUT, PATCH, DELETE');
// Request headers you wish to allow
res.setHeader("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept");
next();
});
this.debugController = new DebugController(this.app, this.ioSocketController);
}
}
export default new App().server;
export default new App().app;

View file

@ -1,40 +1,94 @@
import {Application, Request, Response} from "express";
import Jwt from "jsonwebtoken";
import {BAD_REQUEST, OK} from "http-status-codes";
import {SECRET_KEY, URL_ROOM_STARTED} from "../Enum/EnvironmentVariable"; //TODO fix import by "_Enum/..."
import { uuid } from 'uuidv4';
import { v4 } from 'uuid';
import {HttpRequest, HttpResponse, TemplatedApp} from "uWebSockets.js";
import {BaseController} from "./BaseController";
import {adminApi} from "../Services/AdminApi";
import {jwtTokenManager} from "../Services/JWTTokenManager";
export interface TokenInterface {
name: string,
userId: string
userUuid: string
}
export class AuthenticateController {
App : Application;
export class AuthenticateController extends BaseController {
constructor(private App : TemplatedApp) {
super();
this.register();
this.anonymLogin();
}
//Try to login with an admin token
register(){
this.App.options("/register", (res: HttpResponse, req: HttpRequest) => {
this.addCorsHeaders(res);
res.end();
});
this.App.post("/register", (res: HttpResponse, req: HttpRequest) => {
(async () => {
this.addCorsHeaders(res);
res.onAborted(() => {
console.warn('Login request was aborted');
})
const host = req.getHeader('host');
const param = await res.json();
//todo: what to do if the organizationMemberToken is already used?
const organizationMemberToken:string|null = param.organizationMemberToken;
try {
if (typeof organizationMemberToken != 'string') throw new Error('No organization token');
const data = await adminApi.fetchMemberDataByToken(organizationMemberToken);
const userUuid = data.userUuid;
const organizationSlug = data.organizationSlug;
const worldSlug = data.worldSlug;
const roomSlug = data.roomSlug;
const mapUrlStart = data.mapUrlStart;
const authToken = jwtTokenManager.createJWTToken(userUuid);
res.writeStatus("200 OK").end(JSON.stringify({
authToken,
userUuid,
organizationSlug,
worldSlug,
roomSlug,
mapUrlStart,
}));
} catch (e) {
console.log("An error happened", e)
res.writeStatus(e.status || "500 Internal Server Error").end('An error happened');
}
})();
});
constructor(App : Application) {
this.App = App;
this.login();
}
//permit to login on application. Return token to connect on Websocket IO.
login(){
// For now, let's completely forget the /login route.
this.App.post("/login", (req: Request, res: Response) => {
const param = req.body;
/*if(!param.name){
return res.status(BAD_REQUEST).send({
message: "email parameter is empty"
});
}*/
//TODO check user email for The Coding Machine game
const userId = uuid();
const token = Jwt.sign({name: param.name, userId: userId} as TokenInterface, SECRET_KEY, {expiresIn: '24h'});
return res.status(OK).send({
token: token,
mapUrlStart: URL_ROOM_STARTED,
userId: userId,
});
anonymLogin(){
this.App.options("/anonymLogin", (res: HttpResponse, req: HttpRequest) => {
this.addCorsHeaders(res);
res.end();
});
this.App.post("/anonymLogin", (res: HttpResponse, req: HttpRequest) => {
this.addCorsHeaders(res);
res.onAborted(() => {
console.warn('Login request was aborted');
})
const userUuid = v4();
const authToken = jwtTokenManager.createJWTToken(userUuid);
res.writeStatus("200 OK").end(JSON.stringify({
authToken,
userUuid,
}));
});
}
}

View file

@ -0,0 +1,10 @@
import {HttpResponse} from "uWebSockets.js";
export class BaseController {
protected addCorsHeaders(res: HttpResponse): void {
res.writeHeader('access-control-allow-headers', 'Origin, X-Requested-With, Content-Type, Accept');
res.writeHeader('access-control-allow-methods', 'GET, POST, OPTIONS, PUT, PATCH, DELETE');
res.writeHeader('access-control-allow-origin', '*');
}
}

View file

@ -0,0 +1,44 @@
import {ADMIN_API_TOKEN} from "../Enum/EnvironmentVariable";
import {IoSocketController} from "_Controller/IoSocketController";
import {stringify} from "circular-json";
import {HttpRequest, HttpResponse} from "uWebSockets.js";
import { parse } from 'query-string';
import {App} from "../Server/sifrr.server";
export class DebugController {
constructor(private App : App, private ioSocketController: IoSocketController) {
this.getDump();
}
getDump(){
this.App.get("/dump", (res: HttpResponse, req: HttpRequest) => {
const query = parse(req.getQuery());
if (query.token !== ADMIN_API_TOKEN) {
return res.status(401).send('Invalid token sent!');
}
return res.writeStatus('200 OK').writeHeader('Content-Type', 'application/json').end(stringify(
this.ioSocketController.getWorlds(),
(key: unknown, value: unknown) => {
if(value instanceof Map) {
const obj: any = {}; // eslint-disable-line @typescript-eslint/no-explicit-any
for (const [mapKey, mapValue] of value.entries()) {
obj[mapKey] = mapValue;
}
return obj;
} else if(value instanceof Set) {
const obj: Array<unknown> = [];
for (const [setKey, setValue] of value.entries()) {
obj.push(setValue);
}
return obj;
} else {
return value;
}
}
));
});
}
}

View file

@ -0,0 +1,158 @@
import {App} from "../Server/sifrr.server";
import {v4} from "uuid";
import {HttpRequest, HttpResponse} from "uWebSockets.js";
import {BaseController} from "./BaseController";
import { Readable } from 'stream'
interface UploadedFileBuffer {
buffer: Buffer,
expireDate: Date
}
export class FileController extends BaseController {
private uploadedFileBuffers: Map<string, UploadedFileBuffer> = new Map<string, UploadedFileBuffer>();
constructor(private App : App) {
super();
this.App = App;
this.uploadAudioMessage();
this.downloadAudioMessage();
// Cleanup every 1 minute
setInterval(this.cleanup.bind(this), 60000);
}
/**
* Clean memory from old files
*/
cleanup(): void {
const now = new Date();
for (const [id, file] of this.uploadedFileBuffers) {
if (file.expireDate < now) {
this.uploadedFileBuffers.delete(id);
}
}
}
uploadAudioMessage(){
this.App.options("/upload-audio-message", (res: HttpResponse, req: HttpRequest) => {
this.addCorsHeaders(res);
res.end();
});
this.App.post("/upload-audio-message", (res: HttpResponse, req: HttpRequest) => {
(async () => {
this.addCorsHeaders(res);
res.onAborted(() => {
console.warn('upload-audio-message request was aborted');
})
try {
const audioMessageId = v4();
const params = await res.formData({
onFile: (fieldname: string,
file: NodeJS.ReadableStream,
filename: string,
encoding: string,
mimetype: string) => {
(async () => {
console.log('READING FILE', fieldname)
const chunks: Buffer[] = []
for await (const chunk of file) {
if (!(chunk instanceof Buffer)) {
throw new Error('Unexpected chunk');
}
chunks.push(chunk)
}
// Let's expire in 1 minute.
const expireDate = new Date();
expireDate.setMinutes(expireDate.getMinutes() + 1);
this.uploadedFileBuffers.set(audioMessageId, {
buffer: Buffer.concat(chunks),
expireDate
});
})();
}
});
res.writeStatus("200 OK").end(JSON.stringify({
id: audioMessageId,
path: `/download-audio-message/${audioMessageId}`
}));
} catch (e) {
console.log("An error happened", e)
res.writeStatus(e.status || "500 Internal Server Error").end('An error happened');
}
})();
});
}
downloadAudioMessage(){
this.App.options("/download-audio-message/*", (res: HttpResponse, req: HttpRequest) => {
this.addCorsHeaders(res);
res.end();
});
this.App.get("/download-audio-message/:id", (res: HttpResponse, req: HttpRequest) => {
this.addCorsHeaders(res);
res.onAborted(() => {
console.warn('upload-audio-message request was aborted');
})
const id = req.getParameter(0);
const file = this.uploadedFileBuffers.get(id);
if (file === undefined) {
res.writeStatus("404 Not found").end("Cannot find file");
return;
}
const readable = new Readable()
readable._read = () => {} // _read is required but you can noop it
readable.push(file.buffer);
readable.push(null);
const size = file.buffer.byteLength;
res.writeStatus("200 OK");
readable.on('data', buffer => {
const chunk = buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength),
lastOffset = res.getWriteOffset();
// First try
const [ok, done] = res.tryEnd(chunk, size);
if (done) {
readable.destroy();
} else if (!ok) {
// pause because backpressure
readable.pause();
// Save unsent chunk for later
res.ab = chunk;
res.abOffset = lastOffset;
// Register async handlers for drainage
res.onWritable(offset => {
const [ok, done] = res.tryEnd(res.ab.slice(offset - res.abOffset), size);
if (done) {
readable.destroy();
} else if (ok) {
readable.resume();
}
return ok;
});
}
});
});
}
}

File diff suppressed because it is too large Load diff

View file

@ -1,28 +1,61 @@
import express from "express";
import {Application, Request, Response} from "express";
import {OK} from "http-status-codes";
import {URL_ROOM_STARTED} from "../Enum/EnvironmentVariable";
import {HttpRequest, HttpResponse, TemplatedApp} from "uWebSockets.js";
import {BaseController} from "./BaseController";
import {parse} from "query-string";
import {adminApi} from "../Services/AdminApi";
export class MapController {
App: Application;
//todo: delete this
export class MapController extends BaseController{
constructor(App: Application) {
constructor(private App : TemplatedApp) {
super();
this.App = App;
this.getStartMap();
this.assetMaps();
this.getMapUrl();
}
assetMaps() {
this.App.use('/map/files', express.static('src/Assets/Maps'));
}
// Returns a map mapping map name to file name of the map
getStartMap() {
this.App.get("/start-map", (req: Request, res: Response) => {
res.status(OK).send({
mapUrlStart: req.headers.host + "/map/files" + URL_ROOM_STARTED,
startInstance: "global"
});
getMapUrl() {
this.App.options("/map", (res: HttpResponse, req: HttpRequest) => {
this.addCorsHeaders(res);
res.end();
});
this.App.get("/map", (res: HttpResponse, req: HttpRequest) => {
this.addCorsHeaders(res);
res.onAborted(() => {
console.warn('/map request was aborted');
})
const query = parse(req.getQuery());
if (typeof query.organizationSlug !== 'string') {
console.error('Expected organizationSlug parameter');
res.writeStatus("400 Bad request").end("Expected organizationSlug parameter");
}
if (typeof query.worldSlug !== 'string') {
console.error('Expected worldSlug parameter');
res.writeStatus("400 Bad request").end("Expected worldSlug parameter");
}
if (typeof query.roomSlug !== 'string' && query.roomSlug !== undefined) {
console.error('Expected only one roomSlug parameter');
res.writeStatus("400 Bad request").end("Expected only one roomSlug parameter");
}
(async () => {
try {
const mapDetails = await adminApi.fetchMapDetails(query.organizationSlug as string, query.worldSlug as string, query.roomSlug as string|undefined);
res.writeStatus("200 OK").end(JSON.stringify(mapDetails));
} catch (e) {
console.error(e);
res.writeStatus("500 Internal Server Error").end("An error occurred");
}
})();
});
}
}

View file

@ -1,10 +1,11 @@
import {Application, Request, Response} from "express";
import {App} from "../Server/sifrr.server";
import {IoSocketController} from "_Controller/IoSocketController";
import {HttpRequest, HttpResponse} from "uWebSockets.js";
const register = require('prom-client').register;
const collectDefaultMetrics = require('prom-client').collectDefaultMetrics;
export class PrometheusController {
constructor(private App: Application, private ioSocketController: IoSocketController) {
constructor(private App: App, private ioSocketController: IoSocketController) {
collectDefaultMetrics({
timeout: 10000,
gcDurationBuckets: [0.001, 0.01, 0.1, 1, 2, 5], // These are the default buckets.
@ -13,8 +14,8 @@ export class PrometheusController {
this.App.get("/metrics", this.metrics.bind(this));
}
private metrics(req: Request, res: Response): void {
res.set('Content-Type', register.contentType);
private metrics(res: HttpResponse, req: HttpRequest): void {
res.writeHeader('Content-Type', register.contentType);
res.end(register.metrics());
}
}

View file

@ -3,11 +3,17 @@ 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;
const ADMIN_API_URL = process.env.ADMIN_API_URL || null;
const ADMIN_API_TOKEN = process.env.ADMIN_API_TOKEN || null;
const CPU_OVERHEAT_THRESHOLD = Number(process.env.CPU_OVERHEAT_THRESHOLD) || 80;
export {
SECRET_KEY,
URL_ROOM_STARTED,
MINIMUM_DISTANCE,
ADMIN_API_URL,
ADMIN_API_TOKEN,
GROUP_RADIUS,
ALLOW_ARTILLERY
ALLOW_ARTILLERY,
CPU_OVERHEAT_THRESHOLD,
}

1
back/src/Messages/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/generated/

251
back/src/Model/GameRoom.ts Normal file
View file

@ -0,0 +1,251 @@
import {PointInterface} from "./Websocket/PointInterface";
import {Group} from "./Group";
import {User} from "./User";
import {ExSocketInterface} from "_Model/Websocket/ExSocketInterface";
import {PositionInterface} from "_Model/PositionInterface";
import {Identificable} from "_Model/Websocket/Identificable";
import {EntersCallback, LeavesCallback, MovesCallback} from "_Model/Zone";
import {PositionNotifier} from "./PositionNotifier";
import {ViewportInterface} from "_Model/Websocket/ViewportMessage";
import {Movable} from "_Model/Movable";
export type ConnectCallback = (user: User, group: Group) => void;
export type DisconnectCallback = (user: User, group: Group) => void;
export class GameRoom {
private readonly minDistance: number;
private readonly groupRadius: number;
// Users, sorted by ID
private readonly users: Map<number, User>;
private readonly groups: Set<Group>;
private readonly connectCallback: ConnectCallback;
private readonly disconnectCallback: DisconnectCallback;
private itemsState: Map<number, unknown> = new Map<number, unknown>();
private readonly positionNotifier: PositionNotifier;
constructor(connectCallback: ConnectCallback,
disconnectCallback: DisconnectCallback,
minDistance: number,
groupRadius: number,
onEnters: EntersCallback,
onMoves: MovesCallback,
onLeaves: LeavesCallback)
{
this.users = new Map<number, User>();
this.groups = new Set<Group>();
this.connectCallback = connectCallback;
this.disconnectCallback = disconnectCallback;
this.minDistance = minDistance;
this.groupRadius = groupRadius;
// A zone is 10 sprites wide.
this.positionNotifier = new PositionNotifier(320, 320, onEnters, onMoves, onLeaves);
}
public getGroups(): Group[] {
return Array.from(this.groups.values());
}
public getUsers(): Map<number, User> {
return this.users;
}
public join(socket : ExSocketInterface, userPosition: PointInterface): void {
const user = new User(socket.userId, userPosition, false, this.positionNotifier, socket);
this.users.set(socket.userId, user);
// Let's call update position to trigger the join / leave room
//this.updatePosition(socket, userPosition);
this.updateUserGroup(user);
}
public leave(user : Identificable){
const userObj = this.users.get(user.userId);
if (userObj === undefined) {
console.warn('User ', user.userId, 'does not belong to world! It should!');
}
if (userObj !== undefined && typeof userObj.group !== 'undefined') {
this.leaveGroup(userObj);
}
this.users.delete(user.userId);
if (userObj !== undefined) {
this.positionNotifier.removeViewport(userObj);
this.positionNotifier.leave(userObj);
}
}
public isEmpty(): boolean {
return this.users.size === 0;
}
public updatePosition(socket : Identificable, userPosition: PointInterface): void {
const user = this.users.get(socket.userId);
if(typeof user === 'undefined') {
return;
}
user.setPosition(userPosition);
this.updateUserGroup(user);
}
private updateUserGroup(user: User): void {
user.group?.updatePosition();
if (user.silent) {
return;
}
if (user.group === undefined) {
// If the user is not part of a group:
// should he join a group?
const closestItem: User|Group|null = this.searchClosestAvailableUserOrGroup(user);
if (closestItem !== null) {
if (closestItem instanceof Group) {
// Let's join the group!
closestItem.join(user);
} else {
const closestUser : User = closestItem;
const group: Group = new Group([
user,
closestUser
], this.connectCallback, this.disconnectCallback, this.positionNotifier);
this.groups.add(group);
}
}
} else {
// If the user is part of a group:
// should he leave the group?
const distance = GameRoom.computeDistanceBetweenPositions(user.getPosition(), user.group.getPosition());
if (distance > this.groupRadius) {
this.leaveGroup(user);
}
}
}
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.getPosition());
}
}
/**
* Makes a user leave a group and closes and destroy the group if the group contains only one remaining person.
*
* @param user
*/
private leaveGroup(user: User): void {
const group = user.group;
if (group === undefined) {
throw new Error("The user is part of no group");
}
const oldPosition = group.getPosition();
group.leave(user);
if (group.isEmpty()) {
this.positionNotifier.leave(group);
group.destroy();
if (!this.groups.has(group)) {
throw new Error("Could not find group "+group.getId()+" referenced by user "+user.id+" in World.");
}
this.groups.delete(group);
} else {
group.updatePosition();
//this.positionNotifier.updatePosition(group, group.getPosition(), oldPosition);
}
}
/**
* 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)
*/
private searchClosestAvailableUserOrGroup(user: User): User|Group|null
{
let minimumDistanceFound: number = Math.max(this.minDistance, this.groupRadius);
let matchingItem: User | Group | null = null;
this.users.forEach((currentUser, userId) => {
// Let's only check users that are not part of a group
if (typeof currentUser.group !== 'undefined') {
return;
}
if(currentUser === user) {
return;
}
if (currentUser.silent) {
return;
}
const distance = GameRoom.computeDistance(user, currentUser); // compute distance between peers.
if(distance <= minimumDistanceFound && distance <= this.minDistance) {
minimumDistanceFound = distance;
matchingItem = currentUser;
}
});
this.groups.forEach((group: Group) => {
if (group.isFull()) {
return;
}
const distance = GameRoom.computeDistanceBetweenPositions(user.getPosition(), group.getPosition());
if(distance <= minimumDistanceFound && distance <= this.groupRadius) {
minimumDistanceFound = distance;
matchingItem = group;
}
});
return matchingItem;
}
public static computeDistance(user1: User, user2: User): number
{
const user1Position = user1.getPosition();
const user2Position = user2.getPosition();
return Math.sqrt(Math.pow(user2Position.x - user1Position.x, 2) + Math.pow(user2Position.y - user1Position.y, 2));
}
public static computeDistanceBetweenPositions(position1: PositionInterface, position2: PositionInterface): number
{
return Math.sqrt(Math.pow(position2.x - position1.x, 2) + Math.pow(position2.y - position1.y, 2));
}
public setItemState(itemId: number, state: unknown) {
this.itemsState.set(itemId, state);
}
public getItemsState(): Map<number, unknown> {
return this.itemsState;
}
setViewport(socket : Identificable, viewport: ViewportInterface): Movable[] {
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);
}
}

View file

@ -1,33 +1,37 @@
import { World, ConnectCallback, DisconnectCallback } from "./World";
import { UserInterface } from "./UserInterface";
import { ConnectCallback, DisconnectCallback } from "./GameRoom";
import { User } from "./User";
import {PositionInterface} from "_Model/PositionInterface";
import {uuid} from "uuidv4";
import {Movable} from "_Model/Movable";
import {PositionNotifier} from "_Model/PositionNotifier";
export class Group {
export class Group implements Movable {
static readonly MAX_PER_GROUP = 4;
private id: string;
private users: Set<UserInterface>;
private connectCallback: ConnectCallback;
private disconnectCallback: DisconnectCallback;
private static nextId: number = 1;
private id: number;
private users: Set<User>;
private x!: number;
private y!: number;
constructor(users: UserInterface[], connectCallback: ConnectCallback, disconnectCallback: DisconnectCallback) {
this.users = new Set<UserInterface>();
this.connectCallback = connectCallback;
this.disconnectCallback = disconnectCallback;
this.id = uuid();
constructor(users: User[], private connectCallback: ConnectCallback, private disconnectCallback: DisconnectCallback, private positionNotifier: PositionNotifier) {
this.users = new Set<User>();
this.id = Group.nextId;
Group.nextId++;
users.forEach((user: UserInterface) => {
users.forEach((user: User) => {
this.join(user);
});
this.updatePosition();
}
getUsers(): UserInterface[] {
getUsers(): User[] {
return Array.from(this.users.values());
}
getId() : string{
getId() : number {
return this.id;
}
@ -35,19 +39,40 @@ export class Group {
* Returns the barycenter of all users (i.e. the center of the group)
*/
getPosition(): PositionInterface {
return {
x: this.x,
y: this.y
};
}
/**
* Computes the barycenter of all users (i.e. the center of the group)
*/
updatePosition(): void {
const oldX = this.x;
const oldY = this.y;
let x = 0;
let y = 0;
// Let's compute the barycenter of all users.
this.users.forEach((user: UserInterface) => {
x += user.position.x;
y += user.position.y;
this.users.forEach((user: User) => {
const position = user.getPosition();
x += position.x;
y += position.y;
});
x /= this.users.size;
y /= this.users.size;
return {
x,
y
};
if (this.users.size === 0) {
throw new Error("EMPTY GROUP FOUND!!!");
}
this.x = x;
this.y = y;
if (oldX === undefined) {
this.positionNotifier.enter(this);
} else {
this.positionNotifier.updatePosition(this, {x, y}, {x: oldX, y: oldY});
}
}
isFull(): boolean {
@ -58,15 +83,15 @@ export class Group {
return this.users.size <= 1;
}
join(user: UserInterface): void
join(user: User): void
{
// Broadcast on the right event
this.connectCallback(user.id, this);
this.connectCallback(user, this);
this.users.add(user);
user.group = this;
}
leave(user: UserInterface): void
leave(user: User): void
{
const success = this.users.delete(user);
if (success === false) {
@ -74,8 +99,12 @@ export class Group {
}
user.group = undefined;
if (this.users.size !== 0) {
this.updatePosition();
}
// Broadcast on the right event
this.disconnectCallback(user.id, this);
this.disconnectCallback(user, this);
}
/**

View file

@ -0,0 +1,8 @@
import {PositionInterface} from "_Model/PositionInterface";
/**
* A physical object that can be placed into a Zone
*/
export interface Movable {
getPosition(): PositionInterface
}

View file

@ -0,0 +1,132 @@
/**
* 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 {EntersCallback, LeavesCallback, MovesCallback, Zone} from "./Zone";
import {PointInterface} from "_Model/Websocket/PointInterface";
import {User} from "_Model/User";
import {ViewportInterface} from "_Model/Websocket/ViewportMessage";
import {Movable} from "_Model/Movable";
import {PositionInterface} from "_Model/PositionInterface";
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: EntersCallback, private onUserMoves: MovesCallback, private onUserLeaves: LeavesCallback) {
}
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: User, viewport: ViewportInterface): Movable[] {
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 things: Movable[] = [];
for (const zone of addedZones) {
zone.startListening(user);
things = things.concat(Array.from(zone.getThings()))
}
for (const zone of removedZones) {
zone.stopListening(user);
}
return things;
}
public enter(thing: Movable): void {
const position = thing.getPosition();
const zoneDesc = this.getZoneDescriptorFromCoordinates(position.x, position.y);
const zone = this.getZone(zoneDesc.i, zoneDesc.j);
zone.enter(thing, null, position);
}
public updatePosition(thing: Movable, newPosition: PositionInterface, oldPosition: PositionInterface): void {
// Did we change zone?
const oldZoneDesc = this.getZoneDescriptorFromCoordinates(oldPosition.x, oldPosition.y);
const newZoneDesc = this.getZoneDescriptorFromCoordinates(newPosition.x, newPosition.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(thing, newZone);
// Enter new zone
newZone.enter(thing, oldZone, newPosition);
} else {
const zone = this.getZone(oldZoneDesc.i, oldZoneDesc.j);
zone.move(thing, newPosition);
}
}
public leave(thing: Movable): void {
const oldPosition = thing.getPosition();
const oldZoneDesc = this.getZoneDescriptorFromCoordinates(oldPosition.x, oldPosition.y);
const oldZone = this.getZone(oldZoneDesc.i, oldZoneDesc.j);
oldZone.leave(thing, null);
}
public removeViewport(user: User): void {
// 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, i, j);
this.zones[j][i] = zone;
}
return zone;
}
}

View file

@ -0,0 +1,25 @@
export class RoomIdentifier {
public readonly anonymous: boolean;
public readonly id:string
public readonly organizationSlug: string|undefined;
public readonly worldSlug: string|undefined;
public readonly roomSlug: string|undefined;
constructor(roomID: string) {
if (roomID.startsWith('_/')) {
this.anonymous = true;
} else if(roomID.startsWith('@/')) {
this.anonymous = false;
const match = /@\/([^/]+)\/([^/]+)\/(.+)/.exec(roomID);
if (!match) {
throw new Error('Could not extract info from "'+roomID+'"');
}
this.organizationSlug = match[1];
this.worldSlug = match[2];
this.roomSlug = match[3];
} else {
throw new Error('Incorrect room ID: '+roomID);
}
this.id = roomID;
}
}

34
back/src/Model/User.ts Normal file
View file

@ -0,0 +1,34 @@
import { Group } from "./Group";
import { PointInterface } from "./Websocket/PointInterface";
import {Zone} from "_Model/Zone";
import {Movable} from "_Model/Movable";
import {PositionInterface} from "_Model/PositionInterface";
import {PositionNotifier} from "_Model/PositionNotifier";
import {ExSocketInterface} from "_Model/Websocket/ExSocketInterface";
export class User implements Movable {
public listenedZones: Set<Zone>;
public group?: Group;
public constructor(
public id: number,
private position: PointInterface,
public silent: boolean,
private positionNotifier: PositionNotifier,
public readonly socket: ExSocketInterface
) {
this.listenedZones = new Set<Zone>();
this.positionNotifier.enter(this);
}
public getPosition(): PointInterface {
return this.position;
}
public setPosition(position: PointInterface): void {
const oldPosition = this.position;
this.position = position;
this.positionNotifier.updatePosition(this, position, oldPosition);
}
}

View file

@ -1,9 +0,0 @@
import { Group } from "./Group";
import { PointInterface } from "./Websocket/PointInterface";
export interface UserInterface {
id: string,
group?: Group,
position: PointInterface,
silent: boolean
}

View file

@ -1,15 +1,24 @@
import {Socket} from "socket.io";
import {PointInterface} from "./PointInterface";
import {Identificable} from "./Identificable";
import {TokenInterface} from "../../Controller/AuthenticateController";
import {ViewportInterface} from "_Model/Websocket/ViewportMessage";
import {BatchMessage, SubMessage} from "../../Messages/generated/messages_pb";
import {WebSocket} from "uWebSockets.js"
export interface ExSocketInterface extends Socket, Identificable {
export interface ExSocketInterface extends WebSocket, Identificable {
token: string;
roomId: string;
webRtcRoomId: string;
userId: string;
//userId: number; // A temporary (autoincremented) identifier for this user
userUuid: string; // A unique identifier for this user
name: string;
characterLayers: string[];
position: PointInterface;
isArtillery: boolean; // Whether this socket is opened by Artillery for load testing (hack)
viewport: ViewportInterface;
/**
* Pushes an event that will be sent in the next batch of events
*/
emitInBatch: (payload: SubMessage) => void;
batchedMessages: BatchMessage;
batchTimeout: NodeJS.Timeout|null;
disconnecting: boolean,
tags: string[]
}

View file

@ -0,0 +1,6 @@
import {PositionInterface} from "_Model/PositionInterface";
export interface GroupUpdateInterface {
position: PositionInterface,
groupId: number,
}

View file

@ -1,3 +1,3 @@
export interface Identificable {
userId: string;
userId: number;
}

View file

@ -0,0 +1,10 @@
import * as tg from "generic-type-guard";
export const isItemEventMessageInterface =
new tg.IsInterface().withProperties({
itemId: tg.isNumber,
event: tg.isString,
state: tg.isUnknown,
parameters: tg.isUnknown,
}).get();
export type ItemEventMessageInterface = tg.GuardedType<typeof isItemEventMessageInterface>;

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

@ -1,6 +1,6 @@
import {PointInterface} from "_Model/Websocket/PointInterface";
export class MessageUserJoined {
constructor(public userId: string, public name: string, public characterLayers: string[], public position: PointInterface) {
constructor(public userId: number, public name: string, public characterLayers: string[], public position: PointInterface) {
}
}

View file

@ -1,6 +0,0 @@
import {PointInterface} from "./PointInterface";
export class MessageUserMoved {
constructor(public userId: string, public position: PointInterface) {
}
}

View file

@ -6,6 +6,6 @@ export class Point implements PointInterface{
}
export class MessageUserPosition {
constructor(public userId: string, public name: string, public characterLayers: string[], public position: PointInterface) {
constructor(public userId: number, public name: string, public characterLayers: string[], public position: PointInterface) {
}
}

View file

@ -0,0 +1,92 @@
import {PointInterface} from "./PointInterface";
import {ItemEventMessage, PointMessage, PositionMessage} from "../../Messages/generated/messages_pb";
import {ExSocketInterface} from "_Model/Websocket/ExSocketInterface";
import Direction = PositionMessage.Direction;
import {ItemEventMessageInterface} from "_Model/Websocket/ItemEventMessage";
import {PositionInterface} from "_Model/PositionInterface";
export class ProtobufUtils {
public static toPositionMessage(point: PointInterface): PositionMessage {
let direction: PositionMessage.DirectionMap[keyof PositionMessage.DirectionMap];
switch (point.direction) {
case 'up':
direction = Direction.UP;
break;
case 'down':
direction = Direction.DOWN;
break;
case 'left':
direction = Direction.LEFT;
break;
case 'right':
direction = Direction.RIGHT;
break;
default:
throw new Error('unexpected direction');
}
const position = new PositionMessage();
position.setX(point.x);
position.setY(point.y);
position.setMoving(point.moving);
position.setDirection(direction);
return position;
}
public static toPointInterface(position: PositionMessage): PointInterface {
let direction: string;
switch (position.getDirection()) {
case Direction.UP:
direction = 'up';
break;
case Direction.DOWN:
direction = 'down';
break;
case Direction.LEFT:
direction = 'left';
break;
case Direction.RIGHT:
direction = 'right';
break;
default:
throw new Error("Unexpected direction");
}
// sending to all clients in room except sender
return {
x: position.getX(),
y: position.getY(),
direction,
moving: position.getMoving(),
};
}
public static toPointMessage(point: PositionInterface): PointMessage {
const position = new PointMessage();
position.setX(Math.floor(point.x));
position.setY(Math.floor(point.y));
return position;
}
public static toItemEvent(itemEventMessage: ItemEventMessage): ItemEventMessageInterface {
return {
itemId: itemEventMessage.getItemid(),
event: itemEventMessage.getEvent(),
parameters: JSON.parse(itemEventMessage.getParametersjson()),
state: JSON.parse(itemEventMessage.getStatejson()),
}
}
public static toItemEventProtobuf(itemEvent: ItemEventMessageInterface): ItemEventMessage {
const itemEventMessage = new ItemEventMessage();
itemEventMessage.setItemid(itemEvent.itemId);
itemEventMessage.setEvent(itemEvent.event);
itemEventMessage.setParametersjson(JSON.stringify(itemEvent.parameters));
itemEventMessage.setStatejson(JSON.stringify(itemEvent.state));
return itemEventMessage;
}
}

View file

@ -1,5 +1,5 @@
export interface UserInGroupInterface {
userId: string,
userId: number,
name: string,
initiator: boolean
}

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

@ -7,12 +7,12 @@ export const isSignalData =
export const isWebRtcSignalMessageInterface =
new tg.IsInterface().withProperties({
receiverId: tg.isString,
receiverId: tg.isNumber,
signal: isSignalData
}).get();
export const isWebRtcScreenSharingStartMessageInterface =
new tg.IsInterface().withProperties({
userId: tg.isString,
userId: tg.isNumber,
roomId: tg.isString
}).get();
export type WebRtcSignalMessageInterface = tg.GuardedType<typeof isWebRtcSignalMessageInterface>;

View file

@ -1,319 +0,0 @@
import {MessageUserPosition, Point} from "./Websocket/MessageUserPosition";
import {PointInterface} from "./Websocket/PointInterface";
import {Group} from "./Group";
import {Distance} from "./Distance";
import {UserInterface} from "./UserInterface";
import {ExSocketInterface} from "_Model/Websocket/ExSocketInterface";
import {PositionInterface} from "_Model/PositionInterface";
import {Identificable} from "_Model/Websocket/Identificable";
export type ConnectCallback = (user: string, group: Group) => void;
export type DisconnectCallback = (user: string, group: Group) => void;
// callback called when a group is created or moved or changes users
export type GroupUpdatedCallback = (group: Group) => void;
export type GroupDeletedCallback = (uuid: string, lastUser: UserInterface) => void;
export class World {
private readonly minDistance: number;
private readonly groupRadius: number;
// Users, sorted by ID
private readonly users: Map<string, UserInterface>;
private readonly groups: Set<Group>;
private readonly connectCallback: ConnectCallback;
private readonly disconnectCallback: DisconnectCallback;
private readonly groupUpdatedCallback: GroupUpdatedCallback;
private readonly groupDeletedCallback: GroupDeletedCallback;
constructor(connectCallback: ConnectCallback,
disconnectCallback: DisconnectCallback,
minDistance: number,
groupRadius: number,
groupUpdatedCallback: GroupUpdatedCallback,
groupDeletedCallback: GroupDeletedCallback)
{
this.users = new Map<string, UserInterface>();
this.groups = new Set<Group>();
this.connectCallback = connectCallback;
this.disconnectCallback = disconnectCallback;
this.minDistance = minDistance;
this.groupRadius = groupRadius;
this.groupUpdatedCallback = groupUpdatedCallback;
this.groupDeletedCallback = groupDeletedCallback;
}
public getGroups(): Group[] {
return Array.from(this.groups.values());
}
public getUsers(): Map<string, UserInterface> {
return this.users;
}
public join(socket : Identificable, userPosition: PointInterface): void {
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.
});
// Let's call update position to trigger the join / leave room
this.updatePosition(socket, userPosition);
}
public leave(user : Identificable){
const userObj = this.users.get(user.userId);
if (userObj === undefined) {
console.warn('User ', user.userId, 'does not belong to world! It should!');
}
if (userObj !== undefined && typeof userObj.group !== 'undefined') {
this.leaveGroup(userObj);
}
this.users.delete(user.userId);
}
public isEmpty(): boolean {
return this.users.size === 0;
}
public updatePosition(socket : Identificable, userPosition: PointInterface): void {
const user = this.users.get(socket.userId);
if(typeof user === 'undefined') {
return;
}
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?
const closestItem: UserInterface|Group|null = this.searchClosestAvailableUserOrGroup(user);
if (closestItem !== null) {
if (closestItem instanceof Group) {
// Let's join the group!
closestItem.join(user);
} else {
const closestUser : UserInterface = closestItem;
const group: Group = new Group([
user,
closestUser
], this.connectCallback, this.disconnectCallback);
this.groups.add(group);
}
}
} else {
// If the user is part of a group:
// should he leave the group?
const distance = World.computeDistanceBetweenPositions(user.position, user.group.getPosition());
if (distance > this.groupRadius) {
this.leaveGroup(user);
}
}
// At the very end, if the user is part of a group, let's call the callback to update group position
if (typeof user.group !== 'undefined') {
this.groupUpdatedCallback(user.group);
}
}
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.
*
* @param user
*/
private leaveGroup(user: UserInterface): void {
const group = user.group;
if (typeof group === 'undefined') {
throw new Error("The user is part of no group");
}
group.leave(user);
if (group.isEmpty()) {
this.groupDeletedCallback(group.getId(), user);
group.destroy();
if (!this.groups.has(group)) {
throw new Error("Could not find group "+group.getId()+" referenced by user "+user.id+" in World.");
}
this.groups.delete(group);
} else {
this.groupUpdatedCallback(group);
}
}
/**
* 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)
*/
private searchClosestAvailableUserOrGroup(user: UserInterface): UserInterface|Group|null
{
let minimumDistanceFound: number = Math.max(this.minDistance, this.groupRadius);
let matchingItem: UserInterface | Group | null = null;
this.users.forEach((currentUser, userId) => {
// Let's only check users that are not part of a group
if (typeof currentUser.group !== 'undefined') {
return;
}
if(currentUser === user) {
return;
}
if (currentUser.silent) {
return;
}
const distance = World.computeDistance(user, currentUser); // compute distance between peers.
if(distance <= minimumDistanceFound && distance <= this.minDistance) {
minimumDistanceFound = distance;
matchingItem = currentUser;
}
/*if (typeof currentUser.group === 'undefined' || !currentUser.group.isFull()) {
// We found a user we can bind to.
return;
}*/
/*
if(context.groups.length > 0) {
context.groups.forEach(group => {
if(group.isPartOfGroup(userPosition)) { // Is the user in a group ?
if(group.isStillIn(userPosition)) { // Is the user leaving the group ? (is the user at more than max distance of each player)
// Should we split the group? (is each player reachable from the current player?)
// This is needed if
// A <==> B <==> C <===> D
// becomes A <==> B <=====> C <> D
// If C moves right, the distance between B and C is too great and we must form 2 groups
}
} else {
// If the user is in no group
// Is there someone in a group close enough and with room in the group ?
}
});
} else {
// Aucun groupe n'existe donc je stock les users assez proches de moi
let dist: Distance = {
distance: distance,
first: userPosition,
second: user // TODO: convertir en messageUserPosition
}
usersToBeGroupedWith.push(dist);
}
*/
});
this.groups.forEach((group: Group) => {
if (group.isFull()) {
return;
}
const distance = World.computeDistanceBetweenPositions(user.position, group.getPosition());
if(distance <= minimumDistanceFound && distance <= this.groupRadius) {
minimumDistanceFound = distance;
matchingItem = group;
}
});
return matchingItem;
}
public static computeDistance(user1: UserInterface, user2: UserInterface): number
{
return Math.sqrt(Math.pow(user2.position.x - user1.position.x, 2) + Math.pow(user2.position.y - user1.position.y, 2));
}
public static computeDistanceBetweenPositions(position1: PositionInterface, position2: PositionInterface): number
{
return Math.sqrt(Math.pow(position2.x - position1.x, 2) + Math.pow(position2.y - position1.y, 2));
}
/*getDistancesBetweenGroupUsers(group: Group): Distance[]
{
let i = 0;
let users = group.getUsers();
let distances: Distance[] = [];
users.forEach(function(user1, key1) {
users.forEach(function(user2, key2) {
if(key1 < key2) {
distances[i] = {
distance: World.computeDistance(user1, user2),
first: user1,
second: user2
};
i++;
}
});
});
distances.sort(World.compareDistances);
return distances;
}
filterGroup(distances: Distance[], group: Group): void
{
let users = group.getUsers();
let usersToRemove = false;
let groupTmp: MessageUserPosition[] = [];
distances.forEach(dist => {
if(dist.distance <= World.MIN_DISTANCE) {
let users = [dist.first];
let usersbis = [dist.second]
groupTmp.push(dist.first);
groupTmp.push(dist.second);
} else {
usersToRemove = true;
}
});
if(usersToRemove) {
// Detecte le ou les users qui se sont fait sortir du groupe
let difference = users.filter(x => !groupTmp.includes(x));
// TODO : Notify users un difference that they have left the group
}
let newgroup = new Group(groupTmp);
this.groups.push(newgroup);
}
private static compareDistances(distA: Distance, distB: Distance): number
{
if (distA.distance < distB.distance) {
return -1;
}
if (distA.distance > distB.distance) {
return 1;
}
return 0;
}*/
}

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

@ -0,0 +1,109 @@
import {User} from "./User";
import {PositionInterface} from "_Model/PositionInterface";
import {Movable} from "./Movable";
import {Group} from "./Group";
export type EntersCallback = (thing: Movable, listener: User) => void;
export type MovesCallback = (thing: Movable, position: PositionInterface, listener: User) => void;
export type LeavesCallback = (thing: Movable, listener: User) => void;
export class Zone {
private things: Set<Movable> = new Set<Movable>();
private listeners: Set<User> = new Set<User>();
/**
* @param x For debugging purpose only
* @param y For debugging purpose only
*/
constructor(private onEnters: EntersCallback, private onMoves: MovesCallback, private onLeaves: LeavesCallback, private x: number, private y: number) {
}
/**
* A user/thing leaves the zone
*/
public leave(thing: Movable, newZone: Zone|null) {
const result = this.things.delete(thing);
if (!result) {
if (thing instanceof User) {
throw new Error('Could not find user in zone '+thing.id);
}
if (thing instanceof Group) {
throw new Error('Could not find group '+thing.getId()+' in zone ('+this.x+','+this.y+'). Position of group: ('+thing.getPosition().x+','+thing.getPosition().y+')');
}
}
this.notifyLeft(thing, newZone);
}
/**
* Notify listeners of this zone that this user/thing left
*/
private notifyLeft(thing: Movable, newZone: Zone|null) {
for (const listener of this.listeners) {
if (listener !== thing && (newZone === null || !listener.listenedZones.has(newZone))) {
this.onLeaves(thing, listener);
}
}
}
public enter(thing: Movable, oldZone: Zone|null, position: PositionInterface) {
this.things.add(thing);
this.notifyEnter(thing, oldZone, position);
}
/**
* Notify listeners of this zone that this user entered
*/
private notifyEnter(thing: Movable, oldZone: Zone|null, position: PositionInterface) {
for (const listener of this.listeners) {
if (listener === thing) {
continue;
}
if (oldZone === null || !listener.listenedZones.has(oldZone)) {
this.onEnters(thing, listener);
} else {
this.onMoves(thing, position, listener);
}
}
}
public move(thing: Movable, position: PositionInterface) {
if (!this.things.has(thing)) {
this.things.add(thing);
this.notifyEnter(thing, null, position);
return;
}
for (const listener of this.listeners) {
if (listener !== thing) {
this.onMoves(thing,position, listener);
}
}
}
public startListening(listener: User): void {
for (const thing of this.things) {
if (thing !== listener) {
this.onEnters(thing, listener);
}
}
this.listeners.add(listener);
listener.listenedZones.add(this);
}
public stopListening(listener: User): void {
for (const thing of this.things) {
if (thing !== listener) {
this.onLeaves(thing, listener);
}
}
this.listeners.delete(listener);
listener.listenedZones.delete(this);
}
public getThings(): Set<Movable> {
return this.things;
}
}

View file

@ -0,0 +1,13 @@
import { App as _App, AppOptions } from 'uWebSockets.js';
import BaseApp from './baseapp';
import { extend } from './utils';
import { UwsApp } from './types';
class App extends (<UwsApp>_App) {
constructor(options: AppOptions = {}) {
super(options); // eslint-disable-line constructor-super
extend(this, new BaseApp());
}
}
export default App;

View file

@ -0,0 +1,116 @@
import { Readable } from 'stream';
import { us_listen_socket_close, TemplatedApp, HttpResponse, HttpRequest } from 'uWebSockets.js';
import formData from './formdata';
import { stob } from './utils';
import { Handler } from './types';
import {join} from "path";
const contTypes = ['application/x-www-form-urlencoded', 'multipart/form-data'];
const noOp = () => true;
const handleBody = (res: HttpResponse, req: HttpRequest) => {
const contType = req.getHeader('content-type');
res.bodyStream = function() {
const stream = new Readable();
stream._read = noOp; // eslint-disable-line @typescript-eslint/unbound-method
this.onData((ab: ArrayBuffer, isLast: boolean) => {
// uint and then slicing is bit faster than slice and then uint
stream.push(new Uint8Array(ab.slice((ab as any).byteOffset, ab.byteLength))); // eslint-disable-line @typescript-eslint/no-explicit-any
if (isLast) {
stream.push(null);
}
});
return stream;
};
res.body = () => stob(res.bodyStream());
if (contType.includes('application/json'))
res.json = async () => JSON.parse(await res.body());
if (contTypes.map(t => contType.includes(t)).includes(true))
res.formData = formData.bind(res, contType);
};
class BaseApp {
_sockets = new Map();
ws!: TemplatedApp['ws'];
get!: TemplatedApp['get'];
_post!: TemplatedApp['post'];
_put!: TemplatedApp['put'];
_patch!: TemplatedApp['patch'];
_listen!: TemplatedApp['listen'];
post(pattern: string, handler: Handler) {
if (typeof handler !== 'function')
throw Error(`handler should be a function, given ${typeof handler}.`);
this._post(pattern, (res, req) => {
handleBody(res, req);
handler(res, req);
});
return this;
}
put(pattern: string, handler: Handler) {
if (typeof handler !== 'function')
throw Error(`handler should be a function, given ${typeof handler}.`);
this._put(pattern, (res, req) => {
handleBody(res, req);
handler(res, req);
});
return this;
}
patch(pattern: string, handler: Handler) {
if (typeof handler !== 'function')
throw Error(`handler should be a function, given ${typeof handler}.`);
this._patch(pattern, (res, req) => {
handleBody(res, req);
handler(res, req);
});
return this;
}
listen(h: string | number, p: Function | number = noOp, cb?: Function) {
if (typeof p === 'number' && typeof h === 'string') {
this._listen(h, p, socket => {
this._sockets.set(p, socket);
if (cb === undefined) {
throw new Error('cb undefined');
}
cb(socket);
});
} else if (typeof h === 'number' && typeof p === 'function') {
this._listen(h, socket => {
this._sockets.set(h, socket);
p(socket);
});
} else {
throw Error(
'Argument types: (host: string, port: number, cb?: Function) | (port: number, cb?: Function)'
);
}
return this;
}
close(port: null | number = null) {
if (port) {
this._sockets.has(port) && us_listen_socket_close(this._sockets.get(port));
this._sockets.delete(port);
} else {
this._sockets.forEach(app => {
us_listen_socket_close(app);
});
this._sockets.clear();
}
return this;
}
}
export default BaseApp;

View file

@ -0,0 +1,100 @@
import { createWriteStream } from 'fs';
import { join, dirname } from 'path';
import Busboy from 'busboy';
import mkdirp from 'mkdirp';
function formData(
contType: string,
options: busboy.BusboyConfig & {
abortOnLimit?: boolean;
tmpDir?: string;
onFile?: (
fieldname: string,
file: NodeJS.ReadableStream,
filename: string,
encoding: string,
mimetype: string
) => string;
onField?: (fieldname: string, value: any) => void; // eslint-disable-line @typescript-eslint/no-explicit-any
filename?: (oldName: string) => string;
} = {}
) {
console.log('Enter form data');
options.headers = {
'content-type': contType
};
return new Promise((resolve, reject) => {
const busb = new Busboy(options);
const ret = {};
this.bodyStream().pipe(busb);
busb.on('limit', () => {
if (options.abortOnLimit) {
reject(Error('limit'));
}
});
busb.on('file', function(fieldname, file, filename, encoding, mimetype) {
const value: { filePath: string|undefined, filename: string, encoding:string, mimetype: string } = {
filename,
encoding,
mimetype,
filePath: undefined
};
if (typeof options.tmpDir === 'string') {
if (typeof options.filename === 'function') filename = options.filename(filename);
const fileToSave = join(options.tmpDir, filename);
mkdirp(dirname(fileToSave));
file.pipe(createWriteStream(fileToSave));
value.filePath = fileToSave;
}
if (typeof options.onFile === 'function') {
value.filePath =
options.onFile(fieldname, file, filename, encoding, mimetype) || value.filePath;
}
setRetValue(ret, fieldname, value);
});
busb.on('field', function(fieldname, value) {
if (typeof options.onField === 'function') options.onField(fieldname, value);
setRetValue(ret, fieldname, value);
});
busb.on('finish', function() {
resolve(ret);
});
busb.on('error', reject);
});
}
function setRetValue(
ret: { [x: string]: any }, // eslint-disable-line @typescript-eslint/no-explicit-any
fieldname: string,
value: { filename: string; encoding: string; mimetype: string; filePath?: string } | any // eslint-disable-line @typescript-eslint/no-explicit-any
) {
if (fieldname.endsWith('[]')) {
fieldname = fieldname.slice(0, fieldname.length - 2);
if (Array.isArray(ret[fieldname])) {
ret[fieldname].push(value);
} else {
ret[fieldname] = [value];
}
} else {
if (Array.isArray(ret[fieldname])) {
ret[fieldname].push(value);
} else if (ret[fieldname]) {
ret[fieldname] = [ret[fieldname], value];
} else {
ret[fieldname] = value;
}
}
}
export default formData;

View file

@ -0,0 +1,13 @@
import { SSLApp as _SSLApp, AppOptions } from 'uWebSockets.js';
import BaseApp from './baseapp';
import { extend } from './utils';
import { UwsApp } from './types';
class SSLApp extends (<UwsApp>_SSLApp) {
constructor(options: AppOptions) {
super(options); // eslint-disable-line constructor-super
extend(this, new BaseApp());
}
}
export default SSLApp;

View file

@ -0,0 +1,11 @@
import { AppOptions, TemplatedApp, HttpResponse, HttpRequest } from 'uWebSockets.js';
export type UwsApp = {
(options: AppOptions): TemplatedApp;
new (options: AppOptions): TemplatedApp;
prototype: TemplatedApp;
};
export type Handler = (res: HttpResponse, req: HttpRequest) => void;
export {};

View file

@ -0,0 +1,37 @@
import { ReadStream } from 'fs';
function extend(who: any, from: any, overwrite = true) { // eslint-disable-line @typescript-eslint/no-explicit-any
const ownProps = Object.getOwnPropertyNames(Object.getPrototypeOf(from)).concat(
Object.keys(from)
);
ownProps.forEach(prop => {
if (prop === 'constructor' || from[prop] === undefined) return;
if (who[prop] && overwrite) {
who[`_${prop}`] = who[prop];
}
if (typeof from[prop] === 'function') who[prop] = from[prop].bind(who);
else who[prop] = from[prop];
});
}
function stob(stream: ReadStream): Promise<Buffer> {
return new Promise(resolve => {
const buffers: Buffer[] = [];
stream.on('data', buffers.push.bind(buffers));
stream.on('end', () => {
switch (buffers.length) {
case 0:
resolve(Buffer.allocUnsafe(0));
break;
case 1:
resolve(buffers[0]);
break;
default:
resolve(Buffer.concat(buffers));
}
});
});
}
export { extend, stob };

View file

@ -0,0 +1,19 @@
import { parse } from 'query-string';
import { HttpRequest } from 'uWebSockets.js';
import App from './server/app';
import SSLApp from './server/sslapp';
import * as types from './server/types';
const getQuery = (req: HttpRequest) => {
return parse(req.getQuery());
};
export { App, SSLApp, getQuery };
export * from './server/types';
export default {
App,
SSLApp,
getQuery,
...types
};

View file

@ -0,0 +1,73 @@
import {ADMIN_API_TOKEN, ADMIN_API_URL} from "../Enum/EnvironmentVariable";
import Axios from "axios";
import {RoomIdentifier} from "../Model/RoomIdentifier";
export interface AdminApiData {
organizationSlug: string
worldSlug: string
roomSlug: string
mapUrlStart: string
userUuid: string
}
export interface GrantedApiData {
granted: boolean,
memberTags: string[]
}
class AdminApi {
async fetchMapDetails(organizationSlug: string, worldSlug: string, roomSlug: string|undefined): Promise<AdminApiData> {
if (!ADMIN_API_URL) {
return Promise.reject('No admin backoffice set!');
}
const params: { organizationSlug: string, worldSlug: string, roomSlug?: string } = {
organizationSlug,
worldSlug
};
if (roomSlug) {
params.roomSlug = roomSlug;
}
const res = await Axios.get(ADMIN_API_URL+'/api/map',
{
headers: {"Authorization" : `${ADMIN_API_TOKEN}`},
params
}
)
return res.data;
}
async fetchMemberDataByToken(organizationMemberToken: string): Promise<AdminApiData> {
if (!ADMIN_API_URL) {
return Promise.reject('No admin backoffice set!');
}
//todo: this call can fail if the corresponding world is not activated or if the token is invalid. Handle that case.
const res = await Axios.get(ADMIN_API_URL+'/api/login-url/'+organizationMemberToken,
{ headers: {"Authorization" : `${ADMIN_API_TOKEN}`} }
)
return res.data;
}
async memberIsGrantedAccessToRoom(memberId: string, roomIdentifier: RoomIdentifier): Promise<GrantedApiData> {
if (!ADMIN_API_URL) {
return Promise.reject('No admin backoffice set!');
}
try {
const res = await Axios.get(ADMIN_API_URL+'/api/member/is-granted-access',
{ headers: {"Authorization" : `${ADMIN_API_TOKEN}`}, params: {memberId, organizationSlug: roomIdentifier.organizationSlug, worldSlug: roomIdentifier.worldSlug, roomSlug: roomIdentifier.roomSlug} }
)
return res.data;
} catch (e) {
console.log(e.message)
return {
granted: false,
memberTags: []
};
}
}
}
export const adminApi = new AdminApi();

View file

@ -0,0 +1,55 @@
import {CPU_OVERHEAT_THRESHOLD} from "../Enum/EnvironmentVariable";
function secNSec2ms(secNSec: Array<number>|number) {
if (Array.isArray(secNSec)) {
return secNSec[0] * 1000 + secNSec[1] / 1000000;
}
return secNSec / 1000;
}
class CpuTracker {
private cpuPercent: number = 0;
private overHeating: boolean = false;
constructor() {
let time = process.hrtime.bigint()
let usage = process.cpuUsage()
setInterval(() => {
const elapTime = process.hrtime.bigint();
const elapUsage = process.cpuUsage(usage)
usage = process.cpuUsage()
const elapTimeMS = elapTime - time;
const elapUserMS = secNSec2ms(elapUsage.user)
const elapSystMS = secNSec2ms(elapUsage.system)
this.cpuPercent = Math.round(100 * (elapUserMS + elapSystMS) / Number(elapTimeMS) * 1000000)
time = elapTime;
if (!this.overHeating && this.cpuPercent > CPU_OVERHEAT_THRESHOLD) {
this.overHeating = true;
console.warn('CPU high threshold alert. Going in "overheat" mode');
} else if (this.overHeating && this.cpuPercent <= CPU_OVERHEAT_THRESHOLD) {
this.overHeating = false;
console.log('CPU is back to normal. Canceling "overheat" mode');
}
/*console.log('elapsed time ms: ', elapTimeMS)
console.log('elapsed user ms: ', elapUserMS)
console.log('elapsed system ms:', elapSystMS)
console.log('cpu percent: ', this.cpuPercent)*/
}, 100);
}
public getCpuPercent(): number {
return this.cpuPercent;
}
public isOverHeating(): boolean {
return this.overHeating;
}
}
const cpuTracker = new CpuTracker();
export { cpuTracker };

View file

@ -0,0 +1,60 @@
import {ALLOW_ARTILLERY, SECRET_KEY} from "../Enum/EnvironmentVariable";
import {uuid} from "uuidv4";
import Jwt from "jsonwebtoken";
import {TokenInterface} from "../Controller/AuthenticateController";
class JWTTokenManager {
public createJWTToken(userUuid: string) {
return Jwt.sign({userUuid: userUuid}, SECRET_KEY, {expiresIn: '24h'});
}
public async getUserUuidFromToken(token: unknown): Promise<string> {
if (!token) {
throw new Error('An authentication error happened, a user tried to connect without a token.');
}
if (typeof(token) !== "string") {
throw new Error('Token is expected to be a string');
}
if(token === 'test') {
if (ALLOW_ARTILLERY) {
return uuid();
} else {
throw new Error("In order to perform a load-testing test on this environment, you must set the ALLOW_ARTILLERY environment variable to 'true'");
}
}
return new Promise<string>((resolve, reject) => {
Jwt.verify(token, SECRET_KEY, {},(err, tokenDecoded) => {
const tokenInterface = tokenDecoded as TokenInterface;
if (err) {
console.error('An authentication error happened, invalid JsonWebToken.', err);
reject(new Error('An authentication error happened, invalid JsonWebToken. '+err.message));
return;
}
if (tokenDecoded === undefined) {
console.error('Empty token found.');
reject(new Error('Empty token found.'));
return;
}
if (!this.isValidToken(tokenInterface)) {
reject(new Error('Authentication error, invalid token structure.'));
return;
}
resolve(tokenInterface.userUuid);
});
});
}
private isValidToken(token: object): token is TokenInterface {
return !(typeof((token as TokenInterface).userUuid) !== 'string');
}
}
export const jwtTokenManager = new JWTTokenManager();

View file

@ -0,0 +1,176 @@
import "jasmine";
import {GameRoom, ConnectCallback, DisconnectCallback } from "_Model/GameRoom";
import {Point} from "../src/Model/Websocket/MessageUserPosition";
import { Group } from "../src/Model/Group";
import {PositionNotifier} from "../src/Model/PositionNotifier";
import {User} from "../src/Model/User";
import {PointInterface} from "../src/Model/Websocket/PointInterface";
import {Zone} from "_Model/Zone";
import {Movable} from "_Model/Movable";
import {PositionInterface} from "_Model/PositionInterface";
import {ExSocketInterface} from "_Model/Websocket/ExSocketInterface";
describe("PositionNotifier", () => {
it("should receive notifications when player moves", () => {
let enterTriggered = false;
let moveTriggered = false;
let leaveTriggered = false;
const positionNotifier = new PositionNotifier(300, 300, (thing: Movable) => {
enterTriggered = true;
}, (thing: Movable, position: PositionInterface) => {
moveTriggered = true;
}, (thing: Movable) => {
leaveTriggered = true;
});
const user1 = new User(1, {
x: 500,
y: 500,
moving: false,
direction: 'down'
}, false, positionNotifier, {} as ExSocketInterface);
const user2 = new User(2, {
x: -9999,
y: -9999,
moving: false,
direction: 'down'
}, false, positionNotifier, {} as ExSocketInterface);
positionNotifier.setViewport(user1, {
left: 200,
right: 600,
top: 100,
bottom: 500
});
user2.setPosition({x: 500, y: 500, direction: 'down', moving: false});
expect(enterTriggered).toBe(true);
expect(moveTriggered).toBe(false);
enterTriggered = false;
// Move inside the zone
user2.setPosition({x:501, y:500, direction: 'down', moving: false});
expect(enterTriggered).toBe(false);
expect(moveTriggered).toBe(true);
moveTriggered = false;
// Move out of the zone in a zone that we don't track
user2.setPosition({x: 901, y: 500, direction: 'down', moving: false});
expect(enterTriggered).toBe(false);
expect(moveTriggered).toBe(false);
expect(leaveTriggered).toBe(true);
leaveTriggered = false;
// Move back in
user2.setPosition({x: 500, y: 500, direction: 'down', moving: false});
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
user2.setPosition({x: 200, y: 500, direction: 'down', moving: false});
expect(enterTriggered).toBe(false);
expect(moveTriggered).toBe(true);
expect(leaveTriggered).toBe(false);
moveTriggered = false;
// Leave the room
positionNotifier.leave(user2);
positionNotifier.removeViewport(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, (thing: Movable) => {
enterTriggered = true;
}, (thing: Movable, position: PositionInterface) => {
moveTriggered = true;
}, (thing: Movable) => {
leaveTriggered = true;
});
const user1 = new User(1, {
x: 500,
y: 500,
moving: false,
direction: 'down'
}, false, positionNotifier, {} as ExSocketInterface);
const user2 = new User(2, {
x: 0,
y: 0,
moving: false,
direction: 'down'
}, false, positionNotifier, {} as ExSocketInterface);
let newUsers = positionNotifier.setViewport(user1, {
left: 200,
right: 600,
top: 100,
bottom: 500
});
expect(newUsers.length).toBe(2);
expect(enterTriggered).toBe(true);
enterTriggered = false;
user2.setPosition({x: 500, y: 500, direction: 'down', moving: false});
expect(enterTriggered).toBe(false);
expect(moveTriggered).toBe(true);
moveTriggered = 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(2);
});
})

View file

@ -1,60 +1,68 @@
import "jasmine";
import {World, ConnectCallback, DisconnectCallback } from "../src/Model/World";
import {GameRoom, ConnectCallback, DisconnectCallback } from "../src/Model/GameRoom";
import {Point} from "../src/Model/Websocket/MessageUserPosition";
import { Group } from "../src/Model/Group";
import {ExSocketInterface} from "_Model/Websocket/ExSocketInterface";
import {User} from "_Model/User";
function createMockUser(userId: number): ExSocketInterface {
return {
userId
} as ExSocketInterface;
}
describe("World", () => {
it("should connect user1 and user2", () => {
let connectCalledNumber: number = 0;
const connect: ConnectCallback = (user: string, group: Group): void => {
const connect: ConnectCallback = (user: User, group: Group): void => {
connectCalledNumber++;
}
const disconnect: DisconnectCallback = (user: string, group: Group): void => {
const disconnect: DisconnectCallback = (user: User, group: Group): void => {
}
const world = new World(connect, disconnect, 160, 160, () => {}, () => {});
const world = new GameRoom(connect, disconnect, 160, 160, () => {}, () => {}, () => {});
world.join({ userId: "foo" }, new Point(100, 100));
world.join(createMockUser(1), new Point(100, 100));
world.join({ userId: "bar" }, new Point(500, 100));
world.join(createMockUser(2), new Point(500, 100));
world.updatePosition({ userId: "bar" }, new Point(261, 100));
world.updatePosition({ userId: 2 }, new Point(261, 100));
expect(connectCalledNumber).toBe(0);
world.updatePosition({ userId: "bar" }, new Point(101, 100));
world.updatePosition({ userId: 2 }, new Point(101, 100));
expect(connectCalledNumber).toBe(2);
world.updatePosition({ userId: "bar" }, new Point(102, 100));
world.updatePosition({ userId: 2 }, new Point(102, 100));
expect(connectCalledNumber).toBe(2);
});
it("should connect 3 users", () => {
let connectCalled: boolean = false;
const connect: ConnectCallback = (user: string, group: Group): void => {
const connect: ConnectCallback = (user: User, group: Group): void => {
connectCalled = true;
}
const disconnect: DisconnectCallback = (user: string, group: Group): void => {
const disconnect: DisconnectCallback = (user: User, group: Group): void => {
}
const world = new World(connect, disconnect, 160, 160, () => {}, () => {});
const world = new GameRoom(connect, disconnect, 160, 160, () => {}, () => {}, () => {});
world.join({ userId: "foo" }, new Point(100, 100));
world.join(createMockUser(1), new Point(100, 100));
world.join({ userId: "bar" }, new Point(200, 100));
world.join(createMockUser(2), new Point(200, 100));
expect(connectCalled).toBe(true);
connectCalled = false;
// baz joins at the outer limit of the group
world.join({ userId: "baz" }, new Point(311, 100));
world.join(createMockUser(3), new Point(311, 100));
expect(connectCalled).toBe(false);
world.updatePosition({ userId: "baz" }, new Point(309, 100));
world.updatePosition({ userId: 3 }, new Point(309, 100));
expect(connectCalled).toBe(true);
});
@ -62,27 +70,27 @@ describe("World", () => {
it("should disconnect user1 and user2", () => {
let connectCalled: boolean = false;
let disconnectCallNumber: number = 0;
const connect: ConnectCallback = (user: string, group: Group): void => {
const connect: ConnectCallback = (user: User, group: Group): void => {
connectCalled = true;
}
const disconnect: DisconnectCallback = (user: string, group: Group): void => {
const disconnect: DisconnectCallback = (user: User, group: Group): void => {
disconnectCallNumber++;
}
const world = new World(connect, disconnect, 160, 160, () => {}, () => {});
const world = new GameRoom(connect, disconnect, 160, 160, () => {}, () => {}, () => {});
world.join({ userId: "foo" }, new Point(100, 100));
world.join(createMockUser(1), new Point(100, 100));
world.join({ userId: "bar" }, new Point(259, 100));
world.join(createMockUser(2), new Point(259, 100));
expect(connectCalled).toBe(true);
expect(disconnectCallNumber).toBe(0);
world.updatePosition({ userId: "bar" }, new Point(100+160+160+1, 100));
world.updatePosition({ userId: 2 }, new Point(100+160+160+1, 100));
expect(disconnectCallNumber).toBe(2);
world.updatePosition({ userId: "bar" }, new Point(262, 100));
world.updatePosition({ userId: 2 }, new Point(262, 100));
expect(disconnectCallNumber).toBe(2);
});

View file

@ -7,12 +7,12 @@
"downlevelIteration": true,
"module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */
// "lib": [], /* Specify library files to be included in the compilation. */
// "allowJs": true, /* Allow javascript files to be compiled. */
"allowJs": true, /* Allow javascript files to be compiled. */
// "checkJs": true, /* Report errors in .js files. */
// "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. */
@ -31,7 +31,7 @@
// "strictFunctionTypes": true, /* Enable strict checking of function types. */
// "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */
// "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */
// "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */
"noImplicitThis": false, /* Raise error on 'this' expressions with an implied 'any' type. */ // Disabled because of sifrr server that is monkey patching HttpResponse
// "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */
/* Additional Checks */

File diff suppressed because it is too large Load diff

3
benchmark/.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
/node_modules/
/artillery_output.html
/artillery_output.json

View file

@ -1,17 +0,0 @@
#!/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

View file

@ -0,0 +1,15 @@
#!/bin/bash
yarn run start &
pid1=$!
yarn run start &
pid2=$!
yarn run start &
pid3=$!
yarn run start &
pid4=$!
wait $pid1
wait $pid2
wait $pid3
wait $pid4

56
benchmark/index.ts Normal file
View file

@ -0,0 +1,56 @@
import {RoomConnection} from "../front/src/Connexion/RoomConnection";
import {connectionManager} from "../front/src/Connexion/ConnectionManager";
import * as WebSocket from "ws"
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
RoomConnection.setWebsocketFactory((url: string) => {
return new WebSocket(url);
});
async function startOneUser(): Promise<void> {
const connection = await connectionManager.connectToRoomSocket();
connection.emitPlayerDetailsMessage('foo', ['male3']);
await connection.joinARoom('global__maps.workadventure.localhost/Floor0/floor0', 783, 170, 'down', true, {
top: 0,
bottom: 200,
left: 500,
right: 800
});
console.log(connection.getUserId());
let angle = Math.random() * Math.PI * 2;
for (let i = 0; i < 100; i++) {
const x = Math.floor(320 + 1472/2 * (1 + Math.sin(angle)));
const y = Math.floor(200 + 1090/2 * (1 + Math.cos(angle)));
connection.sharePosition(x, y, 'down', true, {
top: y - 200,
bottom: y + 200,
left: x - 320,
right: x + 320
})
angle += 0.05;
await sleep(200);
}
await sleep(10000);
connection.closeConnection();
}
(async () => {
connectionManager.initBenchmark();
for (let userNo = 0; userNo < 160; userNo++) {
startOneUser();
// Wait 0.5s between adding users
await sleep(125);
}
})();

1970
benchmark/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -3,8 +3,7 @@
"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"
"start": "ts-node ./index.ts"
},
"contributors": [
{
@ -22,6 +21,10 @@
],
"license": "SEE LICENSE IN LICENSE.txt",
"dependencies": {
"artillery": "^1.6.1"
}
"@types/ws": "^7.2.6",
"ts-node-dev": "^1.0.0-pre.62",
"typescript": "^4.0.2",
"ws": "^7.3.1"
},
"devDependencies": {}
}

View file

@ -1,43 +0,0 @@
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
- think: 1
- loop:
- function: "setYRandom"
- emit:
channel: "user-position"
data:
x: "{{ x }}"
y: "{{ y }}"
direction: 'down'
moving: false
- think: 0.2
count: 100
- think: 10

View file

@ -1,11 +0,0 @@
'use strict';
module.exports = {
setYRandom
};
function setYRandom(context, events, done) {
context.vars.x = (883 + Math.round(Math.random() * 300));
context.vars.y = (270 + Math.round(Math.random() * 300));
return done();
}

528
benchmark/yarn.lock Normal file
View file

@ -0,0 +1,528 @@
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
# yarn lockfile v1
"@types/node@*":
version "14.11.2"
resolved "https://registry.yarnpkg.com/@types/node/-/node-14.11.2.tgz#2de1ed6670439387da1c9f549a2ade2b0a799256"
"@types/strip-bom@^3.0.0":
version "3.0.0"
resolved "https://registry.yarnpkg.com/@types/strip-bom/-/strip-bom-3.0.0.tgz#14a8ec3956c2e81edb7520790aecf21c290aebd2"
"@types/strip-json-comments@0.0.30":
version "0.0.30"
resolved "https://registry.yarnpkg.com/@types/strip-json-comments/-/strip-json-comments-0.0.30.tgz#9aa30c04db212a9a0649d6ae6fd50accc40748a1"
"@types/ws@^7.2.6":
version "7.2.6"
resolved "https://registry.yarnpkg.com/@types/ws/-/ws-7.2.6.tgz#516cbfb818310f87b43940460e065eb912a4178d"
dependencies:
"@types/node" "*"
anymatch@~3.1.1:
version "3.1.1"
resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-3.1.1.tgz#c55ecf02185e2469259399310c173ce31233b142"
dependencies:
normalize-path "^3.0.0"
picomatch "^2.0.4"
arg@^4.1.0:
version "4.1.3"
resolved "https://registry.yarnpkg.com/arg/-/arg-4.1.3.tgz#269fc7ad5b8e42cb63c896d5666017261c144089"
array-find-index@^1.0.1:
version "1.0.2"
resolved "https://registry.yarnpkg.com/array-find-index/-/array-find-index-1.0.2.tgz#df010aa1287e164bbda6f9723b0a96a1ec4187a1"
balanced-match@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.0.tgz#89b4d199ab2bee49de164ea02b89ce462d71b767"
binary-extensions@^2.0.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.1.0.tgz#30fa40c9e7fe07dbc895678cd287024dea241dd9"
brace-expansion@^1.1.7:
version "1.1.11"
resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd"
dependencies:
balanced-match "^1.0.0"
concat-map "0.0.1"
braces@~3.0.2:
version "3.0.2"
resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.2.tgz#3454e1a462ee8d599e236df336cd9ea4f8afe107"
dependencies:
fill-range "^7.0.1"
buffer-from@^1.0.0:
version "1.1.1"
resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.1.tgz#32713bc028f75c02fdb710d7c7bcec1f2c6070ef"
camelcase-keys@^2.0.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/camelcase-keys/-/camelcase-keys-2.1.0.tgz#308beeaffdf28119051efa1d932213c91b8f92e7"
dependencies:
camelcase "^2.0.0"
map-obj "^1.0.0"
camelcase@^2.0.0:
version "2.1.1"
resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-2.1.1.tgz#7c1d16d679a1bbe59ca02cacecfb011e201f5a1f"
chokidar@^3.4.0:
version "3.4.2"
resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.4.2.tgz#38dc8e658dec3809741eb3ef7bb0a47fe424232d"
dependencies:
anymatch "~3.1.1"
braces "~3.0.2"
glob-parent "~5.1.0"
is-binary-path "~2.1.0"
is-glob "~4.0.1"
normalize-path "~3.0.0"
readdirp "~3.4.0"
optionalDependencies:
fsevents "~2.1.2"
concat-map@0.0.1:
version "0.0.1"
resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b"
currently-unhandled@^0.4.1:
version "0.4.1"
resolved "https://registry.yarnpkg.com/currently-unhandled/-/currently-unhandled-0.4.1.tgz#988df33feab191ef799a61369dd76c17adf957ea"
dependencies:
array-find-index "^1.0.1"
dateformat@~1.0.4-1.2.3:
version "1.0.12"
resolved "https://registry.yarnpkg.com/dateformat/-/dateformat-1.0.12.tgz#9f124b67594c937ff706932e4a642cca8dbbfee9"
dependencies:
get-stdin "^4.0.1"
meow "^3.3.0"
decamelize@^1.1.2:
version "1.2.0"
resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-1.2.0.tgz#f6534d15148269b20352e7bee26f501f9a191290"
diff@^4.0.1:
version "4.0.2"
resolved "https://registry.yarnpkg.com/diff/-/diff-4.0.2.tgz#60f3aecb89d5fae520c11aa19efc2bb982aade7d"
dynamic-dedupe@^0.3.0:
version "0.3.0"
resolved "https://registry.yarnpkg.com/dynamic-dedupe/-/dynamic-dedupe-0.3.0.tgz#06e44c223f5e4e94d78ef9db23a6515ce2f962a1"
dependencies:
xtend "^4.0.0"
error-ex@^1.2.0:
version "1.3.2"
resolved "https://registry.yarnpkg.com/error-ex/-/error-ex-1.3.2.tgz#b4ac40648107fdcdcfae242f428bea8a14d4f1bf"
dependencies:
is-arrayish "^0.2.1"
fill-range@^7.0.1:
version "7.0.1"
resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-7.0.1.tgz#1919a6a7c75fe38b2c7c77e5198535da9acdda40"
dependencies:
to-regex-range "^5.0.1"
find-up@^1.0.0:
version "1.1.2"
resolved "https://registry.yarnpkg.com/find-up/-/find-up-1.1.2.tgz#6b2e9822b1a2ce0a60ab64d610eccad53cb24d0f"
dependencies:
path-exists "^2.0.0"
pinkie-promise "^2.0.0"
fs.realpath@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f"
fsevents@~2.1.2:
version "2.1.3"
resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.1.3.tgz#fb738703ae8d2f9fe900c33836ddebee8b97f23e"
get-stdin@^4.0.1:
version "4.0.1"
resolved "https://registry.yarnpkg.com/get-stdin/-/get-stdin-4.0.1.tgz#b968c6b0a04384324902e8bf1a5df32579a450fe"
glob-parent@~5.1.0:
version "5.1.1"
resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-5.1.1.tgz#b6c1ef417c4e5663ea498f1c45afac6916bbc229"
dependencies:
is-glob "^4.0.1"
glob@^7.1.3:
version "7.1.6"
resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.6.tgz#141f33b81a7c2492e125594307480c46679278a6"
dependencies:
fs.realpath "^1.0.0"
inflight "^1.0.4"
inherits "2"
minimatch "^3.0.4"
once "^1.3.0"
path-is-absolute "^1.0.0"
graceful-fs@^4.1.2:
version "4.2.4"
resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.4.tgz#2256bde14d3632958c465ebc96dc467ca07a29fb"
hosted-git-info@^2.1.4:
version "2.8.8"
resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-2.8.8.tgz#7539bd4bc1e0e0a895815a2e0262420b12858488"
indent-string@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/indent-string/-/indent-string-2.1.0.tgz#8e2d48348742121b4a8218b7a137e9a52049dc80"
dependencies:
repeating "^2.0.0"
inflight@^1.0.4:
version "1.0.6"
resolved "https://registry.yarnpkg.com/inflight/-/inflight-1.0.6.tgz#49bd6331d7d02d0c09bc910a1075ba8165b56df9"
dependencies:
once "^1.3.0"
wrappy "1"
inherits@2:
version "2.0.4"
resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c"
is-arrayish@^0.2.1:
version "0.2.1"
resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.2.1.tgz#77c99840527aa8ecb1a8ba697b80645a7a926a9d"
is-binary-path@~2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/is-binary-path/-/is-binary-path-2.1.0.tgz#ea1f7f3b80f064236e83470f86c09c254fb45b09"
dependencies:
binary-extensions "^2.0.0"
is-extglob@^2.1.1:
version "2.1.1"
resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2"
is-finite@^1.0.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/is-finite/-/is-finite-1.1.0.tgz#904135c77fb42c0641d6aa1bcdbc4daa8da082f3"
is-glob@^4.0.1, is-glob@~4.0.1:
version "4.0.1"
resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-4.0.1.tgz#7567dbe9f2f5e2467bc77ab83c4a29482407a5dc"
dependencies:
is-extglob "^2.1.1"
is-number@^7.0.0:
version "7.0.0"
resolved "https://registry.yarnpkg.com/is-number/-/is-number-7.0.0.tgz#7535345b896734d5f80c4d06c50955527a14f12b"
is-utf8@^0.2.0:
version "0.2.1"
resolved "https://registry.yarnpkg.com/is-utf8/-/is-utf8-0.2.1.tgz#4b0da1442104d1b336340e80797e865cf39f7d72"
load-json-file@^1.0.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/load-json-file/-/load-json-file-1.1.0.tgz#956905708d58b4bab4c2261b04f59f31c99374c0"
dependencies:
graceful-fs "^4.1.2"
parse-json "^2.2.0"
pify "^2.0.0"
pinkie-promise "^2.0.0"
strip-bom "^2.0.0"
loud-rejection@^1.0.0:
version "1.6.0"
resolved "https://registry.yarnpkg.com/loud-rejection/-/loud-rejection-1.6.0.tgz#5b46f80147edee578870f086d04821cf998e551f"
dependencies:
currently-unhandled "^0.4.1"
signal-exit "^3.0.0"
make-error@^1.1.1:
version "1.3.6"
resolved "https://registry.yarnpkg.com/make-error/-/make-error-1.3.6.tgz#2eb2e37ea9b67c4891f684a1394799af484cf7a2"
map-obj@^1.0.0, map-obj@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/map-obj/-/map-obj-1.0.1.tgz#d933ceb9205d82bdcf4886f6742bdc2b4dea146d"
meow@^3.3.0:
version "3.7.0"
resolved "https://registry.yarnpkg.com/meow/-/meow-3.7.0.tgz#72cb668b425228290abbfa856892587308a801fb"
dependencies:
camelcase-keys "^2.0.0"
decamelize "^1.1.2"
loud-rejection "^1.0.0"
map-obj "^1.0.1"
minimist "^1.1.3"
normalize-package-data "^2.3.4"
object-assign "^4.0.1"
read-pkg-up "^1.0.1"
redent "^1.0.0"
trim-newlines "^1.0.0"
minimatch@^3.0.4:
version "3.0.4"
resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.0.4.tgz#5166e286457f03306064be5497e8dbb0c3d32083"
dependencies:
brace-expansion "^1.1.7"
minimist@^1.1.3, minimist@^1.2.5:
version "1.2.5"
resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.5.tgz#67d66014b66a6a8aaa0c083c5fd58df4e4e97602"
mkdirp@^1.0.4:
version "1.0.4"
resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-1.0.4.tgz#3eb5ed62622756d79a5f0e2a221dfebad75c2f7e"
normalize-package-data@^2.3.2, normalize-package-data@^2.3.4:
version "2.5.0"
resolved "https://registry.yarnpkg.com/normalize-package-data/-/normalize-package-data-2.5.0.tgz#e66db1838b200c1dfc233225d12cb36520e234a8"
dependencies:
hosted-git-info "^2.1.4"
resolve "^1.10.0"
semver "2 || 3 || 4 || 5"
validate-npm-package-license "^3.0.1"
normalize-path@^3.0.0, normalize-path@~3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-3.0.0.tgz#0dcd69ff23a1c9b11fd0978316644a0388216a65"
object-assign@^4.0.1:
version "4.1.1"
resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863"
once@^1.3.0:
version "1.4.0"
resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1"
dependencies:
wrappy "1"
parse-json@^2.2.0:
version "2.2.0"
resolved "https://registry.yarnpkg.com/parse-json/-/parse-json-2.2.0.tgz#f480f40434ef80741f8469099f8dea18f55a4dc9"
dependencies:
error-ex "^1.2.0"
path-exists@^2.0.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-2.1.0.tgz#0feb6c64f0fc518d9a754dd5efb62c7022761f4b"
dependencies:
pinkie-promise "^2.0.0"
path-is-absolute@^1.0.0:
version "1.0.1"
resolved "https://registry.yarnpkg.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f"
path-parse@^1.0.6:
version "1.0.6"
resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.6.tgz#d62dbb5679405d72c4737ec58600e9ddcf06d24c"
path-type@^1.0.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/path-type/-/path-type-1.1.0.tgz#59c44f7ee491da704da415da5a4070ba4f8fe441"
dependencies:
graceful-fs "^4.1.2"
pify "^2.0.0"
pinkie-promise "^2.0.0"
picomatch@^2.0.4, picomatch@^2.2.1:
version "2.2.2"
resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.2.2.tgz#21f333e9b6b8eaff02468f5146ea406d345f4dad"
pify@^2.0.0:
version "2.3.0"
resolved "https://registry.yarnpkg.com/pify/-/pify-2.3.0.tgz#ed141a6ac043a849ea588498e7dca8b15330e90c"
pinkie-promise@^2.0.0:
version "2.0.1"
resolved "https://registry.yarnpkg.com/pinkie-promise/-/pinkie-promise-2.0.1.tgz#2135d6dfa7a358c069ac9b178776288228450ffa"
dependencies:
pinkie "^2.0.0"
pinkie@^2.0.0:
version "2.0.4"
resolved "https://registry.yarnpkg.com/pinkie/-/pinkie-2.0.4.tgz#72556b80cfa0d48a974e80e77248e80ed4f7f870"
read-pkg-up@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/read-pkg-up/-/read-pkg-up-1.0.1.tgz#9d63c13276c065918d57f002a57f40a1b643fb02"
dependencies:
find-up "^1.0.0"
read-pkg "^1.0.0"
read-pkg@^1.0.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/read-pkg/-/read-pkg-1.1.0.tgz#f5ffaa5ecd29cb31c0474bca7d756b6bb29e3f28"
dependencies:
load-json-file "^1.0.0"
normalize-package-data "^2.3.2"
path-type "^1.0.0"
readdirp@~3.4.0:
version "3.4.0"
resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-3.4.0.tgz#9fdccdf9e9155805449221ac645e8303ab5b9ada"
dependencies:
picomatch "^2.2.1"
redent@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/redent/-/redent-1.0.0.tgz#cf916ab1fd5f1f16dfb20822dd6ec7f730c2afde"
dependencies:
indent-string "^2.1.0"
strip-indent "^1.0.1"
repeating@^2.0.0:
version "2.0.1"
resolved "https://registry.yarnpkg.com/repeating/-/repeating-2.0.1.tgz#5214c53a926d3552707527fbab415dbc08d06dda"
dependencies:
is-finite "^1.0.0"
resolve@^1.0.0, resolve@^1.10.0:
version "1.17.0"
resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.17.0.tgz#b25941b54968231cc2d1bb76a79cb7f2c0bf8444"
dependencies:
path-parse "^1.0.6"
rimraf@^2.6.1:
version "2.7.1"
resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-2.7.1.tgz#35797f13a7fdadc566142c29d4f07ccad483e3ec"
dependencies:
glob "^7.1.3"
"semver@2 || 3 || 4 || 5":
version "5.7.1"
resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.1.tgz#a954f931aeba508d307bbf069eff0c01c96116f7"
signal-exit@^3.0.0:
version "3.0.3"
resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.3.tgz#a1410c2edd8f077b08b4e253c8eacfcaf057461c"
source-map-support@^0.5.12, source-map-support@^0.5.17:
version "0.5.19"
resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.19.tgz#a98b62f86dcaf4f67399648c085291ab9e8fed61"
dependencies:
buffer-from "^1.0.0"
source-map "^0.6.0"
source-map@^0.6.0:
version "0.6.1"
resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263"
spdx-correct@^3.0.0:
version "3.1.1"
resolved "https://registry.yarnpkg.com/spdx-correct/-/spdx-correct-3.1.1.tgz#dece81ac9c1e6713e5f7d1b6f17d468fa53d89a9"
dependencies:
spdx-expression-parse "^3.0.0"
spdx-license-ids "^3.0.0"
spdx-exceptions@^2.1.0:
version "2.3.0"
resolved "https://registry.yarnpkg.com/spdx-exceptions/-/spdx-exceptions-2.3.0.tgz#3f28ce1a77a00372683eade4a433183527a2163d"
spdx-expression-parse@^3.0.0:
version "3.0.1"
resolved "https://registry.yarnpkg.com/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz#cf70f50482eefdc98e3ce0a6833e4a53ceeba679"
dependencies:
spdx-exceptions "^2.1.0"
spdx-license-ids "^3.0.0"
spdx-license-ids@^3.0.0:
version "3.0.6"
resolved "https://registry.yarnpkg.com/spdx-license-ids/-/spdx-license-ids-3.0.6.tgz#c80757383c28abf7296744998cbc106ae8b854ce"
strip-bom@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/strip-bom/-/strip-bom-2.0.0.tgz#6219a85616520491f35788bdbf1447a99c7e6b0e"
dependencies:
is-utf8 "^0.2.0"
strip-bom@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/strip-bom/-/strip-bom-3.0.0.tgz#2334c18e9c759f7bdd56fdef7e9ae3d588e68ed3"
strip-indent@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/strip-indent/-/strip-indent-1.0.1.tgz#0c7962a6adefa7bbd4ac366460a638552ae1a0a2"
dependencies:
get-stdin "^4.0.1"
strip-json-comments@^2.0.0:
version "2.0.1"
resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-2.0.1.tgz#3c531942e908c2697c0ec344858c286c7ca0a60a"
to-regex-range@^5.0.1:
version "5.0.1"
resolved "https://registry.yarnpkg.com/to-regex-range/-/to-regex-range-5.0.1.tgz#1648c44aae7c8d988a326018ed72f5b4dd0392e4"
dependencies:
is-number "^7.0.0"
tree-kill@^1.2.2:
version "1.2.2"
resolved "https://registry.yarnpkg.com/tree-kill/-/tree-kill-1.2.2.tgz#4ca09a9092c88b73a7cdc5e8a01b507b0790a0cc"
trim-newlines@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/trim-newlines/-/trim-newlines-1.0.0.tgz#5887966bb582a4503a41eb524f7d35011815a613"
ts-node-dev@^1.0.0-pre.62:
version "1.0.0-pre.62"
resolved "https://registry.yarnpkg.com/ts-node-dev/-/ts-node-dev-1.0.0-pre.62.tgz#835644c43669b659a880379b9d06df86cef665ad"
dependencies:
chokidar "^3.4.0"
dateformat "~1.0.4-1.2.3"
dynamic-dedupe "^0.3.0"
minimist "^1.2.5"
mkdirp "^1.0.4"
resolve "^1.0.0"
rimraf "^2.6.1"
source-map-support "^0.5.12"
tree-kill "^1.2.2"
ts-node "^8.10.2"
tsconfig "^7.0.0"
ts-node@^8.10.2:
version "8.10.2"
resolved "https://registry.yarnpkg.com/ts-node/-/ts-node-8.10.2.tgz#eee03764633b1234ddd37f8db9ec10b75ec7fb8d"
dependencies:
arg "^4.1.0"
diff "^4.0.1"
make-error "^1.1.1"
source-map-support "^0.5.17"
yn "3.1.1"
tsconfig@^7.0.0:
version "7.0.0"
resolved "https://registry.yarnpkg.com/tsconfig/-/tsconfig-7.0.0.tgz#84538875a4dc216e5c4a5432b3a4dec3d54e91b7"
dependencies:
"@types/strip-bom" "^3.0.0"
"@types/strip-json-comments" "0.0.30"
strip-bom "^3.0.0"
strip-json-comments "^2.0.0"
typescript@^4.0.2:
version "4.0.2"
resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.0.2.tgz#7ea7c88777c723c681e33bf7988be5d008d05ac2"
validate-npm-package-license@^3.0.1:
version "3.0.4"
resolved "https://registry.yarnpkg.com/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz#fc91f6b9c7ba15c857f4cb2c5defeec39d4f410a"
dependencies:
spdx-correct "^3.0.0"
spdx-expression-parse "^3.0.0"
wrappy@1:
version "1.0.2"
resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f"
ws@^7.3.1:
version "7.3.1"
resolved "https://registry.yarnpkg.com/ws/-/ws-7.3.1.tgz#d0547bf67f7ce4f12a72dfe31262c68d7dc551c8"
xtend@^4.0.0:
version "4.0.2"
resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.2.tgz#bb72779f5fa465186b1f438f674fa347fdb5db54"
yn@3.1.1:
version "3.1.1"
resolved "https://registry.yarnpkg.com/yn/-/yn-3.1.1.tgz#1e87401a09d767c1d5eab26a6e4c185182d2eb50"

View file

@ -14,7 +14,9 @@
},
"ports": [8080],
"env": {
"SECRET_KEY": "tempSecretKeyNeedsToChange"
"SECRET_KEY": "tempSecretKeyNeedsToChange",
"ADMIN_API_TOKEN": env.ADMIN_API_TOKEN,
"ADMIN_API_URL": "https://admin."+url
}
},
"front": {
@ -32,6 +34,14 @@
"TURN_PASSWORD": "WorkAdventure123"
}
},
"maps": {
"image": "thecodingmachine/workadventure-maps:"+tag,
"host": {
"url": "maps."+url,
"https": "enable"
},
"ports": [80]
},
"website": {
"image": "thecodingmachine/workadventure-website:"+tag,
"host": {

View file

@ -19,7 +19,7 @@ services:
- /var/run/docker.sock:/var/run/docker.sock
front:
image: thecodingmachine/nodejs:12
image: thecodingmachine/nodejs:14
environment:
DEBUG_MODE: "$DEBUG_MODE"
JITSI_URL: $JITSI_URL
@ -42,6 +42,28 @@ services:
- "traefik.http.routers.front-ssl.tls=true"
- "traefik.http.routers.front-ssl.service=front"
maps:
image: thecodingmachine/nodejs:12-apache
environment:
DEBUG_MODE: "$DEBUG_MODE"
HOST: "0.0.0.0"
NODE_ENV: development
#APACHE_DOCUMENT_ROOT: dist/
#APACHE_EXTENSIONS: headers
#APACHE_EXTENSION_HEADERS: 1
STARTUP_COMMAND_0: sudo a2enmod headers
STARTUP_COMMAND_1: yarn install
STARTUP_COMMAND_2: yarn run dev &
volumes:
- ./maps:/var/www/html
labels:
- "traefik.http.routers.maps.rule=Host(`maps.workadventure.localhost`)"
- "traefik.http.routers.maps.entryPoints=web,traefik"
- "traefik.http.services.maps.loadbalancer.server.port=80"
- "traefik.http.routers.maps-ssl.rule=Host(`maps.workadventure.localhost`)"
- "traefik.http.routers.maps-ssl.entryPoints=websecure"
- "traefik.http.routers.maps-ssl.tls=true"
- "traefik.http.routers.maps-ssl.service=maps"
back:
image: thecodingmachine/nodejs:12
@ -51,6 +73,7 @@ services:
STARTUP_COMMAND_1: yarn install
SECRET_KEY: yourSecretKey
ALLOW_ARTILLERY: "true"
ADMIN_API_TOKEN: "$ADMIN_API_TOKEN"
volumes:
- ./back:/usr/src/app
labels:
@ -79,3 +102,13 @@ services:
- "traefik.http.routers.website-ssl.entryPoints=websecure"
- "traefik.http.routers.website-ssl.tls=true"
- "traefik.http.routers.website-ssl.service=website"
messages:
image: thecodingmachine/workadventure-back-base:latest
environment:
STARTUP_COMMAND_1: yarn install
STARTUP_COMMAND_2: yarn run proto:watch
volumes:
- ./messages:/usr/src/app
- ./back:/usr/src/back
- ./front:/usr/src/front

1
front/.gitignore vendored
View file

@ -4,3 +4,4 @@
/dist/webpack.config.js
/dist/webpack.config.js.map
/dist/src
*.sh

View file

@ -1,7 +1,13 @@
# we are rebuilding on each deploy to cope with the API_URL environment URL
FROM thecodingmachine/nodejs:12-apache
FROM thecodingmachine/workadventure-back-base:latest as builder
WORKDIR /var/www/messages
COPY --chown=docker:docker messages .
RUN yarn install && yarn proto
COPY --chown=docker:docker . .
# we are rebuilding on each deploy to cope with the API_URL environment URL
FROM thecodingmachine/nodejs:14-apache
COPY --chown=docker:docker front .
COPY --from=builder --chown=docker:docker /var/www/messages/generated /var/www/html/src/Messages/generated
RUN yarn install
ENV NODE_ENV=production

View file

@ -20,4 +20,5 @@ RewriteBase /
# We only want to let Apache serve files and not directories.
# Rewrite all other queries starting with _ to index.ts.
RewriteCond %{REQUEST_FILENAME} !-f
RewriteRule "^_/" "/index.html" [L]
RewriteRule "^[_@]/" "/index.html" [L]
RewriteRule "^register/" "/index.html" [L]

View file

@ -15,6 +15,7 @@
gtag('config', 'UA-10196481-11');
</script>
<link rel="apple-touch-icon" sizes="57x57" href="static/images/favicons/apple-icon-57x57.png">
<link rel="apple-touch-icon" sizes="60x60" href="static/images/favicons/apple-icon-60x60.png">
<link rel="apple-touch-icon" sizes="72x72" href="static/images/favicons/apple-icon-72x72.png">
@ -39,7 +40,8 @@
<title>WorkAdventure</title>
</head>
<body id="body" style="margin: 0">
<div class="main-container">
<div class="main-container" id="main-container">
<!-- Create the editor container -->
<div id="game" class="game" style="/*background: red;*/">
<div id="game-overlay" class="game-overlay" style="/*background: violet*/;">
<div id="main-section" class="main-section">
@ -87,6 +89,9 @@
</div>
</div>
<div id="cowebsite" class="cowebsite"></div>
<div class="audio-playing">
<img src="/resources/logos/megaphone.svg"/>
</div>
</div>
<!--
<div id="webRtc" class="webrtc">
@ -120,5 +125,6 @@
<audio id="audio-webrtc-in">
<source src="/resources/objects/webrtc-in.mp3" type="audio/mp3">
</audio>
</body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 577 B

View file

@ -0,0 +1,47 @@
{
"frames": [
{
"filename": "computer_off",
"frame": {
"w": 42,
"h": 40,
"x": 0,
"y": 0
},
"anchor": {
"x": 0.5,
"y": 0.5
}
},
{
"filename": "computer_on1",
"frame": {
"w": 42,
"h": 40,
"x": 0,
"y": 40
},
"anchor": {
"x": 0.5,
"y": 0.5
}
},
{
"filename": "computer_on2",
"frame": {
"w": 42,
"h": 40,
"x": 42,
"y": 0
},
"anchor": {
"x": 0.5,
"y": 0.5
}
}
],
"meta": {
"description": "Atlas generado con Atlas Packer Gamma V2",
"web": "https://gammafp.github.io/atlas-packer-phaser/"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 577 B

View file

@ -0,0 +1,47 @@
{
"frames": [
{
"filename": "computer_off",
"frame": {
"w": 42,
"h": 40,
"x": 0,
"y": 0
},
"anchor": {
"x": 0.5,
"y": 0.5
}
},
{
"filename": "computer_on1",
"frame": {
"w": 42,
"h": 40,
"x": 0,
"y": 40
},
"anchor": {
"x": 0.5,
"y": 0.5
}
},
{
"filename": "computer_on2",
"frame": {
"w": 42,
"h": 40,
"x": 42,
"y": 0
},
"anchor": {
"x": 0.5,
"y": 0.5
}
}
],
"meta": {
"description": "Atlas generado con Atlas Packer Gamma V2",
"web": "https://gammafp.github.io/atlas-packer-phaser/"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 379 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 492 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 452 B

View file

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 24.3.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Capa_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 451.7 512" style="enable-background:new 0 0 451.7 512;" xml:space="preserve">
<path d="M436.9,212.6L237.2,12.9c-11.7-11.7-30.7-11.7-42.4,0s-11.7,30.7,0,42.4L394.5,255c11.5,11.9,30.5,12.2,42.4,0.7
c11.9-11.5,12.2-30.5,0.7-42.4C437.4,213.1,437.2,212.8,436.9,212.6z"/>
<path d="M179.5,83.1l-1.5,7.5c-10.4,53-36,103.4-70.6,144.3l109,108.3c40.7-34.9,90.2-61.5,143.1-72.3l7.5-1.5L179.5,83.1z"/>
<path d="M87.4,257l-74.2,74.2c-17.6,17.6-17.6,46.1,0,63.6c0,0,0,0,0,0l42.4,42.4c17.6,17.6,46.1,17.6,63.6,0c0,0,0,0,0,0l74.2-74.2
L87.4,257z M98,373.7c-6.1,5.6-15.6,5.3-21.2-0.8c-5.4-5.8-5.4-14.7,0-20.5l21.2-21.2c6-5.8,15.5-5.6,21.2,0.4
c5.6,5.8,5.6,15,0,20.8L98,373.7z"/>
<path d="M256.1,445.3l20.4-20.4c17.6-17.6,17.6-46.1,0-63.6l-15.1-15.2c-8.4,5.7-16.4,11.7-24.2,18.3l18.1,18.1
c5.8,5.9,5.8,15.3,0,21.2l-20.7,20.8l-30.5-29.5l-42.4,42.4l68.1,65.9c11.7,11.7,30.7,11.7,42.4,0c11.7-11.7,11.7-30.7,0-42.4l0,0
L256.1,445.3z"/>
<path d="M316.7,0c-8.3,0-15,6.7-15,15v30c0,8.3,6.7,15,15,15c8.3,0,15-6.7,15-15V15C331.7,6.7,325,0,316.7,0z"/>
<path d="M436.7,120h-30c-8.3,0-15,6.7-15,15s6.7,15,15,15h30c8.3,0,15-6.7,15-15S445,120,436.7,120z"/>
<path d="M417.3,34.4c-5.9-5.9-15.4-5.9-21.2,0l-30,30c-6,5.8-6.1,15.3-0.4,21.2c5.8,6,15.3,6.1,21.2,0.4c0.1-0.1,0.2-0.2,0.4-0.4
l30-30C423.2,49.7,423.2,40.2,417.3,34.4z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View file

@ -0,0 +1,27 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 24.3.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Calque_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 448 448" style="enable-background:new 0 0 448 448;" xml:space="preserve">
<style type="text/css">
.st0{fill:#FFDA01;}
</style>
<path class="st0" d="M348,288c-44.2,0-80,35.8-80,80s35.8,80,80,80s80-35.8,80-80C428,323.8,392.2,288,348,288z M387.6,359.6
c-3.1,3.1-8.2,3.1-11.3,0L356,339.3V416c0,4.4-3.6,8-8,8s-8-3.6-8-8v-76.7l-20.3,20.3c-3.1,3-8.1,3-11.2-0.1s-3.1-8.1-0.1-11.2
l33.9-33.9c0.7-0.7,1.6-1.3,2.6-1.7c2-0.8,4.2-0.8,6.1,0c1,0.4,1.9,1,2.6,1.7l33.9,33.9C390.7,351.4,390.7,356.5,387.6,359.6z"/>
<path class="st0" d="M244,154.6L148,182v15.4l96-27.4V154.6z"/>
<path class="st0" d="M244,280c0,8.8-7.2,16-16,16s-16-7.2-16-16s7.2-16,16-16S244,271.2,244,280z"/>
<path class="st0" d="M132,312c0,8.8-7.2,16-16,16s-16-7.2-16-16s7.2-16,16-16S132,303.2,132,312z"/>
<path class="st0" d="M31.3,80H100V11.3L31.3,80z"/>
<path class="st0" d="M20,448h275c-0.1-0.1-0.2-0.1-0.3-0.2c-2.9-2-5.8-4.1-8.5-6.4c-0.7-0.6-1.4-1.3-2.1-1.9
c-1.9-1.7-3.8-3.5-5.6-5.4c-0.8-0.9-1.6-1.8-2.4-2.7c-1.6-1.8-3.2-3.8-4.7-5.7c-0.7-0.9-1.4-1.8-2-2.7c-1.8-2.6-3.5-5.2-5-8
c-0.2-0.4-0.4-0.7-0.6-1c-1.7-3.1-3.2-6.4-4.6-9.7c-0.4-1-0.7-2-1.1-3c-0.9-2.4-1.7-4.9-2.4-7.4c-0.3-1.2-0.6-2.4-0.9-3.6
c-0.6-2.5-1.1-5-1.5-7.6c-0.2-1.1-0.4-2.3-0.5-3.4c-0.9-6.9-1-13.9-0.2-20.8c0.1-1.1,0.3-2.1,0.5-3.1c0.3-2.1,0.5-4.1,0.9-6.2
c0.2-1.2,0.6-2.4,0.9-3.7c0.4-1.8,0.8-3.6,1.4-5.3c0.4-1.3,0.9-2.5,1.3-3.8c0.6-1.6,1.1-3.3,1.8-4.9c0.5-1.3,1.1-2.5,1.7-3.7
c0.7-1.5,1.4-3,2.2-4.5c0.6-1.2,1.4-2.4,2-3.6c0.8-1.4,1.7-2.8,2.6-4.2c0.8-1.2,1.6-2.3,2.4-3.4c0.9-1.3,1.9-2.6,2.9-3.9
c0.9-1.1,1.8-2.1,2.7-3.2c1.1-1.2,2.1-2.4,3.2-3.6c1-1,2-2,3-2.9c1.2-1.1,2.3-2.2,3.6-3.2c1.1-0.9,2.1-1.8,3.2-2.7
c1.3-1,2.6-2,3.9-2.9c1.1-0.8,2.3-1.6,3.5-2.4c1.4-0.9,2.8-1.7,4.2-2.5c1.2-0.7,2.4-1.4,3.6-2c1.5-0.8,2.9-1.5,4.4-2.1
c1.3-0.6,2.5-1.2,3.8-1.7c1.6-0.6,3.1-1.2,4.7-1.7c1.3-0.4,2.6-0.9,3.9-1.3c1.6-0.5,3.3-0.9,5-1.3c1.3-0.3,2.6-0.7,4-0.9
c1.8-0.3,3.5-0.6,5.3-0.8c1.3-0.2,2.6-0.4,4-0.5c0.3,0,0.6-0.1,1-0.1V0H116v88c0,4.4-3.6,8-8,8H20V448z M116,280
c5.6,0,11.2,1.6,16,4.4V176c0-3.6,2.4-6.7,5.8-7.7l112-32c2.4-0.7,5-0.2,7,1.3s3.2,3.9,3.2,6.4v136c0,17.7-14.3,32-32,32
s-32-14.3-32-32s14.3-32,32-32c5.6,0,11.2,1.6,16,4.4v-65.8L148,214v98c0,17.7-14.3,32-32,32s-32-14.3-32-32S98.3,280,116,280z"/>
</svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

View file

@ -403,3 +403,168 @@ body {
.chat-mode > div:last-child {
flex-grow: 5;
}
.message-container,
.main-console{
position: absolute;
width: 80%;
height: 80%;
min-height: 200px;
max-height: 80%;
top: -80%;
left: 10%;
background: #000000a6;
z-index: 200;
transition: all 0.1s ease-out;
}
.message-container{
height: auto;
border-radius: 0 0 10px 10px;
color: white;
top: 0;
}
.message-container .content-message{
position: relative;
padding: 20px;
margin: 20px;
overflow: scroll;
max-height: 400px;
}
.main-console div.console,
.message-container div.clear {
position: absolute;
color: white;
z-index: 200;
transition: all 0.1s ease-out;
top: 100%;
width: 100px;
height: 40px;
background-color: black;
left: calc(50% - 50px);
border-radius: 0 0 10px 10px;
text-align: center;
}
.main-console div.console p,
.message-container div.clear p{
margin-top: 6px;
}
.main-console div.console:hover,
.message-container div.clear:hover {
cursor: pointer;
transform: scale(1.2) translateY(3px);
}
.main-console #input-send-text{
min-height: 200px;
}
.main-console #input-send-text .ql-editor{
color: white;
min-height: 200px;
max-height: 300px;
}
.main-console .ql-toolbar{
background: white;
}
.main-console .btn-action{
margin: 10px;
text-align: center;
}
.main-console .btn-action .btn{
border: 1px solid black;
background-color: #00000000;
color: #ffda01;
border-radius: 10px;
padding: 10px 30px;
transition: all .2s ease;
}
.main-console .btn-action .btn:hover{
cursor: pointer;
background-color: #ffda01;
color: black;
border: 1px solid black;
transform: scale(1.1);
}
.main-console .menu {
padding: 20px;
color: #ffffffa6;
text-align: center;
}
.main-console .menu span {
margin: 20px;
cursor: pointer;
}
.main-console .menu span.active {
color: white;
border-bottom: solid 1px white;
}
.main-console section{
text-align: center;
display: none;
}
.main-console section.active{
display: block;
}
.main-console section div.upload{
text-align: center;
border: solid 1px #ffda01;
height: 150px;
margin: 10px 200px;
padding: 20px;
min-height: 200px;
}
.main-console section div.upload label{
color: #ffda01;
}
.main-console section div.upload input{
display: none;
}
.main-console section div.upload label img{
height: 150px;
cursor: pointer;
}
.main-console section div.upload label img{
cursor: pointer;
}
/*audio html when audio message playing*/
.main-container .audio-playing {
position: absolute;
width: 200px;
height: 54px;
right: -210px;
top: 40px;
transition: all 0.1s ease-out;
background-color: black;
border-radius: 30px 0 0 30px;
display: inline-flex;
}
.main-container .audio-playing.active{
right: 0;
}
.main-container .audio-playing img{
width: 30px;
border-radius: 50%;
background-color: #ffda01;
padding: 10px;
}
.main-container .audio-playing p{
color: white;
margin-left: 10px;
}

View file

@ -4,7 +4,9 @@
"main": "index.js",
"license": "SEE LICENSE IN LICENSE.txt",
"devDependencies": {
"@types/google-protobuf": "^3.7.3",
"@types/jasmine": "^3.5.10",
"@types/quill": "^1.3.7",
"@typescript-eslint/eslint-plugin": "^2.26.0",
"@typescript-eslint/parser": "^2.26.0",
"eslint": "^6.8.0",
@ -19,13 +21,17 @@
"webpack-merge": "^4.2.2"
},
"dependencies": {
"@types/axios": "^0.14.0",
"@types/simple-peer": "^9.6.0",
"@types/socket.io-client": "^1.4.32",
"axios": "^0.20.0",
"generic-type-guard": "^3.2.0",
"google-protobuf": "^3.13.0",
"phaser": "^3.22.0",
"queue-typescript": "^1.0.1",
"quill": "^1.3.7",
"simple-peer": "^9.6.2",
"socket.io-client": "^2.3.0"
"socket.io-client": "^2.3.0",
"webpack-require-http": "^0.4.3"
},
"scripts": {
"start": "webpack-dev-server --open",

View file

@ -0,0 +1,324 @@
import {HtmlUtils} from "../WebRtc/HtmlUtils";
import {UserInputManager} from "../Phaser/UserInput/UserInputManager";
import {RoomConnection} from "../Connexion/RoomConnection";
import {PlayGlobalMessageInterface} from "../Connexion/ConnexionModels";
export const CLASS_CONSOLE_MESSAGE = 'main-console';
export const INPUT_CONSOLE_MESSAGE = 'input-send-text';
export const UPLOAD_CONSOLE_MESSAGE = 'input-upload-music';
export const INPUT_TYPE_CONSOLE = 'input-type';
export const AUDIO_TYPE = 'audio';
export const MESSAGE_TYPE = 'message';
interface EventTargetFiles extends EventTarget {
files: Array<File>;
}
export class ConsoleGlobalMessageManager {
private divMainConsole: HTMLDivElement;
private buttonMainConsole: HTMLDivElement;
private activeConsole: boolean = false;
private userInputManager!: UserInputManager;
private static cssLoaded: boolean = false;
constructor(private Connection: RoomConnection, userInputManager : UserInputManager) {
this.buttonMainConsole = document.createElement('div');
this.buttonMainConsole.classList.add('console');
this.divMainConsole = document.createElement('div');
this.userInputManager = userInputManager;
this.initialise();
}
initialise() {
for (const elem of document.getElementsByClassName(CLASS_CONSOLE_MESSAGE)) {
elem.remove();
}
const typeConsole = document.createElement('input');
typeConsole.id = INPUT_TYPE_CONSOLE;
typeConsole.value = MESSAGE_TYPE;
typeConsole.type = 'hidden';
const menu = document.createElement('div');
menu.classList.add('menu')
const textMessage = document.createElement('span');
textMessage.innerText = "Message";
textMessage.classList.add('active');
textMessage.addEventListener('click', () => {
textMessage.classList.add('active');
const messageSection = HtmlUtils.getElementByIdOrFail<HTMLInputElement>(this.getSectionId(INPUT_CONSOLE_MESSAGE));
messageSection.classList.add('active');
textAudio.classList.remove('active');
const audioSection = HtmlUtils.getElementByIdOrFail<HTMLInputElement>(this.getSectionId(UPLOAD_CONSOLE_MESSAGE));
audioSection.classList.remove('active');
typeConsole.value = MESSAGE_TYPE;
});
menu.appendChild(textMessage);
const textAudio = document.createElement('span');
textAudio.innerText = "Audio";
textAudio.addEventListener('click', () => {
textAudio.classList.add('active');
const audioSection = HtmlUtils.getElementByIdOrFail<HTMLInputElement>(this.getSectionId(UPLOAD_CONSOLE_MESSAGE));
audioSection.classList.add('active');
textMessage.classList.remove('active');
const messageSection = HtmlUtils.getElementByIdOrFail<HTMLInputElement>(this.getSectionId(INPUT_CONSOLE_MESSAGE));
messageSection.classList.remove('active');
typeConsole.value = AUDIO_TYPE;
});
menu.appendChild(textMessage);
menu.appendChild(textAudio);
this.divMainConsole.appendChild(menu);
const buttonText = document.createElement('p');
buttonText.innerText = 'Console';
this.buttonMainConsole.appendChild(buttonText);
this.buttonMainConsole.addEventListener('click', () => {
if(this.activeConsole){
this.disabled();
}else{
this.active();
}
});
this.divMainConsole.className = CLASS_CONSOLE_MESSAGE;
this.divMainConsole.appendChild(this.buttonMainConsole);
this.divMainConsole.appendChild(typeConsole);
this.createTextMessagePart();
this.createUploadAudioPart();
const mainSectionDiv = HtmlUtils.getElementByIdOrFail<HTMLDivElement>('main-container');
mainSectionDiv.appendChild(this.divMainConsole);
}
createTextMessagePart(){
const div = document.createElement('div');
div.id = INPUT_CONSOLE_MESSAGE
const buttonSend = document.createElement('button');
buttonSend.innerText = 'Envoyer';
buttonSend.classList.add('btn');
buttonSend.addEventListener('click', (event: MouseEvent) => {
this.sendMessage();
this.disabled();
});
const buttonDiv = document.createElement('div');
buttonDiv.classList.add('btn-action');
buttonDiv.appendChild(buttonSend)
const section = document.createElement('section');
section.id = this.getSectionId(INPUT_CONSOLE_MESSAGE);
section.classList.add('active');
section.appendChild(div);
section.appendChild(buttonDiv);
this.divMainConsole.appendChild(section);
(async () => {
// Start loading CSS
const cssPromise = ConsoleGlobalMessageManager.loadCss();
// Import quill
const Quill:any = await import("quill"); // eslint-disable-line @typescript-eslint/no-explicit-any
// Wait for CSS to be loaded
await cssPromise;
const toolbarOptions = [
['bold', 'italic', 'underline', 'strike'], // toggled buttons
['blockquote', 'code-block'],
[{'header': 1}, {'header': 2}], // custom button values
[{'list': 'ordered'}, {'list': 'bullet'}],
[{'script': 'sub'}, {'script': 'super'}], // superscript/subscript
[{'indent': '-1'}, {'indent': '+1'}], // outdent/indent
[{'direction': 'rtl'}], // text direction
[{'size': ['small', false, 'large', 'huge']}], // custom dropdown
[{'header': [1, 2, 3, 4, 5, 6, false]}],
[{'color': []}, {'background': []}], // dropdown with defaults from theme
[{'font': []}],
[{'align': []}],
['clean'],
['link', 'image', 'video']
// remove formatting button
];
new Quill(`#${INPUT_CONSOLE_MESSAGE}`, {
theme: 'snow',
modules: {
toolbar: toolbarOptions
},
});
})();
}
private static loadCss(): Promise<void> {
return new Promise<void>((resolve, reject) => {
if (ConsoleGlobalMessageManager.cssLoaded) {
resolve();
return;
}
const fileref = document.createElement("link")
fileref.setAttribute("rel", "stylesheet")
fileref.setAttribute("type", "text/css")
fileref.setAttribute("href", "https://cdn.quilljs.com/1.3.7/quill.snow.css");
document.getElementsByTagName("head")[0].appendChild(fileref);
ConsoleGlobalMessageManager.cssLoaded = true;
fileref.onload = () => {
resolve();
}
fileref.onerror = () => {
reject();
}
});
}
createUploadAudioPart(){
const div = document.createElement('div');
div.classList.add('upload');
const label = document.createElement('label');
label.setAttribute('for', UPLOAD_CONSOLE_MESSAGE);
const img = document.createElement('img');
img.setAttribute('for', UPLOAD_CONSOLE_MESSAGE);
img.src = 'resources/logos/music-file.svg';
const input = document.createElement('input');
input.type = 'file';
input.id = UPLOAD_CONSOLE_MESSAGE
input.addEventListener('input', (e: Event) => {
if(!e.target){
return;
}
const eventTarget : EventTargetFiles = (e.target as EventTargetFiles);
if(!eventTarget || !eventTarget.files || eventTarget.files.length === 0){
return;
}
const file : File = eventTarget.files[0];
if(!file){
return;
}
try {
HtmlUtils.removeElementByIdOrFail('audi-message-filename');
}catch (err) {
console.error(err)
}
const p = document.createElement('p');
p.id = 'audi-message-filename';
p.innerText = `${file.name} : ${this.getFileSize(file.size)}`;
label.appendChild(p);
});
label.appendChild(img);
div.appendChild(label);
div.appendChild(input);
const buttonSend = document.createElement('button');
buttonSend.innerText = 'Envoyer';
buttonSend.classList.add('btn');
buttonSend.addEventListener('click', (event: MouseEvent) => {
this.sendMessage();
this.disabled();
});
const buttonDiv = document.createElement('div');
buttonDiv.classList.add('btn-action');
buttonDiv.appendChild(buttonSend)
const section = document.createElement('section');
section.id = this.getSectionId(UPLOAD_CONSOLE_MESSAGE);
section.appendChild(div);
section.appendChild(buttonDiv);
this.divMainConsole.appendChild(section);
}
sendMessage(){
const inputType = HtmlUtils.getElementByIdOrFail<HTMLInputElement>(INPUT_TYPE_CONSOLE);
if(AUDIO_TYPE !== inputType.value && MESSAGE_TYPE !== inputType.value){
throw "Error event type";
}
if(AUDIO_TYPE === inputType.value){
return this.sendAudioMessage();
}
return this.sendTextMessage();
}
private sendTextMessage(){
const elements = document.getElementsByClassName('ql-editor');
const quillEditor = elements.item(0);
if(!quillEditor){
throw "Error get quill node";
}
const GlobalMessage : PlayGlobalMessageInterface = {
id: "1", // FIXME: use another ID?
message: quillEditor.innerHTML,
type: MESSAGE_TYPE
};
quillEditor.innerHTML = '';
this.Connection.emitGlobalMessage(GlobalMessage);
}
private async sendAudioMessage(){
const inputAudio = HtmlUtils.getElementByIdOrFail<HTMLInputElement>(UPLOAD_CONSOLE_MESSAGE);
const selectedFile = inputAudio.files ? inputAudio.files[0] : null;
if(!selectedFile){
throw 'no file selected';
}
const fd = new FormData();
fd.append('file', selectedFile);
const res = await this.Connection.uploadAudio(fd);
const GlobalMessage : PlayGlobalMessageInterface = {
id: (res as {id: string}).id,
message: (res as {path: string}).path,
type: AUDIO_TYPE
};
inputAudio.value = '';
try {
HtmlUtils.removeElementByIdOrFail('audi-message-filename');
}catch (err) {
console.error(err);
}
this.Connection.emitGlobalMessage(GlobalMessage);
}
active(){
this.userInputManager.clearAllInputKeyboard();
this.activeConsole = true;
this.divMainConsole.style.top = '0';
}
disabled(){
this.userInputManager.initKeyBoardEvent();
this.activeConsole = false;
this.divMainConsole.style.top = '-80%';
}
private getSectionId(id: string) : string {
return `section-${id}`;
}
private getFileSize(number: number) :string {
if (number < 1024) {
return number + 'bytes';
} else if (number >= 1024 && number < 1048576) {
return (number / 1024).toFixed(1) + 'KB';
} else if (number >= 1048576) {
return (number / 1048576).toFixed(1) + 'MB';
}else{
return '';
}
}
}

View file

@ -0,0 +1,121 @@
import {HtmlUtils} from "./../WebRtc/HtmlUtils";
import {AUDIO_TYPE, MESSAGE_TYPE} from "./ConsoleGlobalMessageManager";
import {API_URL} from "../Enum/EnvironmentVariable";
import {RoomConnection} from "../Connexion/RoomConnection";
import {PlayGlobalMessageInterface} from "../Connexion/ConnexionModels";
export class GlobalMessageManager {
constructor(private Connection: RoomConnection) {
this.initialise();
}
initialise(){
//receive signal to show message
this.Connection.receivePlayGlobalMessage((message: PlayGlobalMessageInterface) => {
this.playMessage(message);
});
//receive signal to close message
this.Connection.receiveStopGlobalMessage((messageId: string) => {
this.stopMessage(messageId);
});
}
private playMessage(message : PlayGlobalMessageInterface){
const previousMessage = document.getElementById(this.getHtmlMessageId(message.id));
if(previousMessage){
previousMessage.remove();
}
if(AUDIO_TYPE === message.type){
this.playAudioMessage(message.id, message.message);
}
if(MESSAGE_TYPE === message.type){
this.playTextMessage(message.id, message.message);
}
}
private playAudioMessage(messageId : string, urlMessage: string){
//delete previous elements
const previousDivAudio = document.getElementsByClassName('audio-playing');
for(let i = 0; i < previousDivAudio.length; i++){
previousDivAudio[i].remove();
}
//create new element
const divAudio : HTMLDivElement = document.createElement('div');
divAudio.id = `audio-playing-${messageId}`;
divAudio.classList.add('audio-playing');
const imgAudio : HTMLImageElement = document.createElement('img');
imgAudio.src = '/resources/logos/megaphone.svg';
const pAudio : HTMLParagraphElement = document.createElement('p');
pAudio.textContent = 'Message audio'
divAudio.appendChild(imgAudio);
divAudio.appendChild(pAudio);
const mainSectionDiv = HtmlUtils.getElementByIdOrFail<HTMLDivElement>('main-container');
mainSectionDiv.appendChild(divAudio);
const messageAudio : HTMLAudioElement = document.createElement('audio');
messageAudio.id = this.getHtmlMessageId(messageId);
messageAudio.autoplay = true;
messageAudio.style.display = 'none';
messageAudio.onended = () => {
divAudio.classList.remove('active');
messageAudio.remove();
setTimeout(() => {
divAudio.remove();
}, 1000);
}
messageAudio.onplay = () => {
divAudio.classList.add('active');
}
const messageAudioSource : HTMLSourceElement = document.createElement('source');
messageAudioSource.src = `${API_URL}${urlMessage}`;
messageAudio.appendChild(messageAudioSource);
mainSectionDiv.appendChild(messageAudio);
}
private playTextMessage(messageId : string, htmlMessage: string){
//add button to clear message
const buttonText = document.createElement('p');
buttonText.id = 'button-clear-message';
buttonText.innerText = 'Clear';
const buttonMainConsole = document.createElement('div');
buttonMainConsole.classList.add('clear');
buttonMainConsole.appendChild(buttonText);
buttonMainConsole.addEventListener('click', () => {
messageContainer.style.top = '-80%';
setTimeout(() => {
messageContainer.remove();
buttonMainConsole.remove();
});
});
//create content message
const messageCotent = document.createElement('div');
messageCotent.innerHTML = htmlMessage;
messageCotent.className = "content-message";
//add message container
const messageContainer = document.createElement('div');
messageContainer.id = this.getHtmlMessageId(messageId);
messageContainer.className = "message-container";
messageContainer.appendChild(messageCotent);
messageContainer.appendChild(buttonMainConsole);
const mainSectionDiv = HtmlUtils.getElementByIdOrFail<HTMLDivElement>('main-container');
mainSectionDiv.appendChild(messageContainer);
}
private stopMessage(messageId: string){
HtmlUtils.removeElementByIdOrFail<HTMLDivElement>(this.getHtmlMessageId(messageId));
}
private getHtmlMessageId(messageId: string) : string{
return `message-${messageId}`;
}
}

View file

@ -1,243 +0,0 @@
import Axios from "axios";
import {API_URL} from "./Enum/EnvironmentVariable";
import {MessageUI} from "./Logger/MessageUI";
import {SetPlayerDetailsMessage} from "./Messages/SetPlayerDetailsMessage";
const SocketIo = require('socket.io-client');
import Socket = SocketIOClient.Socket;
import {PlayerAnimationNames} from "./Phaser/Player/Animation";
import {UserSimplePeerInterface} from "./WebRtc/SimplePeer";
import {SignalData} from "simple-peer";
enum EventMessage{
WEBRTC_SIGNAL = "webrtc-signal",
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_MOVED = "user-moved", // From server to client
USER_LEFT = "user-left", // From server to client
MESSAGE_ERROR = "message-error",
WEBRTC_DISCONNECT = "webrtc-disconect",
GROUP_CREATE_UPDATE = "group-create-update",
GROUP_DELETE = "group-delete",
SET_PLAYER_DETAILS = "set-player-details", // Send the name and character to the server (on connect), receive back the id.
CONNECT_ERROR = "connect_error",
SET_SILENT = "set_silent", // Set or unset the silent mode for this user.
}
export interface PointInterface {
x: number;
y: number;
direction : string;
moving: boolean;
}
export class Point implements PointInterface{
constructor(public x : number, public y : number, public direction : string = PlayerAnimationNames.WalkDown, public moving : boolean = false) {
if(x === null || y === null){
throw Error("position x and y cannot be null");
}
}
}
export interface MessageUserPositionInterface {
userId: string;
name: string;
characterLayers: string[];
position: PointInterface;
}
export interface MessageUserMovedInterface {
userId: string;
position: PointInterface;
}
export interface MessageUserJoined {
userId: string;
name: string;
characterLayers: string[];
position: PointInterface
}
export interface PositionInterface {
x: number,
y: number
}
export interface GroupCreatedUpdatedMessageInterface {
position: PositionInterface,
groupId: string
}
export interface WebRtcStartMessageInterface {
roomId: string,
clients: UserSimplePeerInterface[]
}
export interface WebRtcDisconnectMessageInterface {
userId: string
}
export interface WebRtcSignalSentMessageInterface {
receiverId: string,
signal: SignalData
}
export interface WebRtcSignalReceivedMessageInterface {
userId: string,
signal: SignalData
}
export interface StartMapInterface {
mapUrlStart: string,
startInstance: string
}
export class Connection implements Connection {
private readonly socket: Socket;
private userId: string|null = null;
private constructor(token: string) {
this.socket = SocketIo(`${API_URL}`, {
query: {
token: token
},
reconnection: false // Reconnection is handled by the application itself
});
this.socket.on(EventMessage.MESSAGE_ERROR, (message: string) => {
console.error(EventMessage.MESSAGE_ERROR, message);
})
}
public static createConnection(name: string, characterLayersSelected: string[]): Promise<Connection> {
return Axios.post(`${API_URL}/login`, {name: name})
.then((res) => {
return new Promise<Connection>((resolve, reject) => {
const connection = new Connection(res.data.token);
connection.onConnectError((error: object) => {
console.log('An error occurred while connecting to socket server. Retrying');
reject(error);
});
connection.socket.emit(EventMessage.SET_PLAYER_DETAILS, {
name: name,
characterLayers: characterLayersSelected
} as SetPlayerDetailsMessage, (id: string) => {
connection.userId = id;
});
resolve(connection);
});
})
.catch((err) => {
// Let's retry in 4-6 seconds
return new Promise<Connection>((resolve, reject) => {
setTimeout(() => {
Connection.createConnection(name, characterLayersSelected).then((connection) => resolve(connection))
.catch((error) => reject(error));
}, 4000 + Math.floor(Math.random() * 2000) );
});
});
}
public closeConnection(): void {
this.socket?.close();
}
public joinARoom(roomId: string, startX: number, startY: number, direction: string, moving: boolean): Promise<MessageUserPositionInterface[]> {
const promise = new Promise<MessageUserPositionInterface[]>((resolve, reject) => {
this.socket.emit(EventMessage.JOIN_ROOM, { roomId, position: {x: startX, y: startY, direction, moving }}, (userPositions: MessageUserPositionInterface[]) => {
resolve(userPositions);
});
})
return promise;
}
public sharePosition(x : number, y : number, direction : string, moving: boolean) : void{
if(!this.socket){
return;
}
const point = new Point(x, y, direction, moving);
this.socket.emit(EventMessage.USER_POSITION, point);
}
public setSilent(silent: boolean): void {
this.socket.emit(EventMessage.SET_SILENT, silent);
}
public onUserJoins(callback: (message: MessageUserJoined) => void): void {
this.socket.on(EventMessage.JOIN_ROOM, callback);
}
public onUserMoved(callback: (message: MessageUserMovedInterface) => void): void {
this.socket.on(EventMessage.USER_MOVED, callback);
}
public onUserLeft(callback: (userId: string) => void): void {
this.socket.on(EventMessage.USER_LEFT, callback);
}
public onGroupUpdatedOrCreated(callback: (groupCreateUpdateMessage: GroupCreatedUpdatedMessageInterface) => void): void {
this.socket.on(EventMessage.GROUP_CREATE_UPDATE, callback);
}
public onGroupDeleted(callback: (groupId: string) => void): void {
this.socket.on(EventMessage.GROUP_DELETE, callback)
}
public onConnectError(callback: (error: object) => void): void {
this.socket.on(EventMessage.CONNECT_ERROR, callback)
}
public sendWebrtcSignal(signal: unknown, receiverId : string) {
return this.socket.emit(EventMessage.WEBRTC_SIGNAL, {
receiverId: receiverId,
signal: signal
} as WebRtcSignalSentMessageInterface);
}
public sendWebrtcScreenSharingSignal(signal: unknown, receiverId : string) {
return this.socket.emit(EventMessage.WEBRTC_SCREEN_SHARING_SIGNAL, {
receiverId: receiverId,
signal: signal
} as WebRtcSignalSentMessageInterface);
}
public receiveWebrtcStart(callback: (message: WebRtcStartMessageInterface) => void) {
this.socket.on(EventMessage.WEBRTC_START, callback);
}
public receiveWebrtcSignal(callback: (message: WebRtcSignalReceivedMessageInterface) => void) {
return this.socket.on(EventMessage.WEBRTC_SIGNAL, callback);
}
public receiveWebrtcScreenSharingSignal(callback: (message: WebRtcSignalReceivedMessageInterface) => void) {
return this.socket.on(EventMessage.WEBRTC_SCREEN_SHARING_SIGNAL, callback);
}
public onServerDisconnected(callback: (reason: string) => void): void {
this.socket.on('disconnect', (reason: string) => {
if (reason === 'io client disconnect') {
// The client asks for disconnect, let's not trigger any event.
return;
}
callback(reason);
});
}
public getUserId(): string|null {
return this.userId;
}
disconnectMessage(callback: (message: WebRtcDisconnectMessageInterface) => void): void {
this.socket.on(EventMessage.WEBRTC_DISCONNECT, callback);
}
}

View file

@ -0,0 +1,95 @@
import Axios from "axios";
import {API_URL} from "../Enum/EnvironmentVariable";
import {RoomConnection} from "./RoomConnection";
import {PositionInterface, ViewportInterface} from "./ConnexionModels";
import {GameConnexionTypes, urlManager} from "../Url/UrlManager";
import {localUserStore} from "./LocalUserStore";
import {LocalUser} from "./LocalUser";
import {Room} from "./Room";
const URL_ROOM_STARTED = '/Floor0/floor0.json';
class ConnectionManager {
private localUser!:LocalUser;
/**
* Tries to login to the node server and return the starting map url to be loaded
*/
public async initGameConnexion(): Promise<Room> {
const connexionType = urlManager.getGameConnexionType();
if(connexionType === GameConnexionTypes.register) {
const organizationMemberToken = urlManager.getOrganizationToken();
const data = await Axios.post(`${API_URL}/register`, {organizationMemberToken}).then(res => res.data);
this.localUser = new LocalUser(data.userUuid, data.authToken);
localUserStore.saveUser(this.localUser);
const organizationSlug = data.organizationSlug;
const worldSlug = data.worldSlug;
const roomSlug = data.roomSlug;
urlManager.editUrlForRoom(roomSlug, organizationSlug, worldSlug);
const room = new Room(window.location.pathname);
return Promise.resolve(room);
} else if (connexionType === GameConnexionTypes.anonymous || connexionType === GameConnexionTypes.empty) {
const localUser = localUserStore.getLocalUser();
if (localUser && localUser.jwtToken && localUser.uuid) {
this.localUser = localUser
} else {
const data = await Axios.post(`${API_URL}/anonymLogin`).then(res => res.data);
this.localUser = new LocalUser(data.userUuid, data.authToken);
localUserStore.saveUser(this.localUser);
}
let roomId: string
if (connexionType === GameConnexionTypes.empty) {
const defaultMapUrl = window.location.host.replace('play.', 'maps.') + URL_ROOM_STARTED;
roomId = urlManager.editUrlForRoom(defaultMapUrl, null, null);
} else {
roomId = window.location.pathname;
}
const room = new Room(roomId);
return Promise.resolve(room);
} else if (connexionType == GameConnexionTypes.organization) {
const localUser = localUserStore.getLocalUser();
if (localUser) {
this.localUser = localUser
const room = new Room(window.location.pathname);
return Promise.resolve(room);
} else {
//todo: find some kind of fallback?
return Promise.reject('Could not find a user in localstorage');
}
}
return Promise.reject('Invalid URL');
}
public initBenchmark(): void {
this.localUser = new LocalUser('', 'test');
}
public connectToRoomSocket(roomId: string, name: string, characterLayers: string[], position: PositionInterface, viewport: ViewportInterface): Promise<RoomConnection> {
return new Promise<RoomConnection>((resolve, reject) => {
const connection = new RoomConnection(this.localUser.jwtToken, roomId, name, characterLayers, position, viewport);
connection.onConnectError((error: object) => {
console.log('An error occurred while connecting to socket server. Retrying');
reject(error);
});
connection.onConnect(() => {
resolve(connection);
})
}).catch((err) => {
// Let's retry in 4-6 seconds
return new Promise<RoomConnection>((resolve, reject) => {
setTimeout(() => {
//todo: allow a way to break recurrsion?
this.connectToRoomSocket(roomId, name, characterLayers, position, viewport).then((connection) => resolve(connection));
}, 4000 + Math.floor(Math.random() * 2000) );
});
});
}
}
export const connectionManager = new ConnectionManager();

View file

@ -0,0 +1,127 @@
import {PlayerAnimationNames} from "../Phaser/Player/Animation";
import {UserSimplePeerInterface} from "../WebRtc/SimplePeer";
import {SignalData} from "simple-peer";
export enum EventMessage{
WEBRTC_SIGNAL = "webrtc-signal",
WEBRTC_SCREEN_SHARING_SIGNAL = "webrtc-screen-sharing-signal",
WEBRTC_START = "webrtc-start",
START_ROOM = "start-room", // From server to client: list of all room users/groups/items
JOIN_ROOM = "join-room", // 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",
WEBRTC_DISCONNECT = "webrtc-disconect",
GROUP_CREATE_UPDATE = "group-create-update",
GROUP_DELETE = "group-delete",
SET_PLAYER_DETAILS = "set-player-details", // Send the name and character to the server (on connect), receive back the id.
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",
PLAY_GLOBAL_MESSAGE = "play-global-message",
STOP_GLOBAL_MESSAGE = "stop-global-message",
}
export interface PointInterface {
x: number;
y: number;
direction : string;
moving: boolean;
}
export class Point implements PointInterface{
constructor(public x : number, public y : number, public direction : string = PlayerAnimationNames.WalkDown, public moving : boolean = false) {
if(x === null || y === null){
throw Error("position x and y cannot be null");
}
}
}
export interface MessageUserPositionInterface {
userId: number;
name: string;
characterLayers: string[];
position: PointInterface;
}
export interface MessageUserMovedInterface {
userId: number;
position: PointInterface;
}
export interface MessageUserJoined {
userId: number;
name: string;
characterLayers: string[];
position: PointInterface
}
export interface PositionInterface {
x: number,
y: number
}
export interface GroupCreatedUpdatedMessageInterface {
position: PositionInterface,
groupId: number
}
export interface WebRtcStartMessageInterface {
roomId: string,
clients: UserSimplePeerInterface[]
}
export interface WebRtcDisconnectMessageInterface {
userId: number
}
export interface WebRtcSignalSentMessageInterface {
receiverId: number,
signal: SignalData
}
export interface WebRtcSignalReceivedMessageInterface {
userId: number,
signal: SignalData
}
export interface StartMapInterface {
mapUrlStart: string,
startInstance: string
}
export interface ViewportInterface {
left: number,
top: number,
right: number,
bottom: number,
}
export interface BatchedMessageInterface {
event: string,
payload: unknown
}
export interface ItemEventMessageInterface {
itemId: number,
event: string,
state: unknown,
parameters: unknown
}
export interface RoomJoinedMessageInterface {
users: MessageUserPositionInterface[],
groups: GroupCreatedUpdatedMessageInterface[],
items: { [itemId: number] : unknown }
}
export interface PlayGlobalMessageInterface {
id: string
type: string
message: string
}

View file

@ -0,0 +1,9 @@
export class LocalUser {
public uuid: string;
public jwtToken: string;
constructor(uuid:string, jwtToken: string) {
this.uuid = uuid;
this.jwtToken = jwtToken;
}
}

View file

@ -0,0 +1,16 @@
import {LocalUser} from "./LocalUser";
class LocalUserStore {
saveUser(localUser: LocalUser) {
localStorage.setItem('localUser', JSON.stringify(localUser));
}
getLocalUser(): LocalUser|null {
const data = localStorage.getItem('localUser');
return data ? JSON.parse(data) : null;
}
}
export const localUserStore = new LocalUserStore();

View file

@ -0,0 +1,91 @@
import Axios from "axios";
import {API_URL} from "../Enum/EnvironmentVariable";
export class Room {
public readonly id: string;
public readonly isPublic: boolean;
private mapUrl: string|undefined;
private instance: string|undefined;
constructor(id: string) {
if (id.startsWith('/')) {
id = id.substr(1);
}
this.id = id;
if (id.startsWith('_/')) {
this.isPublic = true;
} else if (id.startsWith('@/')) {
this.isPublic = false;
} else {
throw new Error('Invalid room ID');
}
}
public async getMapUrl(): Promise<string> {
return new Promise<string>((resolve, reject) => {
if (this.mapUrl !== undefined) {
resolve(this.mapUrl);
return;
}
if (this.isPublic) {
const match = /_\/[^/]+\/(.+)/.exec(this.id);
if (!match) throw new Error('Could not extract url from "'+this.id+'"');
this.mapUrl = window.location.protocol+'//'+match[1];
resolve(this.mapUrl);
return;
} else {
// We have a private ID, we need to query the map URL from the server.
const urlParts = this.parsePrivateUrl(this.id);
Axios.get(`${API_URL}/map`, {
params: urlParts
}).then(({data}) => {
console.log('Map ', this.id, ' resolves to URL ', data.mapUrl);
resolve(data.mapUrl);
return;
});
}
});
}
/**
* Instance name is:
* - In a public URL: the second part of the URL ( _/[instance]/map.json)
* - In a private URL: [organizationId/worldId]
*/
public getInstance(): string {
if (this.instance !== undefined) {
return this.instance;
}
if (this.isPublic) {
const match = /_\/([^/]+)\/.+/.exec(this.id);
if (!match) throw new Error('Could not extract instance from "'+this.id+'"');
this.instance = match[1];
return this.instance;
} else {
const match = /@\/([^/]+)\/([^/]+)\/.+/.exec(this.id);
if (!match) throw new Error('Could not extract instance from "'+this.id+'"');
this.instance = match[1]+'/'+match[2];
return this.instance;
}
}
private parsePrivateUrl(url: string): { organizationSlug: string, worldSlug: string, roomSlug?: string } {
const regex = /@\/([^/]+)\/([^/]+)(?:\/([^/]*))?/gm;
const match = regex.exec(url);
if (!match) {
throw new Error('Invalid URL '+url);
}
const results: { organizationSlug: string, worldSlug: string, roomSlug?: string } = {
organizationSlug: match[1],
worldSlug: match[2],
}
if (match[3] !== undefined) {
results.roomSlug = match[3];
}
return results;
}
}

View file

@ -0,0 +1,487 @@
import {API_URL} from "../Enum/EnvironmentVariable";
import Axios from "axios";
import {
BatchMessage,
ClientToServerMessage,
GroupDeleteMessage,
GroupUpdateMessage,
ItemEventMessage,
PlayGlobalMessage,
PositionMessage,
RoomJoinedMessage,
ServerToClientMessage,
SetPlayerDetailsMessage,
SilentMessage, StopGlobalMessage,
UserJoinedMessage,
UserLeftMessage,
UserMovedMessage,
UserMovesMessage,
ViewportMessage,
WebRtcDisconnectMessage,
WebRtcSignalToClientMessage,
WebRtcSignalToServerMessage,
WebRtcStartMessage
} from "../Messages/generated/messages_pb"
import {UserSimplePeerInterface} from "../WebRtc/SimplePeer";
import Direction = PositionMessage.Direction;
import {ProtobufClientUtils} from "../Network/ProtobufClientUtils";
import {
EventMessage,
GroupCreatedUpdatedMessageInterface, ItemEventMessageInterface,
MessageUserJoined, PlayGlobalMessageInterface, PositionInterface,
RoomJoinedMessageInterface,
ViewportInterface, WebRtcDisconnectMessageInterface,
WebRtcSignalReceivedMessageInterface,
WebRtcSignalSentMessageInterface,
WebRtcStartMessageInterface
} from "./ConnexionModels";
export class RoomConnection implements RoomConnection {
private readonly socket: WebSocket;
private userId: number|null = null;
private listeners: Map<string, Function[]> = new Map<string, Function[]>();
private static websocketFactory: null|((url: string)=>any) = null; // eslint-disable-line @typescript-eslint/no-explicit-any
private closed: boolean = false;
private tags: string[] = [];
public static setWebsocketFactory(websocketFactory: (url: string)=>any): void { // eslint-disable-line @typescript-eslint/no-explicit-any
RoomConnection.websocketFactory = websocketFactory;
}
/**
*
* @param token A JWT token containing the UUID of the user
* @param roomId The ID of the room in the form "_/[instance]/[map_url]" or "@/[org]/[event]/[map]"
*/
public constructor(token: string|null, roomId: string, name: string, characterLayers: string[], position: PositionInterface, viewport: ViewportInterface) {
let url = API_URL.replace('http://', 'ws://').replace('https://', 'wss://');
url += '/room';
url += '?roomId='+(roomId ?encodeURIComponent(roomId):'');
url += '&token='+(token ?encodeURIComponent(token):'');
url += '&name='+encodeURIComponent(name);
for (const layer of characterLayers) {
url += '&characterLayers='+encodeURIComponent(layer);
}
url += '&x='+Math.floor(position.x);
url += '&y='+Math.floor(position.y);
url += '&top='+Math.floor(viewport.top);
url += '&bottom='+Math.floor(viewport.bottom);
url += '&left='+Math.floor(viewport.left);
url += '&right='+Math.floor(viewport.right);
if (RoomConnection.websocketFactory) {
this.socket = RoomConnection.websocketFactory(url);
} else {
this.socket = new WebSocket(url);
}
this.socket.binaryType = 'arraybuffer';
this.socket.onopen = (ev) => {
//console.log('WS connected');
};
this.socket.onmessage = (messageEvent) => {
const arrayBuffer: ArrayBuffer = messageEvent.data;
const message = ServerToClientMessage.deserializeBinary(new Uint8Array(arrayBuffer));
if (message.hasBatchmessage()) {
for (const subMessage of (message.getBatchmessage() as BatchMessage).getPayloadList()) {
let event: string;
let payload;
if (subMessage.hasUsermovedmessage()) {
event = EventMessage.USER_MOVED;
payload = subMessage.getUsermovedmessage();
} else if (subMessage.hasGroupupdatemessage()) {
event = EventMessage.GROUP_CREATE_UPDATE;
payload = subMessage.getGroupupdatemessage();
} else if (subMessage.hasGroupdeletemessage()) {
event = EventMessage.GROUP_DELETE;
payload = subMessage.getGroupdeletemessage();
} else if (subMessage.hasUserjoinedmessage()) {
event = EventMessage.JOIN_ROOM;
payload = subMessage.getUserjoinedmessage();
} else if (subMessage.hasUserleftmessage()) {
event = EventMessage.USER_LEFT;
payload = subMessage.getUserleftmessage();
} else if (subMessage.hasItemeventmessage()) {
event = EventMessage.ITEM_EVENT;
payload = subMessage.getItemeventmessage();
} else {
throw new Error('Unexpected batch message type');
}
this.dispatch(event, payload);
}
} else if (message.hasRoomjoinedmessage()) {
const roomJoinedMessage = message.getRoomjoinedmessage() as RoomJoinedMessage;
const users: Array<MessageUserJoined> = roomJoinedMessage.getUserList().map(this.toMessageUserJoined.bind(this));
const groups: Array<GroupCreatedUpdatedMessageInterface> = roomJoinedMessage.getGroupList().map(this.toGroupCreatedUpdatedMessage.bind(this));
const items: { [itemId: number] : unknown } = {};
for (const item of roomJoinedMessage.getItemList()) {
items[item.getItemid()] = JSON.parse(item.getStatejson());
}
this.userId = roomJoinedMessage.getCurrentuserid();
this.tags = roomJoinedMessage.getTagList();
this.dispatch(EventMessage.START_ROOM, {
users,
groups,
items
});
} else if (message.hasErrormessage()) {
console.error(EventMessage.MESSAGE_ERROR, message.getErrormessage()?.getMessage());
} else if (message.hasWebrtcsignaltoclientmessage()) {
this.dispatch(EventMessage.WEBRTC_SIGNAL, message.getWebrtcsignaltoclientmessage());
} else if (message.hasWebrtcscreensharingsignaltoclientmessage()) {
this.dispatch(EventMessage.WEBRTC_SCREEN_SHARING_SIGNAL, message.getWebrtcscreensharingsignaltoclientmessage());
} else if (message.hasWebrtcstartmessage()) {
this.dispatch(EventMessage.WEBRTC_START, message.getWebrtcstartmessage());
} else if (message.hasWebrtcdisconnectmessage()) {
this.dispatch(EventMessage.WEBRTC_DISCONNECT, message.getWebrtcdisconnectmessage());
} else if (message.hasPlayglobalmessage()) {
this.dispatch(EventMessage.PLAY_GLOBAL_MESSAGE, message.getPlayglobalmessage());
} else if (message.hasStopglobalmessage()) {
this.dispatch(EventMessage.STOP_GLOBAL_MESSAGE, message.getStopglobalmessage());
} else {
throw new Error('Unknown message received');
}
}
}
private dispatch(event: string, payload: unknown): void {
const listeners = this.listeners.get(event);
if (listeners === undefined) {
return;
}
for (const listener of listeners) {
listener(payload);
}
}
public emitPlayerDetailsMessage(userName: string, characterLayersSelected: string[]) {
const message = new SetPlayerDetailsMessage();
message.setName(userName);
message.setCharacterlayersList(characterLayersSelected);
const clientToServerMessage = new ClientToServerMessage();
clientToServerMessage.setSetplayerdetailsmessage(message);
this.socket.send(clientToServerMessage.serializeBinary().buffer);
}
public closeConnection(): void {
this.socket?.close();
this.closed = true;
}
private toPositionMessage(x : number, y : number, direction : string, moving: boolean): PositionMessage {
const positionMessage = new PositionMessage();
positionMessage.setX(Math.floor(x));
positionMessage.setY(Math.floor(y));
let directionEnum: PositionMessage.DirectionMap[keyof PositionMessage.DirectionMap];
switch (direction) {
case 'up':
directionEnum = Direction.UP;
break;
case 'down':
directionEnum = Direction.DOWN;
break;
case 'left':
directionEnum = Direction.LEFT;
break;
case 'right':
directionEnum = Direction.RIGHT;
break;
default:
throw new Error("Unexpected direction");
}
positionMessage.setDirection(directionEnum);
positionMessage.setMoving(moving);
return positionMessage;
}
private toViewportMessage(viewport: ViewportInterface): ViewportMessage {
const viewportMessage = new ViewportMessage();
viewportMessage.setLeft(Math.floor(viewport.left));
viewportMessage.setRight(Math.floor(viewport.right));
viewportMessage.setTop(Math.floor(viewport.top));
viewportMessage.setBottom(Math.floor(viewport.bottom));
return viewportMessage;
}
public sharePosition(x : number, y : number, direction : string, moving: boolean, viewport: ViewportInterface) : void{
if(!this.socket){
return;
}
const positionMessage = this.toPositionMessage(x, y, direction, moving);
const viewportMessage = this.toViewportMessage(viewport);
const userMovesMessage = new UserMovesMessage();
userMovesMessage.setPosition(positionMessage);
userMovesMessage.setViewport(viewportMessage);
//console.log('Sending position ', positionMessage.getX(), positionMessage.getY());
const clientToServerMessage = new ClientToServerMessage();
clientToServerMessage.setUsermovesmessage(userMovesMessage);
this.socket.send(clientToServerMessage.serializeBinary().buffer);
}
public setSilent(silent: boolean): void {
const silentMessage = new SilentMessage();
silentMessage.setSilent(silent);
const clientToServerMessage = new ClientToServerMessage();
clientToServerMessage.setSilentmessage(silentMessage);
this.socket.send(clientToServerMessage.serializeBinary().buffer);
}
public setViewport(viewport: ViewportInterface): void {
const viewportMessage = new ViewportMessage();
viewportMessage.setTop(Math.round(viewport.top));
viewportMessage.setBottom(Math.round(viewport.bottom));
viewportMessage.setLeft(Math.round(viewport.left));
viewportMessage.setRight(Math.round(viewport.right));
const clientToServerMessage = new ClientToServerMessage();
clientToServerMessage.setViewportmessage(viewportMessage);
this.socket.send(clientToServerMessage.serializeBinary().buffer);
}
public onUserJoins(callback: (message: MessageUserJoined) => void): void {
this.onMessage(EventMessage.JOIN_ROOM, (message: UserJoinedMessage) => {
callback(this.toMessageUserJoined(message));
});
}
// TODO: move this to protobuf utils
private toMessageUserJoined(message: UserJoinedMessage): MessageUserJoined {
const position = message.getPosition();
if (position === undefined) {
throw new Error('Invalid JOIN_ROOM message');
}
return {
userId: message.getUserid(),
name: message.getName(),
characterLayers: message.getCharacterlayersList(),
position: ProtobufClientUtils.toPointInterface(position)
}
}
public onUserMoved(callback: (message: UserMovedMessage) => void): void {
this.onMessage(EventMessage.USER_MOVED, callback);
//this.socket.on(EventMessage.USER_MOVED, callback);
}
/**
* Registers a listener on a message that is part of a batch
*/
private onMessage(eventName: string, callback: Function): void {
let callbacks = this.listeners.get(eventName);
if (callbacks === undefined) {
callbacks = new Array<Function>();
this.listeners.set(eventName, callbacks);
}
callbacks.push(callback);
}
public onUserLeft(callback: (userId: number) => void): void {
this.onMessage(EventMessage.USER_LEFT, (message: UserLeftMessage) => {
callback(message.getUserid());
});
}
public onGroupUpdatedOrCreated(callback: (groupCreateUpdateMessage: GroupCreatedUpdatedMessageInterface) => void): void {
this.onMessage(EventMessage.GROUP_CREATE_UPDATE, (message: GroupUpdateMessage) => {
callback(this.toGroupCreatedUpdatedMessage(message));
});
}
private toGroupCreatedUpdatedMessage(message: GroupUpdateMessage): GroupCreatedUpdatedMessageInterface {
const position = message.getPosition();
if (position === undefined) {
throw new Error('Missing position in GROUP_CREATE_UPDATE');
}
return {
groupId: message.getGroupid(),
position: position.toObject()
}
}
public onGroupDeleted(callback: (groupId: number) => void): void {
this.onMessage(EventMessage.GROUP_DELETE, (message: GroupDeleteMessage) => {
callback(message.getGroupid());
});
}
public onConnectError(callback: (error: Event) => void): void {
this.socket.addEventListener('error', callback)
}
public onConnect(callback: (event: Event) => void): void {
this.socket.addEventListener('open', callback)
}
/**
* Triggered when we receive all the details of a room (users, groups, ...)
*/
public onStartRoom(callback: (event: RoomJoinedMessageInterface) => void): void {
this.onMessage(EventMessage.START_ROOM, callback);
}
public sendWebrtcSignal(signal: unknown, receiverId: number) {
const webRtcSignal = new WebRtcSignalToServerMessage();
webRtcSignal.setReceiverid(receiverId);
webRtcSignal.setSignal(JSON.stringify(signal));
const clientToServerMessage = new ClientToServerMessage();
clientToServerMessage.setWebrtcsignaltoservermessage(webRtcSignal);
this.socket.send(clientToServerMessage.serializeBinary().buffer);
}
public sendWebrtcScreenSharingSignal(signal: unknown, receiverId: number) {
const webRtcSignal = new WebRtcSignalToServerMessage();
webRtcSignal.setReceiverid(receiverId);
webRtcSignal.setSignal(JSON.stringify(signal));
const clientToServerMessage = new ClientToServerMessage();
clientToServerMessage.setWebrtcscreensharingsignaltoservermessage(webRtcSignal);
this.socket.send(clientToServerMessage.serializeBinary().buffer);
}
public receiveWebrtcStart(callback: (message: UserSimplePeerInterface) => void) {
this.onMessage(EventMessage.WEBRTC_START, (message: WebRtcStartMessage) => {
callback({
userId: message.getUserid(),
name: message.getName(),
initiator: message.getInitiator()
});
});
}
public receiveWebrtcSignal(callback: (message: WebRtcSignalReceivedMessageInterface) => void) {
this.onMessage(EventMessage.WEBRTC_SIGNAL, (message: WebRtcSignalToClientMessage) => {
callback({
userId: message.getUserid(),
signal: JSON.parse(message.getSignal())
});
});
}
public receiveWebrtcScreenSharingSignal(callback: (message: WebRtcSignalReceivedMessageInterface) => void) {
this.onMessage(EventMessage.WEBRTC_SCREEN_SHARING_SIGNAL, (message: WebRtcSignalToClientMessage) => {
callback({
userId: message.getUserid(),
signal: JSON.parse(message.getSignal())
});
});
}
public onServerDisconnected(callback: (event: CloseEvent) => void): void {
this.socket.addEventListener('close', (event) => {
if (this.closed === true) {
return;
}
console.log('Socket closed with code '+event.code+". Reason: "+event.reason);
if (event.code === 1000) {
// Normal closure case
return;
}
callback(event);
});
}
public getUserId(): number|null {
return this.userId;
}
disconnectMessage(callback: (message: WebRtcDisconnectMessageInterface) => void): void {
this.onMessage(EventMessage.WEBRTC_DISCONNECT, (message: WebRtcDisconnectMessage) => {
callback({
userId: message.getUserid()
});
});
}
emitActionableEvent(itemId: number, event: string, state: unknown, parameters: unknown): void {
const itemEventMessage = new ItemEventMessage();
itemEventMessage.setItemid(itemId);
itemEventMessage.setEvent(event);
itemEventMessage.setStatejson(JSON.stringify(state));
itemEventMessage.setParametersjson(JSON.stringify(parameters));
const clientToServerMessage = new ClientToServerMessage();
clientToServerMessage.setItemeventmessage(itemEventMessage);
this.socket.send(clientToServerMessage.serializeBinary().buffer);
}
onActionableEvent(callback: (message: ItemEventMessageInterface) => void): void {
this.onMessage(EventMessage.ITEM_EVENT, (message: ItemEventMessage) => {
callback({
itemId: message.getItemid(),
event: message.getEvent(),
parameters: JSON.parse(message.getParametersjson()),
state: JSON.parse(message.getStatejson())
});
});
}
public uploadAudio(file : FormData){
return Axios.post(`${API_URL}/upload-audio-message`, file).then((res: {data:{}}) => {
return res.data;
}).catch((err) => {
console.error(err);
throw err;
});
}
public receivePlayGlobalMessage(callback: (message: PlayGlobalMessageInterface) => void) {
return this.onMessage(EventMessage.PLAY_GLOBAL_MESSAGE, (message: PlayGlobalMessage) => {
callback({
id: message.getId(),
type: message.getType(),
message: message.getMessage(),
});
});
}
public receiveStopGlobalMessage(callback: (messageId: string) => void) {
return this.onMessage(EventMessage.STOP_GLOBAL_MESSAGE, (message: StopGlobalMessage) => {
callback(message.getId());
});
}
public emitGlobalMessage(message: PlayGlobalMessageInterface){
console.log('emitGlobalMessage', message);
const playGlobalMessage = new PlayGlobalMessage();
playGlobalMessage.setId(message.id);
playGlobalMessage.setType(message.type);
playGlobalMessage.setMessage(message.message);
const clientToServerMessage = new ClientToServerMessage();
clientToServerMessage.setPlayglobalmessage(playGlobalMessage);
this.socket.send(clientToServerMessage.serializeBinary().buffer);
}
public hasTag(tag: string): boolean {
return this.tags.includes(tag);
}
}

View file

@ -7,7 +7,7 @@ const JITSI_URL : string|undefined = (process.env.JITSI_URL === '') ? undefined
const RESOLUTION = 3;
const ZOOM_LEVEL = 1/*3/4*/;
const POSITION_DELAY = 200; // Wait 200ms between sending position events
const MAX_EXTRAPOLATION_TIME = 250; // Extrapolate a maximum of 250ms if no new movement is sent by the player
const MAX_EXTRAPOLATION_TIME = 100; // Extrapolate a maximum of 250ms if no new movement is sent by the player
export {
DEBUG_MODE,

1
front/src/Messages/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/generated/

View file

@ -1,4 +0,0 @@
export interface SetPlayerDetailsMessage {
name: string,
characterLayers: string[]
}

View file

@ -0,0 +1,34 @@
import {PositionMessage} from "../Messages/generated/messages_pb";
import Direction = PositionMessage.Direction;
import {PointInterface} from "../Connexion/ConnexionModels";
export class ProtobufClientUtils {
public static toPointInterface(position: PositionMessage): PointInterface {
let direction: string;
switch (position.getDirection()) {
case Direction.UP:
direction = 'up';
break;
case Direction.DOWN:
direction = 'down';
break;
case Direction.LEFT:
direction = 'left';
break;
case Direction.RIGHT:
direction = 'right';
break;
default:
throw new Error("Unexpected direction");
}
// sending to all clients in room except sender
return {
x: position.getX(),
y: position.getY(),
direction,
moving: position.getMoving(),
};
}
}

View file

@ -10,8 +10,6 @@ export class TextInput extends Phaser.GameObjects.BitmapText {
this.underLine = this.scene.add.text(x, y+1, '_______', { fontFamily: 'Arial', fontSize: "32px", color: '#ffffff'})
const keySpace = this.scene.input.keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.SPACE);
const keyBackspace = this.scene.input.keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.BACKSPACE);
this.scene.input.keyboard.on('keydown', (event: KeyboardEvent) => {
if (event.keyCode === 8 && this.text.length > 0) {
this.deleteLetter();
@ -40,4 +38,16 @@ export class TextInput extends Phaser.GameObjects.BitmapText {
getText(): string {
return this.text;
}
setX(x: number): this {
super.setX(x);
this.underLine.x = x;
return this;
}
setY(y: number): this {
super.setY(y);
this.underLine.y = y+1;
return this;
}
}

View file

@ -1,15 +1,15 @@
import {GameScene} from "../Game/GameScene";
import {PointInterface} from "../../Connection";
import {PointInterface} from "../../Connexion/ConnexionModels";
import {Character} from "../Entity/Character";
/**
* Class representing the sprite of a remote player (a player that plays on another computer)
*/
export class RemotePlayer extends Character {
userId: string;
userId: number;
constructor(
userId: string,
userId: number,
Scene: GameScene,
x: number,
y: number,

View file

@ -1,7 +1,7 @@
import {PointInterface} from "../../Connection";
import {PointInterface} from "../../Connexion/ConnexionModels";
export interface AddPlayerInterface {
userId: string;
userId: number;
name: string;
characterLayers: string[];
position: PointInterface;

View file

@ -1,9 +1,7 @@
import {GameScene} from "./GameScene";
import {
StartMapInterface
} from "../../Connection";
import Axios from "axios";
import {API_URL} from "../../Enum/EnvironmentVariable";
import {connectionManager} from "../../Connexion/ConnectionManager";
import {Room} from "../../Connexion/Room";
import {FourOFourSceneName} from "../Reconnecting/FourOFourScene";
export interface HasMovedEvent {
direction: string;
@ -15,6 +13,12 @@ export interface HasMovedEvent {
export class GameManager {
private playerName!: string;
private characterLayers!: string[];
private startRoom!:Room;
public async init(scenePlugin: Phaser.Scenes.ScenePlugin) {
this.startRoom = await connectionManager.initGameConnexion();
await this.loadMap(this.startRoom, scenePlugin);
}
public setPlayerName(name: string): void {
this.playerName = name;
@ -28,16 +32,6 @@ export class GameManager {
this.characterLayers = layers;
}
loadStartMap() : Promise<StartMapInterface> {
return Axios.get(`${API_URL}/start-map`)
.then((res) => {
return res.data;
}).catch((err) => {
console.error(err);
throw err;
});
}
getPlayerName(): string {
return this.playerName;
}
@ -46,15 +40,31 @@ export class GameManager {
return this.characterLayers;
}
loadMap(mapUrl: string, scene: Phaser.Scenes.ScenePlugin, instance: string): string {
const sceneKey = GameScene.getMapKeyByUrl(mapUrl);
const gameIndex = scene.getIndex(sceneKey);
public async loadMap(room: Room, scenePlugin: Phaser.Scenes.ScenePlugin): Promise<void> {
const roomID = room.id;
const mapUrl = await room.getMapUrl();
console.log('Loading map '+roomID+' at url '+mapUrl);
const gameIndex = scenePlugin.getIndex(mapUrl);
if(gameIndex === -1){
const game : Phaser.Scene = GameScene.createFromUrl(mapUrl, instance);
scene.add(sceneKey, game, false);
const game : Phaser.Scene = GameScene.createFromUrl(room, mapUrl);
console.log('Adding scene '+mapUrl);
scenePlugin.add(mapUrl, game, false);
}
return sceneKey;
}
public getMapKeyByUrl(mapUrlStart: string) : string {
// FIXME: the key should be computed from the full URL of the map.
const startPos = mapUrlStart.indexOf('://')+3;
const endPos = mapUrlStart.indexOf(".json");
return mapUrlStart.substring(startPos, endPos);
}
public async goToStartingMap(scenePlugin: Phaser.Scenes.ScenePlugin) {
const url = await this.startRoom.getMapUrl();
console.log('Starting scene '+url);
scenePlugin.start(url, {startLayerName: 'global'});
}
}

View file

@ -1,16 +1,21 @@
import {GameManager, gameManager, HasMovedEvent} from "./GameManager";
import {
Connection,
GroupCreatedUpdatedMessageInterface,
MessageUserJoined,
MessageUserMovedInterface,
MessageUserPositionInterface,
PointInterface,
PositionInterface
} from "../../Connection";
PositionInterface,
RoomJoinedMessageInterface
} from "../../Connexion/ConnexionModels";
import {CurrentGamerInterface, hasMovedEventName, Player} from "../Player/Player";
import {DEBUG_MODE, JITSI_URL, POSITION_DELAY, RESOLUTION, ZOOM_LEVEL} from "../../Enum/EnvironmentVariable";
import {ITiledMap, ITiledMapLayer, ITiledMapLayerProperty, ITiledTileSet} from "../Map/ITiledMap";
import {
ITiledMap,
ITiledMapLayer,
ITiledMapLayerProperty, ITiledMapObject,
ITiledTileSet
} from "../Map/ITiledMap";
import {PLAYER_RESOURCES, PlayerResourceDescriptionInterface} from "../Entity/Character";
import {AddPlayerInterface} from "./AddPlayerInterface";
import {PlayerAnimationNames} from "../Player/Animation";
@ -20,7 +25,6 @@ import {RemotePlayer} from "../Entity/RemotePlayer";
import {Queue} from 'queue-typescript';
import {SimplePeer, UserSimplePeerInterface} from "../../WebRtc/SimplePeer";
import {ReconnectingSceneName} from "../Reconnecting/ReconnectingScene";
import {FourOFourSceneName} from "../Reconnecting/FourOFourScene";
import {loadAllLayers} from "../Entity/body_character";
import {CenterListener, layoutManager, LayoutMode} from "../../WebRtc/LayoutManager";
import Texture = Phaser.Textures.Texture;
@ -31,6 +35,18 @@ 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";
import {UserInputManager} from "../UserInput/UserInputManager";
import {UserMovedMessage} from "../../Messages/generated/messages_pb";
import {ProtobufClientUtils} from "../../Network/ProtobufClientUtils";
import {connectionManager} from "../../Connexion/ConnectionManager";
import {RoomConnection} from "../../Connexion/RoomConnection";
import {GlobalMessageManager} from "../../Administration/GlobalMessageManager";
import {ConsoleGlobalMessageManager} from "../../Administration/ConsoleGlobalMessageManager";
import {ResizableScene} from "../Login/ResizableScene";
import {Room} from "../../Connexion/Room";
export enum Textures {
@ -54,7 +70,7 @@ interface AddPlayerEventInterface {
interface RemovePlayerEventInterface {
type: 'RemovePlayerEvent'
userId: string
userId: number
}
interface UserMovedEventInterface {
@ -69,29 +85,35 @@ interface GroupCreatedUpdatedEventInterface {
interface DeleteGroupEventInterface {
type: 'DeleteGroupEvent'
groupId: string
groupId: number
}
export class GameScene extends Phaser.Scene implements CenterListener {
export class GameScene extends ResizableScene implements CenterListener {
GameManager : GameManager;
Terrains : Array<Phaser.Tilemaps.Tileset>;
CurrentPlayer!: CurrentGamerInterface;
MapPlayers!: Phaser.Physics.Arcade.Group;
MapPlayersByKey : Map<string, RemotePlayer> = new Map<string, RemotePlayer>();
MapPlayersByKey : Map<number, RemotePlayer> = new Map<number, RemotePlayer>();
Map!: Phaser.Tilemaps.Tilemap;
Layers!: Array<Phaser.Tilemaps.StaticTilemapLayer>;
Objects!: Array<Phaser.Physics.Arcade.Sprite>;
mapFile!: ITiledMap;
groups: Map<string, Sprite>;
groups: Map<number, Sprite>;
startX!: number;
startY!: number;
circleTexture!: CanvasTexture;
pendingEvents: Queue<InitUserPositionEventInterface|AddPlayerEventInterface|RemovePlayerEventInterface|UserMovedEventInterface|GroupCreatedUpdatedEventInterface|DeleteGroupEventInterface> = new Queue<InitUserPositionEventInterface|AddPlayerEventInterface|RemovePlayerEventInterface|UserMovedEventInterface|GroupCreatedUpdatedEventInterface|DeleteGroupEventInterface>();
private initPosition: PositionInterface|null = null;
private playersPositionInterpolator = new PlayersPositionInterpolator();
private connection!: Connection;
private connection!: RoomConnection;
private simplePeer!: SimplePeer;
private connectionPromise!: Promise<Connection>
private GlobalMessageManager!: GlobalMessageManager;
private ConsoleGlobalMessageManager!: ConsoleGlobalMessageManager;
private connectionAnswerPromise: Promise<RoomJoinedMessageInterface>;
private connectionAnswerPromiseResolve!: (value?: RoomJoinedMessageInterface | PromiseLike<RoomJoinedMessageInterface>) => void;
// A promise that will resolve when the "create" method is called (signaling loading is ended)
private createPromise: Promise<void>;
private createPromiseResolve!: (value?: void | PromiseLike<void>) => void;
MapKey: string;
MapUrlFile: string;
@ -111,30 +133,40 @@ 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 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;
private userInputManager!: UserInputManager;
static createFromUrl(mapUrlFile: string, instance: string, key: string|null = null): GameScene {
const mapKey = GameScene.getMapKeyByUrl(mapUrlFile);
if (key === null) {
key = mapKey;
static createFromUrl(room: Room, mapUrlFile: string, gameSceneKey: string|null = null): GameScene {
// We use the map URL as a key
if (gameSceneKey === null) {
gameSceneKey = mapUrlFile;
}
return new GameScene(mapKey, mapUrlFile, instance, key);
return new GameScene(room, mapUrlFile, gameSceneKey);
}
constructor(MapKey : string, MapUrlFile: string, instance: string, key: string) {
constructor(private room: Room, MapUrlFile: string, gameSceneKey: string) {
super({
key: key
key: gameSceneKey
});
this.GameManager = gameManager;
this.Terrains = [];
this.groups = new Map<string, Sprite>();
this.instance = instance;
this.groups = new Map<number, Sprite>();
this.instance = room.getInstance();
this.MapKey = MapKey;
this.MapKey = MapUrlFile;
this.MapUrlFile = MapUrlFile;
this.RoomId = this.instance + '__' + MapKey;
this.RoomId = room.id;
this.createPromise = new Promise<void>((resolve, reject): void => {
this.createPromiseResolve = resolve;
})
this.connectionAnswerPromise = new Promise<RoomJoinedMessageInterface>((resolve, reject): void => {
this.connectionAnswerPromiseResolve = resolve;
})
}
//hook preload scene
@ -174,87 +206,11 @@ export class GameScene extends Phaser.Scene implements CenterListener {
loadAllLayers(this.load);
this.load.bitmapFont('main_font', 'resources/fonts/arcade.png', 'resources/fonts/arcade.xml');
this.connectionPromise = Connection.createConnection(gameManager.getPlayerName(), gameManager.getCharacterSelected()).then((connection : Connection) => {
this.connection = connection;
connection.onUserJoins((message: MessageUserJoined) => {
const userMessage: AddPlayerInterface = {
userId: message.userId,
characterLayers: message.characterLayers,
name: message.name,
position: message.position
}
this.addPlayer(userMessage);
});
connection.onUserMoved((message: MessageUserMovedInterface) => {
this.updatePlayerPosition(message);
});
connection.onUserLeft((userId: string) => {
this.removePlayer(userId);
});
connection.onGroupUpdatedOrCreated((groupPositionMessage: GroupCreatedUpdatedMessageInterface) => {
this.shareGroupPosition(groupPositionMessage);
})
connection.onGroupDeleted((groupId: string) => {
try {
this.deleteGroup(groupId);
} catch (e) {
console.error(e);
}
})
connection.onServerDisconnected(() => {
console.log('Player disconnected from server. Reloading scene.');
this.simplePeer.closeAllConnections();
this.simplePeer.unregister();
const key = 'somekey'+Math.round(Math.random()*10000);
const game : Phaser.Scene = GameScene.createFromUrl(this.MapUrlFile, this.instance, key);
this.scene.add(key, game, true,
{
initPosition: {
x: this.CurrentPlayer.x,
y: this.CurrentPlayer.y
}
});
this.scene.stop(this.scene.key);
this.scene.remove(this.scene.key);
window.removeEventListener('resize', this.repositionCallback);
})
// When connection is performed, let's connect SimplePeer
this.simplePeer = new SimplePeer(this.connection);
const self = this;
this.simplePeer.registerPeerConnectionListener({
onConnect(user: UserSimplePeerInterface) {
self.presentationModeSprite.setVisible(true);
self.chatModeSprite.setVisible(true);
},
onDisconnect(userId: string) {
if (self.simplePeer.getNbConnections() === 0) {
self.presentationModeSprite.setVisible(false);
self.chatModeSprite.setVisible(false);
}
}
})
this.scene.wake();
this.scene.sleep(ReconnectingSceneName);
return connection;
});
}
// FIXME: we need to put a "unknown" instead of a "any" and validate the structure of the JSON we are receiving.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
private onMapLoad(data: any): void {
private async onMapLoad(data: any): Promise<void> {
// Triggered when the map is loaded
// Load tiles attached to the map recursively
this.mapFile = data.data;
@ -267,6 +223,92 @@ export class GameScene extends Phaser.Scene implements CenterListener {
//TODO strategy to add access token
this.load.image(`${url}/${tileset.image}`, `${url}/${tileset.image}`);
})
// Scan the object layers for objects to load and load them.
const objects = new Map<string, ITiledMapObject[]>();
for (const layer of this.mapFile.layers) {
if (layer.type === 'objectgroup') {
for (const object of layer.objects) {
let objectsOfType: ITiledMapObject[]|undefined;
if (!objects.has(object.type)) {
objectsOfType = new Array<ITiledMapObject>();
} else {
objectsOfType = objects.get(object.type);
if (objectsOfType === undefined) {
throw new Error('Unexpected object type not found');
}
}
objectsOfType.push(object);
objects.set(object.type, objectsOfType);
}
}
}
for (const [itemType, objectsOfType] of objects) {
// FIXME: we would ideally need for the loader to WAIT for the import to be performed, which means writing our own loader plugin.
let itemFactory: ItemFactoryInterface;
switch (itemType) {
case 'computer': {
const module = await import('../Items/Computer/computer');
itemFactory = module.default;
break;
}
default:
throw new Error('Unsupported object type: "'+ itemType +'"');
}
itemFactory.preload(this.load);
this.load.start(); // Let's manually start the loader because the import might be over AFTER the loading ends.
this.load.on('complete', () => {
// FIXME: the factory might fail because the resources might not be loaded yet...
// We would need to add a loader ended event in addition to the createPromise
this.createPromise.then(async () => {
itemFactory.create(this);
const roomJoinedAnswer = await this.connectionAnswerPromise;
for (const object of objectsOfType) {
// TODO: we should pass here a factory to create sprites (maybe?)
// Do we have a state for this object?
const state = roomJoinedAnswer.items[object.id];
const actionableItem = itemFactory.factory(this, object, state);
this.actionableItems.set(actionableItem.getId(), actionableItem);
}
});
});
// import(/* webpackIgnore: true */ scriptUrl).then(result => {
//
// result.default.preload(this.load);
//
// this.load.start(); // Let's manually start the loader because the import might be over AFTER the loading ends.
// this.load.on('complete', () => {
// // FIXME: the factory might fail because the resources might not be loaded yet...
// // We would need to add a loader ended event in addition to the createPromise
// this.createPromise.then(() => {
// result.default.create(this);
//
// for (let object of objectsOfType) {
// // TODO: we should pass here a factory to create sprites (maybe?)
// let objectSprite = result.default.factory(this, object);
// }
// });
// });
// });
}
// TEST: let's load a module dynamically!
/*let foo = "http://maps.workadventure.localhost/computer.js";
import(/* webpackIgnore: true * / foo).then(result => {
console.log(result);
});*/
}
//hook initialisation
@ -352,13 +394,15 @@ export class GameScene extends Phaser.Scene implements CenterListener {
//initialise list of other player
this.MapPlayers = this.physics.add.group({ immovable: true });
//create input to move
this.userInputManager = new UserInputManager(this);
//notify game manager can to create currentUser in map
this.createCurrentPlayer();
//initialise camera
this.initCamera();
// Let's generate the circle for the group delimiter
const circleElement = Object.values(this.textures.list).find((object: Texture) => object.key === 'circleSprite');
if(circleElement) {
@ -373,14 +417,6 @@ export class GameScene extends Phaser.Scene implements CenterListener {
context.stroke();
this.circleTexture.refresh();
// Let's alter browser history
const url = new URL(this.MapUrlFile);
let path = '/_/'+this.instance+'/'+url.host+url.pathname;
if (this.startLayerName) {
path += '#'+this.startLayerName;
}
window.history.pushState({}, 'WorkAdventure', path);
// Let's pause the scene if the connection is not established yet
if (this.connection === undefined) {
// Let's wait 0.5 seconds before printing the "connecting" screen to avoid blinking
@ -392,17 +428,25 @@ export class GameScene extends Phaser.Scene implements CenterListener {
}, 500);
}
this.createPromiseResolve();
this.userInputManager.spaceEvent( () => {
this.outlinedItem?.activate();
});
this.presentationModeSprite = this.add.sprite(2, this.game.renderer.height - 2, 'layout_modes', 0);
this.presentationModeSprite.setScrollFactor(0, 0);
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
@ -410,8 +454,6 @@ export class GameScene extends Phaser.Scene implements CenterListener {
this.switchLayoutMode();
});
this.repositionCallback = this.reposition.bind(this);
window.addEventListener('resize', this.repositionCallback);
this.reposition();
// From now, this game scene will be notified of reposition events
@ -462,6 +504,136 @@ export class GameScene extends Phaser.Scene implements CenterListener {
this.connection.setSilent(true);
}
});
const camera = this.cameras.main;
connectionManager.connectToRoomSocket(
this.RoomId,
gameManager.getPlayerName(),
gameManager.getCharacterSelected(),
{
x: this.startX,
y: this.startY
},
{
left: camera.scrollX,
top: camera.scrollY,
right: camera.scrollX + camera.width,
bottom: camera.scrollY + camera.height,
}).then((connection : RoomConnection) => {
this.connection = connection;
//this.connection.emitPlayerDetailsMessage(gameManager.getPlayerName(), gameManager.getCharacterSelected())
connection.onStartRoom((roomJoinedMessage: RoomJoinedMessageInterface) => {
this.initUsersPosition(roomJoinedMessage.users);
this.connectionAnswerPromiseResolve(roomJoinedMessage);
// Analyze tags to find if we are admin. If yes, show console.
if (this.connection.hasTag('admin')) {
this.ConsoleGlobalMessageManager = new ConsoleGlobalMessageManager(this.connection, this.userInputManager);
}
});
connection.onUserJoins((message: MessageUserJoined) => {
const userMessage: AddPlayerInterface = {
userId: message.userId,
characterLayers: message.characterLayers,
name: message.name,
position: message.position
}
this.addPlayer(userMessage);
});
connection.onUserMoved((message: UserMovedMessage) => {
const position = message.getPosition();
if (position === undefined) {
throw new Error('Position missing from UserMovedMessage');
}
//console.log('Received position ', position.getX(), position.getY(), "from user", message.getUserid());
const messageUserMoved: MessageUserMovedInterface = {
userId: message.getUserid(),
position: ProtobufClientUtils.toPointInterface(position)
}
this.updatePlayerPosition(messageUserMoved);
});
connection.onUserLeft((userId: number) => {
this.removePlayer(userId);
});
connection.onGroupUpdatedOrCreated((groupPositionMessage: GroupCreatedUpdatedMessageInterface) => {
this.shareGroupPosition(groupPositionMessage);
})
connection.onGroupDeleted((groupId: number) => {
try {
this.deleteGroup(groupId);
} catch (e) {
console.error(e);
}
})
connection.onServerDisconnected(() => {
console.log('Player disconnected from server. Reloading scene.');
this.simplePeer.closeAllConnections();
this.simplePeer.unregister();
const gameSceneKey = 'somekey'+Math.round(Math.random()*10000);
const game : Phaser.Scene = GameScene.createFromUrl(this.room, this.MapUrlFile, gameSceneKey);
this.scene.add(gameSceneKey, game, true,
{
initPosition: {
x: this.CurrentPlayer.x,
y: this.CurrentPlayer.y
}
});
this.scene.stop(this.scene.key);
this.scene.remove(this.scene.key);
})
connection.onActionableEvent((message => {
const item = this.actionableItems.get(message.itemId);
if (item === undefined) {
console.warn('Received an event about object "'+message.itemId+'" but cannot find this item on the map.');
return;
}
item.fire(message.event, message.state, message.parameters);
}));
// When connection is performed, let's connect SimplePeer
this.simplePeer = new SimplePeer(this.connection);
this.GlobalMessageManager = new GlobalMessageManager(this.connection);
const self = this;
this.simplePeer.registerPeerConnectionListener({
onConnect(user: UserSimplePeerInterface) {
self.presentationModeSprite.setVisible(true);
self.chatModeSprite.setVisible(true);
},
onDisconnect(userId: number) {
if (self.simplePeer.getNbConnections() === 0) {
self.presentationModeSprite.setVisible(false);
self.chatModeSprite.setVisible(false);
}
}
})
//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);
})
this.scene.wake();
this.scene.sleep(ReconnectingSceneName);
return connection;
});
}
private switchLayoutMode(): void {
@ -508,6 +680,7 @@ export class GameScene extends Phaser.Scene implements CenterListener {
* @param tileWidth
* @param tileHeight
*/
//todo: push that into the gameManager
private loadNextGame(layer: ITiledMapLayer, mapWidth: number, tileWidth: number, tileHeight: number){
const exitSceneUrl = this.getExitSceneUrl(layer);
if (exitSceneUrl === undefined) {
@ -518,9 +691,20 @@ export class GameScene extends Phaser.Scene implements CenterListener {
instance = this.instance;
}
console.log('existSceneUrl', exitSceneUrl);
console.log('existSceneInstance', instance);
// TODO: eventually compute a relative URL
// TODO: handle /@/ URL CASES!
const absoluteExitSceneUrl = new URL(exitSceneUrl, this.MapUrlFile).href;
const exitSceneKey = gameManager.loadMap(absoluteExitSceneUrl, this.scene, instance);
const absoluteExitSceneUrlWithoutProtocol = absoluteExitSceneUrl.toString().substr(absoluteExitSceneUrl.toString().indexOf('://')+3);
const roomId = '_/'+instance+'/'+absoluteExitSceneUrlWithoutProtocol;
console.log("Foo", instance, absoluteExitSceneUrlWithoutProtocol);
const room = new Room(roomId);
gameManager.loadMap(room, this.scene);
const exitSceneKey = roomId;
const tiles : number[] = layer.data as number[];
for (let key=0; key < tiles.length; key++) {
@ -541,10 +725,12 @@ export class GameScene extends Phaser.Scene implements CenterListener {
if (this.PositionNextScene[y] === undefined) {
this.PositionNextScene[y] = new Array<{key: string, hash: string}>();
}
this.PositionNextScene[y][x] = {
key: exitSceneKey,
hash
}
room.getMapUrl().then((url: string) => {
this.PositionNextScene[y][x] = {
key: url,
hash
}
})
}
}
@ -607,14 +793,6 @@ export class GameScene extends Phaser.Scene implements CenterListener {
});
}
createCollisionObject(){
/*this.Objects.forEach((Object : Phaser.Physics.Arcade.Sprite) => {
this.physics.add.collider(this.CurrentPlayer, Object, (object1, object2) => {
this.CurrentPlayer.say("Collision with object : " + (object2 as Phaser.Physics.Arcade.Sprite).texture.key)
});
})*/
}
createCurrentPlayer(){
//initialise player
//TODO create animation moving between exit and start
@ -625,25 +803,12 @@ export class GameScene extends Phaser.Scene implements CenterListener {
this.GameManager.getPlayerName(),
this.GameManager.getCharacterSelected(),
PlayerAnimationNames.WalkDown,
false
false,
this.userInputManager
);
//create collision
this.createCollisionWithPlayer();
this.createCollisionObject();
//join room
this.connectionPromise.then((connection: Connection) => {
connection.joinARoom(this.RoomId, this.startX, this.startY, PlayerAnimationNames.WalkDown, false).then((userPositions: MessageUserPositionInterface[]) => {
this.initUsersPosition(userPositions);
});
//listen event to share position of user
this.CurrentPlayer.on(hasMovedEventName, this.pushPlayerPosition.bind(this))
this.CurrentPlayer.on(hasMovedEventName, (event: HasMovedEvent) => {
this.gameMap.setPosition(event.x, event.y);
})
});
}
pushPlayerPosition(event: HasMovedEvent) {
@ -672,10 +837,59 @@ export class GameScene extends Phaser.Scene implements CenterListener {
// Otherwise, do nothing.
}
/**
* Finds the correct item to outline and outline it (if there is an item to be outlined)
* @param event
*/
private outlineItem(event: HasMovedEvent): void {
let x = event.x;
let y = event.y;
switch (event.direction) {
case PlayerAnimationNames.WalkUp:
y -= 32;
break;
case PlayerAnimationNames.WalkDown:
y += 32;
break;
case PlayerAnimationNames.WalkLeft:
x -= 32;
break;
case PlayerAnimationNames.WalkRight:
x += 32;
break;
default:
throw new Error('Unexpected direction "' + event.direction + '"');
}
let shortestDistance: number = Infinity;
let selectedItem: ActionableItem|null = null;
for (const item of this.actionableItems.values()) {
const distance = item.actionableDistance(x, y);
if (distance !== null && distance < shortestDistance) {
shortestDistance = distance;
selectedItem = item;
}
}
if (this.outlinedItem === selectedItem) {
return;
}
this.outlinedItem?.notSelectable();
this.outlinedItem = selectedItem;
this.outlinedItem?.selectable();
}
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(){
@ -724,7 +938,7 @@ export class GameScene extends Phaser.Scene implements CenterListener {
// Let's move all users
const updatedPlayersPositions = this.playersPositionInterpolator.getUpdatedPositions(time);
updatedPlayersPositions.forEach((moveEvent: HasMovedEvent, userId: string) => {
updatedPlayersPositions.forEach((moveEvent: HasMovedEvent, userId: number) => {
const player : RemotePlayer | undefined = this.MapPlayersByKey.get(userId);
if (player === undefined) {
throw new Error('Cannot find player with ID "' + userId +'"');
@ -733,23 +947,19 @@ export class GameScene extends Phaser.Scene implements CenterListener {
});
const nextSceneKey = this.checkToExit();
if(nextSceneKey){
if (nextSceneKey) {
// We are completely destroying the current scene to avoid using a half-backed instance when coming back to the same map.
this.connection.closeConnection();
this.simplePeer.unregister();
this.scene.stop();
this.scene.remove(this.scene.key);
window.removeEventListener('resize', this.repositionCallback);
this.scene.start(nextSceneKey.key, {
startLayerName: nextSceneKey.hash
});
}
}
/**
*
*/
checkToExit(): {key: string, hash: string} | null {
private checkToExit(): {key: string, hash: string} | null {
const x = Math.floor(this.CurrentPlayer.x / 32);
const y = Math.floor(this.CurrentPlayer.y / 32);
@ -768,7 +978,6 @@ export class GameScene extends Phaser.Scene implements CenterListener {
type: "InitUserPositionEvent",
event: usersPosition
});
}
/**
@ -782,7 +991,7 @@ export class GameScene extends Phaser.Scene implements CenterListener {
player.destroy();
this.MapPlayers.remove(player);
});
this.MapPlayersByKey = new Map<string, RemotePlayer>();
this.MapPlayersByKey = new Map<number, RemotePlayer>();
// load map
usersPosition.forEach((userPosition : MessageUserPositionInterface) => {
@ -839,14 +1048,14 @@ export class GameScene extends Phaser.Scene implements CenterListener {
/**
* Called by the connexion when a player is removed from the map
*/
public removePlayer(userId: string) {
public removePlayer(userId: number) {
this.pendingEvents.enqueue({
type: "RemovePlayerEvent",
userId
});
}
private doRemovePlayer(userId: string) {
private doRemovePlayer(userId: number) {
const player = this.MapPlayersByKey.get(userId);
if (player === undefined) {
console.error('Cannot find user with id ', userId);
@ -876,6 +1085,7 @@ export class GameScene extends Phaser.Scene implements CenterListener {
// We do not update the player position directly (because it is sent only every 200ms).
// Instead we use the PlayersPositionInterpolator that will do a smooth animation over the next 200ms.
const playerMovement = new PlayerMovement({ x: player.x, y: player.y }, this.currentTick, message.position, this.currentTick + POSITION_DELAY);
//console.log('Target position: ', player.x, player.y);
this.playersPositionInterpolator.updatePlayerPosition(player.userId, playerMovement);
}
@ -905,14 +1115,14 @@ export class GameScene extends Phaser.Scene implements CenterListener {
}
}
deleteGroup(groupId: string): void {
deleteGroup(groupId: number): void {
this.pendingEvents.enqueue({
type: "DeleteGroupEvent",
groupId
});
}
doDeleteGroup(groupId: string): void {
doDeleteGroup(groupId: number): void {
const group = this.groups.get(groupId);
if(!group){
return;
@ -921,11 +1131,31 @@ export class GameScene extends Phaser.Scene implements CenterListener {
this.groups.delete(groupId);
}
public static getMapKeyByUrl(mapUrlStart: string) : string {
// FIXME: the key should be computed from the full URL of the map.
const startPos = mapUrlStart.indexOf('://')+3;
const endPos = mapUrlStart.indexOf(".json");
return mapUrlStart.substring(startPos, endPos);
/**
* Sends to the server an event emitted by one of the ActionableItems.
*
* @param itemId
* @param eventName
* @param state
* @param parameters
*/
emitActionableEvent(itemId: number, eventName: string, state: unknown, parameters: unknown) {
this.connection.emitActionableEvent(itemId, eventName, state, parameters);
}
public 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 {

View file

@ -1,6 +1,6 @@
import {HasMovedEvent} from "./GameManager";
import {MAX_EXTRAPOLATION_TIME} from "../../Enum/EnvironmentVariable";
import {PositionInterface} from "../../Connection";
import {PositionInterface} from "../../Connexion/ConnexionModels";
export class PlayerMovement {
public constructor(private startPosition: PositionInterface, private startTick: number, private endPosition: HasMovedEvent, private endTick: number) {
@ -20,12 +20,13 @@ export class PlayerMovement {
public getPosition(tick: number): HasMovedEvent {
// Special case: end position reached and end position is not moving
if (tick >= this.endTick && this.endPosition.moving === false) {
//console.log('Movement finished ', this.endPosition)
return this.endPosition;
}
const x = (this.endPosition.x - this.startPosition.x) * ((tick - this.startTick) / (this.endTick - this.startTick)) + this.startPosition.x;
const y = (this.endPosition.y - this.startPosition.y) * ((tick - this.startTick) / (this.endTick - this.startTick)) + this.startPosition.y;
//console.log('Computed position ', x, y)
return {
x,
y,

View file

@ -6,19 +6,19 @@ import {PlayerMovement} from "./PlayerMovement";
import {HasMovedEvent} from "./GameManager";
export class PlayersPositionInterpolator {
playerMovements: Map<string, PlayerMovement> = new Map<string, PlayerMovement>();
playerMovements: Map<number, PlayerMovement> = new Map<number, PlayerMovement>();
updatePlayerPosition(userId: string, playerMovement: PlayerMovement) : void {
updatePlayerPosition(userId: number, playerMovement: PlayerMovement) : void {
this.playerMovements.set(userId, playerMovement);
}
removePlayer(userId: string): void {
removePlayer(userId: number): void {
this.playerMovements.delete(userId);
}
getUpdatedPositions(tick: number) : Map<string, HasMovedEvent> {
const positions = new Map<string, HasMovedEvent>();
this.playerMovements.forEach((playerMovement: PlayerMovement, userId: string) => {
getUpdatedPositions(tick: number) : Map<number, HasMovedEvent> {
const positions = new Map<number, HasMovedEvent>();
this.playerMovements.forEach((playerMovement: PlayerMovement, userId: number) => {
if (playerMovement.isOutdated(tick)) {
//console.log("outdated")
this.playerMovements.delete(userId);

View file

@ -0,0 +1,92 @@
/**
* An actionable item represents an in-game object that can be activated using the space-bar.
* It has coordinates and an "activation radius"
*/
import Sprite = Phaser.GameObjects.Sprite;
import {OutlinePipeline} from "../Shaders/OutlinePipeline";
import {GameScene} from "../Game/GameScene";
type EventCallback = (state: unknown, parameters: unknown) => void;
export class ActionableItem {
private readonly activationRadiusSquared : number;
private isSelectable: boolean = false;
private callbacks: Map<string, Array<EventCallback>> = new Map<string, Array<EventCallback>>();
public constructor(private id: number, private sprite: Sprite, private eventHandler: GameScene, private activationRadius: number, private onActivateCallback: (item: ActionableItem) => void) {
this.activationRadiusSquared = activationRadius * activationRadius;
}
public getId(): number {
return this.id;
}
/**
* Returns the square of the distance to the object center IF we are in item action range
* OR null if we are out of range.
*/
public actionableDistance(x: number, y: number): number|null {
const distanceSquared = (x - this.sprite.x)*(x - this.sprite.x) + (y - this.sprite.y)*(y - this.sprite.y);
if (distanceSquared < this.activationRadiusSquared) {
return distanceSquared;
} else {
return null;
}
}
/**
* Show the outline of the sprite.
*/
public selectable(): void {
if (this.isSelectable) {
return;
}
this.isSelectable = true;
this.sprite.setPipeline(OutlinePipeline.KEY);
this.sprite.pipeline.setFloat2('uTextureSize',
this.sprite.texture.getSourceImage().width, this.sprite.texture.getSourceImage().height);
}
/**
* Hide the outline of the sprite
*/
public notSelectable(): void {
if (!this.isSelectable) {
return;
}
this.isSelectable = false;
this.sprite.resetPipeline();
}
/**
* Triggered when the "space" key is pressed and the object is in range of being activated.
*/
public activate(): void {
this.onActivateCallback(this);
}
public emit(eventName: string, state: unknown, parameters: unknown = null): void {
this.eventHandler.emitActionableEvent(this.id, eventName, state, parameters);
// Also, execute the action locally.
this.fire(eventName, state, parameters);
}
public on(eventName: string, callback: EventCallback): void {
let callbacksArray: Array<EventCallback>|undefined = this.callbacks.get(eventName);
if (callbacksArray === undefined) {
callbacksArray = new Array<EventCallback>();
this.callbacks.set(eventName, callbacksArray);
}
callbacksArray.push(callback);
}
public fire(eventName: string, state: unknown, parameters: unknown): void {
const callbacksArray = this.callbacks.get(eventName);
if (callbacksArray === undefined) {
return;
}
for (const callback of callbacksArray) {
callback(state, parameters);
}
}
}

View file

@ -0,0 +1,86 @@
import * as Phaser from 'phaser';
import {Scene} from "phaser";
import Sprite = Phaser.GameObjects.Sprite;
import {ITiledMapObject} from "../../Map/ITiledMap";
import {ItemFactoryInterface} from "../ItemFactoryInterface";
import {GameScene} from "../../Game/GameScene";
import {ActionableItem} from "../ActionableItem";
import * as tg from "generic-type-guard";
const isComputerState =
new tg.IsInterface().withProperties({
status: tg.isString,
}).get();
type ComputerState = tg.GuardedType<typeof isComputerState>;
let state: ComputerState = {
'status': 'off'
};
export default {
preload: (loader: Phaser.Loader.LoaderPlugin): void => {
loader.atlas('computer', '/resources/items/computer/computer.png', '/resources/items/computer/computer_atlas.json');
},
create: (scene: GameScene): void => {
scene.anims.create({
key: 'computer_off',
frames: [
{
key: 'computer',
frame: 'computer_off'
}
],
frameRate: 10,
repeat: -1
});
scene.anims.create({
key: 'computer_run',
frames: [
{
key: 'computer',
frame: 'computer_on1'
},
{
key: 'computer',
frame: 'computer_on2'
}
],
frameRate: 5,
repeat: -1
});
},
factory: (scene: GameScene, object: ITiledMapObject, initState: unknown): ActionableItem => {
if (initState !== undefined) {
if (!isComputerState(initState)) {
throw new Error('Invalid state received for computer object');
}
state = initState;
}
// Idée: ESSAYER WebPack? https://paultavares.wordpress.com/2018/07/02/webpack-how-to-generate-an-es-module-bundle/
const computer = new Sprite(scene, object.x, object.y, 'computer');
scene.add.existing(computer);
if (state.status === 'on') {
computer.anims.play('computer_run');
}
const item = new ActionableItem(object.id, computer, scene, 32, (item: ActionableItem) => {
if (state.status === 'off') {
state.status = 'on';
item.emit('TURN_ON', state);
} else {
state.status = 'off';
item.emit('TURN_OFF', state);
}
});
item.on('TURN_ON', () => {
computer.anims.play('computer_run');
});
item.on('TURN_OFF', () => {
computer.anims.play('computer_off');
});
return item;
//scene.add.sprite(object.x, object.y, 'computer');
}
} as ItemFactoryInterface;

View file

@ -0,0 +1,10 @@
import LoaderPlugin = Phaser.Loader.LoaderPlugin;
import {GameScene} from "../Game/GameScene";
import {ITiledMapObject} from "../Map/ITiledMap";
import {ActionableItem} from "./ActionableItem";
export interface ItemFactoryInterface {
preload: (loader: LoaderPlugin) => void;
create: (scene: GameScene) => void;
factory: (scene: GameScene, object: ITiledMapObject, state: unknown) => ActionableItem;
}

Some files were not shown because too many files have changed in this diff Show more