Merge pull request #1324 from thecodingmachine/develop

Release v1.4.11
This commit is contained in:
grégoire parant 2021-08-02 11:23:16 +02:00 committed by GitHub
commit f52b459872
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
126 changed files with 4384 additions and 2894 deletions

View file

@ -19,3 +19,6 @@ ACME_EMAIL=
MAX_PER_GROUP=4 MAX_PER_GROUP=4
MAX_USERNAME_LENGTH=8 MAX_USERNAME_LENGTH=8
OPID_CLIENT_ID=
OPID_CLIENT_SECRET=
OPID_CLIENT_ISSUER=

View file

@ -50,6 +50,7 @@ jobs:
run: yarn run build run: yarn run build
env: env:
PUSHER_URL: "//localhost:8080" PUSHER_URL: "//localhost:8080"
ADMIN_URL: "//localhost:80"
working-directory: "front" working-directory: "front"
- name: "Svelte check" - name: "Svelte check"
@ -81,7 +82,7 @@ jobs:
- name: "Setup NodeJS" - name: "Setup NodeJS"
uses: actions/setup-node@v1 uses: actions/setup-node@v1
with: with:
node-version: '12.x' node-version: '14.x'
- name: Install Protoc - name: Install Protoc
uses: arduino/setup-protoc@v1 uses: arduino/setup-protoc@v1

View file

@ -47,6 +47,7 @@ jobs:
run: yarn run build-typings run: yarn run build-typings
env: env:
PUSHER_URL: "//localhost:8080" PUSHER_URL: "//localhost:8080"
ADMIN_URL: "//localhost:80"
working-directory: "front" working-directory: "front"
# We build the front to generate the typings of iframe_api, then we copy those typings in a separate package. # We build the front to generate the typings of iframe_api, then we copy those typings in a separate package.

View file

@ -1,4 +1,12 @@
## Version 1.4.x-dev ## Version develop
### Updates
- Rewrote the way authentification works: the auth jwt token can now contains an email instead of an uuid
- Added an OpenId login flow than can be plugged to any OIDC provider.
-
## Version 1.4.10
### Updates ### Updates
@ -8,17 +16,27 @@
- Migrated the admin console to Svelte, and redesigned the console #1211 - Migrated the admin console to Svelte, and redesigned the console #1211
- Layer properties (like `exitUrl`, `silent`, etc...) can now also used in tile properties #1210 (@jonnytest1) - Layer properties (like `exitUrl`, `silent`, etc...) can now also used in tile properties #1210 (@jonnytest1)
- New scripting API features : - New scripting API features :
- Use `WA.onInit(): Promise<void>` to wait for scripting API initialization
- Use `WA.room.showLayer(): void` to show a layer - Use `WA.room.showLayer(): void` to show a layer
- Use `WA.room.hideLayer(): void` to hide a layer - Use `WA.room.hideLayer(): void` to hide a layer
- Use `WA.room.setProperty() : void` to add, delete or change existing property of a layer - Use `WA.room.setProperty() : void` to add, delete or change existing property of a layer
- Use `WA.player.onPlayerMove(): void` to track the movement of the current player - Use `WA.player.onPlayerMove(): void` to track the movement of the current player
- Use `WA.player.getCurrentUser(): Promise<User>` to get the ID, name and tags of the current player - Use `WA.player.id: string|undefined` to get the ID of the current player
- Use `WA.room.getCurrentRoom(): Promise<Room>` to get the ID, JSON map file, url of the map of the current room and the layer where the current player started - Use `WA.player.name: string` to get the name of the current player
- Use `WA.ui.registerMenuCommand(): void` to add a custom menu - Use `WA.player.tags: string[]` to get the tags of the current player
- Use `WA.room.id: string` to get the ID of the room
- Use `WA.room.mapURL: string` to get the URL of the map
- Use `WA.room.mapURL: string` to get the URL of the map
- Use `WA.room.getMap(): Promise<ITiledMap>` to get the JSON map file
- Use `WA.room.setTiles(): void` to add, delete or change an array of tiles - Use `WA.room.setTiles(): void` to add, delete or change an array of tiles
- Use `WA.ui.registerMenuCommand(): void` to add a custom menu
- Use `WA.state.loadVariable(key: string): unknown` to retrieve a variable
- Use `WA.state.saveVariable(key: string, value: unknown): Promise<void>` to set a variable (across the room, for all users)
- Use `WA.state.onVariableChange(key: string): Observable<unknown>` to track a variable
- Use `WA.state.[any variable]: unknown` to access directly any variable (this is a shortcut to using `WA.state.loadVariable` and `WA.state.saveVariable`)
- Users blocking now relies on UUID rather than ID. A blocked user that leaves a room and comes back will stay blocked. - Users blocking now relies on UUID rather than ID. A blocked user that leaves a room and comes back will stay blocked.
- The text chat was redesigned to be prettier and to use more features : - The text chat was redesigned to be prettier and to use more features :
- The chat is now persistent bewteen discussions and always accesible - The chat is now persistent between discussions and always accessible
- The chat now tracks incoming and outcoming users in your conversation - The chat now tracks incoming and outcoming users in your conversation
- The chat allows your to see the visit card of users - The chat allows your to see the visit card of users
- You can close the chat window with the escape key - You can close the chat window with the escape key

View file

@ -40,6 +40,7 @@
}, },
"homepage": "https://github.com/thecodingmachine/workadventure#readme", "homepage": "https://github.com/thecodingmachine/workadventure#readme",
"dependencies": { "dependencies": {
"@workadventure/tiled-map-type-guard": "^1.0.0",
"axios": "^0.21.1", "axios": "^0.21.1",
"busboy": "^0.3.1", "busboy": "^0.3.1",
"circular-json": "^0.5.9", "circular-json": "^0.5.9",
@ -47,10 +48,12 @@
"generic-type-guard": "^3.2.0", "generic-type-guard": "^3.2.0",
"google-protobuf": "^3.13.0", "google-protobuf": "^3.13.0",
"grpc": "^1.24.4", "grpc": "^1.24.4",
"ipaddr.js": "^2.0.1",
"jsonwebtoken": "^8.5.1", "jsonwebtoken": "^8.5.1",
"mkdirp": "^1.0.4", "mkdirp": "^1.0.4",
"prom-client": "^12.0.0", "prom-client": "^12.0.0",
"query-string": "^6.13.3", "query-string": "^6.13.3",
"redis": "^3.1.2",
"systeminformation": "^4.31.1", "systeminformation": "^4.31.1",
"uWebSockets.js": "uNetworking/uWebSockets.js#v18.5.0", "uWebSockets.js": "uNetworking/uWebSockets.js#v18.5.0",
"uuidv4": "^6.0.7" "uuidv4": "^6.0.7"
@ -64,6 +67,7 @@
"@types/jasmine": "^3.5.10", "@types/jasmine": "^3.5.10",
"@types/jsonwebtoken": "^8.3.8", "@types/jsonwebtoken": "^8.3.8",
"@types/mkdirp": "^1.0.1", "@types/mkdirp": "^1.0.1",
"@types/redis": "^2.8.31",
"@types/uuidv4": "^5.0.0", "@types/uuidv4": "^5.0.0",
"@typescript-eslint/eslint-plugin": "^2.26.0", "@typescript-eslint/eslint-plugin": "^2.26.0",
"@typescript-eslint/parser": "^2.26.0", "@typescript-eslint/parser": "^2.26.0",

View file

@ -12,6 +12,9 @@ const GRPC_PORT = parseInt(process.env.GRPC_PORT || "50051") || 50051;
export const SOCKET_IDLE_TIMER = parseInt(process.env.SOCKET_IDLE_TIMER as string) || 30; // maximum time (in second) without activity before a socket is closed export const SOCKET_IDLE_TIMER = parseInt(process.env.SOCKET_IDLE_TIMER as string) || 30; // maximum time (in second) without activity before a socket is closed
export const TURN_STATIC_AUTH_SECRET = process.env.TURN_STATIC_AUTH_SECRET || ""; export const TURN_STATIC_AUTH_SECRET = process.env.TURN_STATIC_AUTH_SECRET || "";
export const MAX_PER_GROUP = parseInt(process.env.MAX_PER_GROUP || "4"); export const MAX_PER_GROUP = parseInt(process.env.MAX_PER_GROUP || "4");
export const REDIS_HOST = process.env.REDIS_HOST || undefined;
export const REDIS_PORT = parseInt(process.env.REDIS_PORT || "6379") || 6379;
export const REDIS_PASSWORD = process.env.REDIS_PASSWORD || undefined;
export { export {
MINIMUM_DISTANCE, MINIMUM_DISTANCE,

View file

@ -5,35 +5,63 @@ import { PositionInterface } from "_Model/PositionInterface";
import { EmoteCallback, EntersCallback, LeavesCallback, MovesCallback } from "_Model/Zone"; import { EmoteCallback, EntersCallback, LeavesCallback, MovesCallback } from "_Model/Zone";
import { PositionNotifier } from "./PositionNotifier"; import { PositionNotifier } from "./PositionNotifier";
import { Movable } from "_Model/Movable"; import { Movable } from "_Model/Movable";
import { EmoteEventMessage, JoinRoomMessage } from "../Messages/generated/messages_pb"; import {
BatchToPusherMessage,
BatchToPusherRoomMessage,
EmoteEventMessage,
ErrorMessage,
JoinRoomMessage,
SubToPusherRoomMessage,
VariableMessage,
VariableWithTagMessage,
} from "../Messages/generated/messages_pb";
import { ProtobufUtils } from "../Model/Websocket/ProtobufUtils"; import { ProtobufUtils } from "../Model/Websocket/ProtobufUtils";
import { ZoneSocket } from "src/RoomManager"; import { RoomSocket, ZoneSocket } from "src/RoomManager";
import { Admin } from "../Model/Admin"; import { Admin } from "../Model/Admin";
import { adminApi } from "../Services/AdminApi";
import { isMapDetailsData, MapDetailsData } from "../Services/AdminApi/MapDetailsData";
import { ITiledMap } from "@workadventure/tiled-map-type-guard/dist";
import { mapFetcher } from "../Services/MapFetcher";
import { VariablesManager } from "../Services/VariablesManager";
import { ADMIN_API_URL } from "../Enum/EnvironmentVariable";
import { LocalUrlError } from "../Services/LocalUrlError";
import { emitErrorOnRoomSocket } from "../Services/MessageHelpers";
export type ConnectCallback = (user: User, group: Group) => void; export type ConnectCallback = (user: User, group: Group) => void;
export type DisconnectCallback = (user: User, group: Group) => void; export type DisconnectCallback = (user: User, group: Group) => void;
export class GameRoom { export class GameRoom {
private readonly minDistance: number;
private readonly groupRadius: number;
// Users, sorted by ID // Users, sorted by ID
private readonly users: Map<number, User>; private readonly users = new Map<number, User>();
private readonly usersByUuid: Map<string, User>; private readonly usersByUuid = new Map<string, User>();
private readonly groups: Set<Group>; private readonly groups = new Set<Group>();
private readonly admins: Set<Admin>; private readonly admins = new Set<Admin>();
private readonly connectCallback: ConnectCallback; private itemsState = new Map<number, unknown>();
private readonly disconnectCallback: DisconnectCallback;
private itemsState: Map<number, unknown> = new Map<number, unknown>();
private readonly positionNotifier: PositionNotifier; private readonly positionNotifier: PositionNotifier;
public readonly roomUrl: string;
private versionNumber: number = 1; private versionNumber: number = 1;
private nextUserId: number = 1; private nextUserId: number = 1;
constructor( private roomListeners: Set<RoomSocket> = new Set<RoomSocket>();
private constructor(
public readonly roomUrl: string,
private mapUrl: string,
private readonly connectCallback: ConnectCallback,
private readonly disconnectCallback: DisconnectCallback,
private readonly minDistance: number,
private readonly groupRadius: number,
onEnters: EntersCallback,
onMoves: MovesCallback,
onLeaves: LeavesCallback,
onEmote: EmoteCallback
) {
// A zone is 10 sprites wide.
this.positionNotifier = new PositionNotifier(320, 320, onEnters, onMoves, onLeaves, onEmote);
}
public static async create(
roomUrl: string, roomUrl: string,
connectCallback: ConnectCallback, connectCallback: ConnectCallback,
disconnectCallback: DisconnectCallback, disconnectCallback: DisconnectCallback,
@ -43,19 +71,23 @@ export class GameRoom {
onMoves: MovesCallback, onMoves: MovesCallback,
onLeaves: LeavesCallback, onLeaves: LeavesCallback,
onEmote: EmoteCallback onEmote: EmoteCallback
) { ): Promise<GameRoom> {
this.roomUrl = roomUrl; const mapDetails = await GameRoom.getMapDetails(roomUrl);
this.users = new Map<number, User>(); const gameRoom = new GameRoom(
this.usersByUuid = new Map<string, User>(); roomUrl,
this.admins = new Set<Admin>(); mapDetails.mapUrl,
this.groups = new Set<Group>(); connectCallback,
this.connectCallback = connectCallback; disconnectCallback,
this.disconnectCallback = disconnectCallback; minDistance,
this.minDistance = minDistance; groupRadius,
this.groupRadius = groupRadius; onEnters,
// A zone is 10 sprites wide. onMoves,
this.positionNotifier = new PositionNotifier(320, 320, onEnters, onMoves, onLeaves, onEmote); onLeaves,
onEmote
);
return gameRoom;
} }
public getGroups(): Group[] { public getGroups(): Group[] {
@ -298,6 +330,37 @@ export class GameRoom {
return this.itemsState; return this.itemsState;
} }
public async setVariable(name: string, value: string, user: User): Promise<void> {
// First, let's check if "user" is allowed to modify the variable.
const variableManager = await this.getVariableManager();
const readableBy = variableManager.setVariable(name, value, user);
// If the variable was not changed, let's not dispatch anything.
if (readableBy === false) {
return;
}
// TODO: should we batch those every 100ms?
const variableMessage = new VariableWithTagMessage();
variableMessage.setName(name);
variableMessage.setValue(value);
if (readableBy) {
variableMessage.setReadableby(readableBy);
}
const subMessage = new SubToPusherRoomMessage();
subMessage.setVariablemessage(variableMessage);
const batchMessage = new BatchToPusherRoomMessage();
batchMessage.addPayload(subMessage);
// Dispatch the message on the room listeners
for (const socket of this.roomListeners) {
socket.write(batchMessage);
}
}
public addZoneListener(call: ZoneSocket, x: number, y: number): Set<Movable> { public addZoneListener(call: ZoneSocket, x: number, y: number): Set<Movable> {
return this.positionNotifier.addZoneListener(call, x, y); return this.positionNotifier.addZoneListener(call, x, y);
} }
@ -327,4 +390,98 @@ export class GameRoom {
public emitEmoteEvent(user: User, emoteEventMessage: EmoteEventMessage) { public emitEmoteEvent(user: User, emoteEventMessage: EmoteEventMessage) {
this.positionNotifier.emitEmoteEvent(user, emoteEventMessage); this.positionNotifier.emitEmoteEvent(user, emoteEventMessage);
} }
public addRoomListener(socket: RoomSocket) {
this.roomListeners.add(socket);
}
public removeRoomListener(socket: RoomSocket) {
this.roomListeners.delete(socket);
}
/**
* Connects to the admin server to fetch map details.
* If there is no admin server, the map details are generated by analysing the map URL (that must be in the form: /_/instance/map_url)
*/
private static async getMapDetails(roomUrl: string): Promise<MapDetailsData> {
if (!ADMIN_API_URL) {
const roomUrlObj = new URL(roomUrl);
const match = /\/_\/[^/]+\/(.+)/.exec(roomUrlObj.pathname);
if (!match) {
console.error("Unexpected room URL", roomUrl);
throw new Error('Unexpected room URL "' + roomUrl + '"');
}
const mapUrl = roomUrlObj.protocol + "//" + match[1];
return {
mapUrl,
policy_type: 1,
textures: [],
tags: [],
};
}
const result = await adminApi.fetchMapDetails(roomUrl);
if (!isMapDetailsData(result)) {
console.error("Unexpected room details received from server", result);
throw new Error("Unexpected room details received from server");
}
return result;
}
private mapPromise: Promise<ITiledMap> | undefined;
/**
* Returns a promise to the map file.
* @throws LocalUrlError if the map we are trying to load is hosted on a local network
* @throws Error
*/
private getMap(): Promise<ITiledMap> {
if (!this.mapPromise) {
this.mapPromise = mapFetcher.fetchMap(this.mapUrl);
}
return this.mapPromise;
}
private variableManagerPromise: Promise<VariablesManager> | undefined;
private getVariableManager(): Promise<VariablesManager> {
if (!this.variableManagerPromise) {
this.variableManagerPromise = this.getMap()
.then((map) => {
const variablesManager = new VariablesManager(this.roomUrl, map);
return variablesManager.init();
})
.catch((e) => {
if (e instanceof LocalUrlError) {
// If we are trying to load a local URL, we are probably in test mode.
// In this case, let's bypass the server-side checks completely.
// Note: we run this message inside a setTimeout so that the room listeners can have time to connect.
setTimeout(() => {
for (const roomListener of this.roomListeners) {
emitErrorOnRoomSocket(
roomListener,
"You are loading a local map. If you use the scripting API in this map, please be aware that server-side checks and variable persistence is disabled."
);
}
}, 1000);
const variablesManager = new VariablesManager(this.roomUrl, null);
return variablesManager.init();
} else {
throw e;
}
});
}
return this.variableManagerPromise;
}
public async getVariablesForTags(tags: string[]): Promise<Map<string, string>> {
const variablesManager = await this.getVariableManager();
return variablesManager.getVariablesForTags(tags);
}
} }

View file

@ -21,7 +21,7 @@ interface ZoneDescriptor {
} }
export class PositionNotifier { export class PositionNotifier {
// TODO: we need a way to clean the zones if noone is in the zone and noone listening (to free memory!) // TODO: we need a way to clean the zones if no one is in the zone and no one listening (to free memory!)
private zones: Zone[][] = []; private zones: Zone[][] = [];

View file

@ -5,6 +5,8 @@ import {
AdminPusherToBackMessage, AdminPusherToBackMessage,
AdminRoomMessage, AdminRoomMessage,
BanMessage, BanMessage,
BatchToPusherMessage,
BatchToPusherRoomMessage,
EmotePromptMessage, EmotePromptMessage,
EmptyMessage, EmptyMessage,
ItemEventMessage, ItemEventMessage,
@ -13,17 +15,18 @@ import {
PusherToBackMessage, PusherToBackMessage,
QueryJitsiJwtMessage, QueryJitsiJwtMessage,
RefreshRoomPromptMessage, RefreshRoomPromptMessage,
RoomMessage,
ServerToAdminClientMessage, ServerToAdminClientMessage,
ServerToClientMessage,
SilentMessage, SilentMessage,
UserMovesMessage, UserMovesMessage,
VariableMessage,
WebRtcSignalToServerMessage, WebRtcSignalToServerMessage,
WorldFullWarningToRoomMessage, WorldFullWarningToRoomMessage,
ZoneMessage, ZoneMessage,
} from "./Messages/generated/messages_pb"; } from "./Messages/generated/messages_pb";
import { sendUnaryData, ServerDuplexStream, ServerUnaryCall, ServerWritableStream } from "grpc"; import { sendUnaryData, ServerDuplexStream, ServerUnaryCall, ServerWritableStream } from "grpc";
import { socketManager } from "./Services/SocketManager"; import { socketManager } from "./Services/SocketManager";
import { emitError } from "./Services/MessageHelpers"; import { emitError, emitErrorOnRoomSocket, emitErrorOnZoneSocket } from "./Services/MessageHelpers";
import { User, UserSocket } from "./Model/User"; import { User, UserSocket } from "./Model/User";
import { GameRoom } from "./Model/GameRoom"; import { GameRoom } from "./Model/GameRoom";
import Debug from "debug"; import Debug from "debug";
@ -32,7 +35,8 @@ import { Admin } from "./Model/Admin";
const debug = Debug("roommanager"); const debug = Debug("roommanager");
export type AdminSocket = ServerDuplexStream<AdminPusherToBackMessage, ServerToAdminClientMessage>; export type AdminSocket = ServerDuplexStream<AdminPusherToBackMessage, ServerToAdminClientMessage>;
export type ZoneSocket = ServerWritableStream<ZoneMessage, ServerToClientMessage>; export type ZoneSocket = ServerWritableStream<ZoneMessage, BatchToPusherMessage>;
export type RoomSocket = ServerWritableStream<RoomMessage, BatchToPusherRoomMessage>;
const roomManager: IRoomManagerServer = { const roomManager: IRoomManagerServer = {
joinRoom: (call: UserSocket): void => { joinRoom: (call: UserSocket): void => {
@ -42,79 +46,96 @@ const roomManager: IRoomManagerServer = {
let user: User | null = null; let user: User | null = null;
call.on("data", (message: PusherToBackMessage) => { call.on("data", (message: PusherToBackMessage) => {
try { (async () => {
if (room === null || user === null) { try {
if (message.hasJoinroommessage()) { if (room === null || user === null) {
socketManager if (message.hasJoinroommessage()) {
.handleJoinRoom(call, message.getJoinroommessage() as JoinRoomMessage) socketManager
.then(({ room: gameRoom, user: myUser }) => { .handleJoinRoom(call, message.getJoinroommessage() as JoinRoomMessage)
if (call.writable) { .then(({ room: gameRoom, user: myUser }) => {
room = gameRoom; if (call.writable) {
user = myUser; room = gameRoom;
} else { user = myUser;
//Connexion may have been closed before the init was finished, so we have to manually disconnect the user. } else {
socketManager.leaveRoom(gameRoom, myUser); //Connection may have been closed before the init was finished, so we have to manually disconnect the user.
} socketManager.leaveRoom(gameRoom, myUser);
}); }
} else { })
throw new Error("The first message sent MUST be of type JoinRoomMessage"); .catch((e) => emitError(call, e));
} } else {
} else { throw new Error("The first message sent MUST be of type JoinRoomMessage");
if (message.hasJoinroommessage()) {
throw new Error("Cannot call JoinRoomMessage twice!");
} else if (message.hasUsermovesmessage()) {
socketManager.handleUserMovesMessage(
room,
user,
message.getUsermovesmessage() as UserMovesMessage
);
} else if (message.hasSilentmessage()) {
socketManager.handleSilentMessage(room, user, message.getSilentmessage() as SilentMessage);
} else if (message.hasItemeventmessage()) {
socketManager.handleItemEvent(room, user, message.getItemeventmessage() as ItemEventMessage);
} else if (message.hasWebrtcsignaltoservermessage()) {
socketManager.emitVideo(
room,
user,
message.getWebrtcsignaltoservermessage() as WebRtcSignalToServerMessage
);
} else if (message.hasWebrtcscreensharingsignaltoservermessage()) {
socketManager.emitScreenSharing(
room,
user,
message.getWebrtcscreensharingsignaltoservermessage() as WebRtcSignalToServerMessage
);
} else if (message.hasPlayglobalmessage()) {
socketManager.emitPlayGlobalMessage(room, message.getPlayglobalmessage() as PlayGlobalMessage);
} else if (message.hasQueryjitsijwtmessage()) {
socketManager.handleQueryJitsiJwtMessage(
user,
message.getQueryjitsijwtmessage() as QueryJitsiJwtMessage
);
} else if (message.hasEmotepromptmessage()) {
socketManager.handleEmoteEventMessage(
room,
user,
message.getEmotepromptmessage() as EmotePromptMessage
);
} else if (message.hasSendusermessage()) {
const sendUserMessage = message.getSendusermessage();
if (sendUserMessage !== undefined) {
socketManager.handlerSendUserMessage(user, sendUserMessage);
}
} else if (message.hasBanusermessage()) {
const banUserMessage = message.getBanusermessage();
if (banUserMessage !== undefined) {
socketManager.handlerBanUserMessage(room, user, banUserMessage);
} }
} else { } else {
throw new Error("Unhandled message type"); if (message.hasJoinroommessage()) {
throw new Error("Cannot call JoinRoomMessage twice!");
} else if (message.hasUsermovesmessage()) {
socketManager.handleUserMovesMessage(
room,
user,
message.getUsermovesmessage() as UserMovesMessage
);
} else if (message.hasSilentmessage()) {
socketManager.handleSilentMessage(room, user, message.getSilentmessage() as SilentMessage);
} else if (message.hasItemeventmessage()) {
socketManager.handleItemEvent(
room,
user,
message.getItemeventmessage() as ItemEventMessage
);
} else if (message.hasVariablemessage()) {
await socketManager.handleVariableEvent(
room,
user,
message.getVariablemessage() as VariableMessage
);
} else if (message.hasWebrtcsignaltoservermessage()) {
socketManager.emitVideo(
room,
user,
message.getWebrtcsignaltoservermessage() as WebRtcSignalToServerMessage
);
} else if (message.hasWebrtcscreensharingsignaltoservermessage()) {
socketManager.emitScreenSharing(
room,
user,
message.getWebrtcscreensharingsignaltoservermessage() as WebRtcSignalToServerMessage
);
} else if (message.hasPlayglobalmessage()) {
socketManager.emitPlayGlobalMessage(
room,
message.getPlayglobalmessage() as PlayGlobalMessage
);
} else if (message.hasQueryjitsijwtmessage()) {
socketManager.handleQueryJitsiJwtMessage(
user,
message.getQueryjitsijwtmessage() as QueryJitsiJwtMessage
);
} else if (message.hasEmotepromptmessage()) {
socketManager.handleEmoteEventMessage(
room,
user,
message.getEmotepromptmessage() as EmotePromptMessage
);
} else if (message.hasSendusermessage()) {
const sendUserMessage = message.getSendusermessage();
if (sendUserMessage !== undefined) {
socketManager.handlerSendUserMessage(user, sendUserMessage);
}
} else if (message.hasBanusermessage()) {
const banUserMessage = message.getBanusermessage();
if (banUserMessage !== undefined) {
socketManager.handlerBanUserMessage(room, user, banUserMessage);
}
} else {
throw new Error("Unhandled message type");
}
} }
} catch (e) {
console.error(e);
emitError(call, e);
call.end();
} }
} catch (e) { })().catch((e) => console.error(e));
emitError(call, e);
call.end();
}
}); });
call.on("end", () => { call.on("end", () => {
@ -136,20 +157,54 @@ const roomManager: IRoomManagerServer = {
debug("listenZone called"); debug("listenZone called");
const zoneMessage = call.request; const zoneMessage = call.request;
socketManager.addZoneListener(call, zoneMessage.getRoomid(), zoneMessage.getX(), zoneMessage.getY()); socketManager
.addZoneListener(call, zoneMessage.getRoomid(), zoneMessage.getX(), zoneMessage.getY())
.catch((e) => {
emitErrorOnZoneSocket(call, e.toString());
});
call.on("cancelled", () => { call.on("cancelled", () => {
debug("listenZone cancelled"); debug("listenZone cancelled");
socketManager.removeZoneListener(call, zoneMessage.getRoomid(), zoneMessage.getX(), zoneMessage.getY()); socketManager
.removeZoneListener(call, zoneMessage.getRoomid(), zoneMessage.getX(), zoneMessage.getY())
.catch((e) => console.error(e));
call.end(); call.end();
}); });
call.on("close", () => { call.on("close", () => {
debug("listenZone connection closed"); debug("listenZone connection closed");
socketManager.removeZoneListener(call, zoneMessage.getRoomid(), zoneMessage.getX(), zoneMessage.getY()); socketManager
.removeZoneListener(call, zoneMessage.getRoomid(), zoneMessage.getX(), zoneMessage.getY())
.catch((e) => console.error(e));
}).on("error", (e) => { }).on("error", (e) => {
console.error("An error occurred in listenZone stream:", e); console.error("An error occurred in listenZone stream:", e);
socketManager.removeZoneListener(call, zoneMessage.getRoomid(), zoneMessage.getX(), zoneMessage.getY()); socketManager
.removeZoneListener(call, zoneMessage.getRoomid(), zoneMessage.getX(), zoneMessage.getY())
.catch((e) => console.error(e));
call.end();
});
},
listenRoom(call: RoomSocket): void {
debug("listenRoom called");
const roomMessage = call.request;
socketManager.addRoomListener(call, roomMessage.getRoomid()).catch((e) => {
emitErrorOnRoomSocket(call, e.toString());
});
call.on("cancelled", () => {
debug("listenRoom cancelled");
socketManager.removeRoomListener(call, roomMessage.getRoomid()).catch((e) => console.error(e));
call.end();
});
call.on("close", () => {
debug("listenRoom connection closed");
socketManager.removeRoomListener(call, roomMessage.getRoomid()).catch((e) => console.error(e));
}).on("error", (e) => {
console.error("An error occurred in listenRoom stream:", e);
socketManager.removeRoomListener(call, roomMessage.getRoomid()).catch((e) => console.error(e));
call.end(); call.end();
}); });
}, },
@ -165,9 +220,12 @@ const roomManager: IRoomManagerServer = {
if (room === null) { if (room === null) {
if (message.hasSubscribetoroom()) { if (message.hasSubscribetoroom()) {
const roomId = message.getSubscribetoroom(); const roomId = message.getSubscribetoroom();
socketManager.handleJoinAdminRoom(admin, roomId).then((gameRoom: GameRoom) => { socketManager
room = gameRoom; .handleJoinAdminRoom(admin, roomId)
}); .then((gameRoom: GameRoom) => {
room = gameRoom;
})
.catch((e) => console.error(e));
} else { } else {
throw new Error("The first message sent MUST be of type JoinRoomMessage"); throw new Error("The first message sent MUST be of type JoinRoomMessage");
} }
@ -192,11 +250,9 @@ const roomManager: IRoomManagerServer = {
}); });
}, },
sendAdminMessage(call: ServerUnaryCall<AdminMessage>, callback: sendUnaryData<EmptyMessage>): void { sendAdminMessage(call: ServerUnaryCall<AdminMessage>, callback: sendUnaryData<EmptyMessage>): void {
socketManager.sendAdminMessage( socketManager
call.request.getRoomid(), .sendAdminMessage(call.request.getRoomid(), call.request.getRecipientuuid(), call.request.getMessage())
call.request.getRecipientuuid(), .catch((e) => console.error(e));
call.request.getMessage()
);
callback(null, new EmptyMessage()); callback(null, new EmptyMessage());
}, },
@ -207,26 +263,33 @@ const roomManager: IRoomManagerServer = {
}, },
ban(call: ServerUnaryCall<BanMessage>, callback: sendUnaryData<EmptyMessage>): void { ban(call: ServerUnaryCall<BanMessage>, callback: sendUnaryData<EmptyMessage>): void {
// FIXME Work in progress // FIXME Work in progress
socketManager.banUser(call.request.getRoomid(), call.request.getRecipientuuid(), call.request.getMessage()); socketManager
.banUser(call.request.getRoomid(), call.request.getRecipientuuid(), call.request.getMessage())
.catch((e) => console.error(e));
callback(null, new EmptyMessage()); callback(null, new EmptyMessage());
}, },
sendAdminMessageToRoom(call: ServerUnaryCall<AdminRoomMessage>, callback: sendUnaryData<EmptyMessage>): void { sendAdminMessageToRoom(call: ServerUnaryCall<AdminRoomMessage>, callback: sendUnaryData<EmptyMessage>): void {
socketManager.sendAdminRoomMessage(call.request.getRoomid(), call.request.getMessage()); // FIXME: we could improve return message by returning a Success|ErrorMessage message
socketManager
.sendAdminRoomMessage(call.request.getRoomid(), call.request.getMessage())
.catch((e) => console.error(e));
callback(null, new EmptyMessage()); callback(null, new EmptyMessage());
}, },
sendWorldFullWarningToRoom( sendWorldFullWarningToRoom(
call: ServerUnaryCall<WorldFullWarningToRoomMessage>, call: ServerUnaryCall<WorldFullWarningToRoomMessage>,
callback: sendUnaryData<EmptyMessage> callback: sendUnaryData<EmptyMessage>
): void { ): void {
socketManager.dispatchWorlFullWarning(call.request.getRoomid()); // FIXME: we could improve return message by returning a Success|ErrorMessage message
socketManager.dispatchWorldFullWarning(call.request.getRoomid()).catch((e) => console.error(e));
callback(null, new EmptyMessage()); callback(null, new EmptyMessage());
}, },
sendRefreshRoomPrompt( sendRefreshRoomPrompt(
call: ServerUnaryCall<RefreshRoomPromptMessage>, call: ServerUnaryCall<RefreshRoomPromptMessage>,
callback: sendUnaryData<EmptyMessage> callback: sendUnaryData<EmptyMessage>
): void { ): void {
socketManager.dispatchRoomRefresh(call.request.getRoomid()); // FIXME: we could improve return message by returning a Success|ErrorMessage message
socketManager.dispatchRoomRefresh(call.request.getRoomid()).catch((e) => console.error(e));
callback(null, new EmptyMessage()); callback(null, new EmptyMessage());
}, },
}; };

View file

@ -0,0 +1,24 @@
import { ADMIN_API_TOKEN, ADMIN_API_URL } from "../Enum/EnvironmentVariable";
import Axios from "axios";
import { MapDetailsData } from "./AdminApi/MapDetailsData";
import { RoomRedirect } from "./AdminApi/RoomRedirect";
class AdminApi {
async fetchMapDetails(playUri: string): Promise<MapDetailsData | RoomRedirect> {
if (!ADMIN_API_URL) {
return Promise.reject(new Error("No admin backoffice set!"));
}
const params: { playUri: string } = {
playUri,
};
const res = await Axios.get(ADMIN_API_URL + "/api/map", {
headers: { Authorization: `${ADMIN_API_TOKEN}` },
params,
});
return res.data;
}
}
export const adminApi = new AdminApi();

View file

@ -0,0 +1,11 @@
import * as tg from "generic-type-guard";
export const isCharacterTexture = new tg.IsInterface()
.withProperties({
id: tg.isNumber,
level: tg.isNumber,
url: tg.isString,
rights: tg.isString,
})
.get();
export type CharacterTexture = tg.GuardedType<typeof isCharacterTexture>;

View file

@ -0,0 +1,21 @@
import * as tg from "generic-type-guard";
import { isCharacterTexture } from "./CharacterTexture";
import { isAny, isNumber } from "generic-type-guard";
/*const isNumericEnum =
<T extends { [n: number]: string }>(vs: T) =>
(v: any): v is T =>
typeof v === "number" && v in vs;*/
export const isMapDetailsData = new tg.IsInterface()
.withProperties({
mapUrl: tg.isString,
policy_type: isNumber, //isNumericEnum(GameRoomPolicyTypes),
tags: tg.isArray(tg.isString),
textures: tg.isArray(isCharacterTexture),
})
.withOptionalProperties({
roomSlug: tg.isUnion(tg.isString, tg.isNull), // deprecated
})
.get();
export type MapDetailsData = tg.GuardedType<typeof isMapDetailsData>;

View file

@ -0,0 +1,8 @@
import * as tg from "generic-type-guard";
export const isRoomRedirect = new tg.IsInterface()
.withProperties({
redirectUrl: tg.isString,
})
.get();
export type RoomRedirect = tg.GuardedType<typeof isRoomRedirect>;

View file

@ -0,0 +1 @@
export class LocalUrlError extends Error {}

View file

@ -0,0 +1,67 @@
import Axios from "axios";
import ipaddr from "ipaddr.js";
import { Resolver } from "dns";
import { promisify } from "util";
import { LocalUrlError } from "./LocalUrlError";
import { ITiledMap } from "@workadventure/tiled-map-type-guard";
import { isTiledMap } from "@workadventure/tiled-map-type-guard/dist";
class MapFetcher {
async fetchMap(mapUrl: string): Promise<ITiledMap> {
// Before trying to make the query, let's verify the map is actually on the open internet (and not a local test map)
if (await this.isLocalUrl(mapUrl)) {
throw new LocalUrlError('URL for map "' + mapUrl + '" targets a local map');
}
// Note: mapUrl is provided by the client. A possible attack vector would be to use a rogue DNS server that
// returns local URLs. Alas, Axios cannot pin a URL to a given IP. So "isLocalUrl" and Axios.get could potentially
// target to different servers (and one could trick Axios.get into loading resources on the internal network
// despite isLocalUrl checking that.
// We can deem this problem not that important because:
// - We make sure we are only passing "GET" requests
// - The result of the query is never displayed to the end user
const res = await Axios.get(mapUrl, {
maxContentLength: 50 * 1024 * 1024, // Max content length: 50MB. Maps should not be bigger
timeout: 10000, // Timeout after 10 seconds
});
if (!isTiledMap(res.data)) {
throw new Error("Invalid map format for map " + mapUrl);
}
return res.data;
}
/**
* Returns true if the domain name is localhost of *.localhost
* Returns true if the domain name resolves to an IP address that is "private" (like 10.x.x.x or 192.168.x.x)
*
* @private
*/
async isLocalUrl(url: string): Promise<boolean> {
const urlObj = new URL(url);
if (urlObj.hostname === "localhost" || urlObj.hostname.endsWith(".localhost")) {
return true;
}
let addresses = [];
if (!ipaddr.isValid(urlObj.hostname)) {
const resolver = new Resolver();
addresses = await promisify(resolver.resolve).bind(resolver)(urlObj.hostname);
} else {
addresses = [urlObj.hostname];
}
for (const address of addresses) {
const addr = ipaddr.parse(address);
if (addr.range() !== "unicast") {
return true;
}
}
return false;
}
}
export const mapFetcher = new MapFetcher();

View file

@ -1,5 +1,14 @@
import { ErrorMessage, ServerToClientMessage } from "../Messages/generated/messages_pb"; import {
BatchMessage,
BatchToPusherMessage,
BatchToPusherRoomMessage,
ErrorMessage,
ServerToClientMessage,
SubToPusherMessage,
SubToPusherRoomMessage,
} from "../Messages/generated/messages_pb";
import { UserSocket } from "_Model/User"; import { UserSocket } from "_Model/User";
import { RoomSocket, ZoneSocket } from "../RoomManager";
export function emitError(Client: UserSocket, message: string): void { export function emitError(Client: UserSocket, message: string): void {
const errorMessage = new ErrorMessage(); const errorMessage = new ErrorMessage();
@ -13,3 +22,39 @@ export function emitError(Client: UserSocket, message: string): void {
//} //}
console.warn(message); console.warn(message);
} }
export function emitErrorOnRoomSocket(Client: RoomSocket, message: string): void {
console.error(message);
const errorMessage = new ErrorMessage();
errorMessage.setMessage(message);
const subToPusherRoomMessage = new SubToPusherRoomMessage();
subToPusherRoomMessage.setErrormessage(errorMessage);
const batchToPusherMessage = new BatchToPusherRoomMessage();
batchToPusherMessage.addPayload(subToPusherRoomMessage);
//if (!Client.disconnecting) {
Client.write(batchToPusherMessage);
//}
console.warn(message);
}
export function emitErrorOnZoneSocket(Client: ZoneSocket, message: string): void {
console.error(message);
const errorMessage = new ErrorMessage();
errorMessage.setMessage(message);
const subToPusherMessage = new SubToPusherMessage();
subToPusherMessage.setErrormessage(errorMessage);
const batchToPusherMessage = new BatchToPusherMessage();
batchToPusherMessage.addPayload(subToPusherMessage);
//if (!Client.disconnecting) {
Client.write(batchToPusherMessage);
//}
console.warn(message);
}

View file

@ -0,0 +1,23 @@
import { ClientOpts, createClient, RedisClient } from "redis";
import { REDIS_HOST, REDIS_PASSWORD, REDIS_PORT } from "../Enum/EnvironmentVariable";
let redisClient: RedisClient | null = null;
if (REDIS_HOST !== undefined) {
const config: ClientOpts = {
host: REDIS_HOST,
port: REDIS_PORT,
};
if (REDIS_PASSWORD) {
config.password = REDIS_PASSWORD;
}
redisClient = createClient(config);
redisClient.on("error", (err) => {
console.error("Error connecting to Redis:", err);
});
}
export { redisClient };

View file

@ -0,0 +1,43 @@
import { promisify } from "util";
import { RedisClient } from "redis";
import { VariablesRepositoryInterface } from "./VariablesRepositoryInterface";
/**
* Class in charge of saving/loading variables from the data store
*/
export class RedisVariablesRepository implements VariablesRepositoryInterface {
private readonly hgetall: OmitThisParameter<(arg1: string) => Promise<{ [p: string]: string }>>;
private readonly hset: OmitThisParameter<(arg1: [string, ...string[]]) => Promise<number>>;
private readonly hdel: OmitThisParameter<(arg1: string, arg2: string) => Promise<number>>;
constructor(private redisClient: RedisClient) {
/* eslint-disable @typescript-eslint/unbound-method */
this.hgetall = promisify(redisClient.hgetall).bind(redisClient);
this.hset = promisify(redisClient.hset).bind(redisClient);
this.hdel = promisify(redisClient.hdel).bind(redisClient);
/* eslint-enable @typescript-eslint/unbound-method */
}
/**
* Load all variables for a room.
*
* Note: in Redis, variables are stored in a hashmap and the key is the roomUrl
*/
async loadVariables(roomUrl: string): Promise<{ [key: string]: string }> {
return this.hgetall(roomUrl);
}
async saveVariable(roomUrl: string, key: string, value: string): Promise<number> {
// The value is passed to JSON.stringify client side. If value is "undefined", JSON.stringify returns "undefined"
// which is translated to empty string when fetching the value in the pusher.
// Therefore, empty string server side == undefined client side.
if (value === "") {
return this.hdel(roomUrl, key);
}
// TODO: SLOW WRITING EVERY 2 SECONDS WITH A TIMEOUT
// @ts-ignore See https://stackoverflow.com/questions/63539317/how-do-i-use-hmset-with-node-promisify
return this.hset(roomUrl, key, value);
}
}

View file

@ -0,0 +1,14 @@
import { RedisVariablesRepository } from "./RedisVariablesRepository";
import { redisClient } from "../RedisClient";
import { VoidVariablesRepository } from "./VoidVariablesRepository";
import { VariablesRepositoryInterface } from "./VariablesRepositoryInterface";
let variablesRepository: VariablesRepositoryInterface;
if (!redisClient) {
console.warn("WARNING: Redis isnot configured. No variables will be persisted.");
variablesRepository = new VoidVariablesRepository();
} else {
variablesRepository = new RedisVariablesRepository(redisClient);
}
export { variablesRepository };

View file

@ -0,0 +1,10 @@
export interface VariablesRepositoryInterface {
/**
* Load all variables for a room.
*
* Note: in Redis, variables are stored in a hashmap and the key is the roomUrl
*/
loadVariables(roomUrl: string): Promise<{ [key: string]: string }>;
saveVariable(roomUrl: string, key: string, value: string): Promise<number>;
}

View file

@ -0,0 +1,14 @@
import { VariablesRepositoryInterface } from "./VariablesRepositoryInterface";
/**
* Mock class in charge of NOT saving/loading variables from the data store
*/
export class VoidVariablesRepository implements VariablesRepositoryInterface {
loadVariables(roomUrl: string): Promise<{ [key: string]: string }> {
return Promise.resolve({});
}
saveVariable(roomUrl: string, key: string, value: string): Promise<number> {
return Promise.resolve(0);
}
}

View file

@ -30,6 +30,9 @@ import {
BanUserMessage, BanUserMessage,
RefreshRoomMessage, RefreshRoomMessage,
EmotePromptMessage, EmotePromptMessage,
VariableMessage,
BatchToPusherRoomMessage,
SubToPusherRoomMessage,
} from "../Messages/generated/messages_pb"; } from "../Messages/generated/messages_pb";
import { User, UserSocket } from "../Model/User"; import { User, UserSocket } from "../Model/User";
import { ProtobufUtils } from "../Model/Websocket/ProtobufUtils"; import { ProtobufUtils } from "../Model/Websocket/ProtobufUtils";
@ -48,7 +51,7 @@ import Jwt from "jsonwebtoken";
import { JITSI_URL } from "../Enum/EnvironmentVariable"; import { JITSI_URL } from "../Enum/EnvironmentVariable";
import { clientEventsEmitter } from "./ClientEventsEmitter"; import { clientEventsEmitter } from "./ClientEventsEmitter";
import { gaugeManager } from "./GaugeManager"; import { gaugeManager } from "./GaugeManager";
import { ZoneSocket } from "../RoomManager"; import { RoomSocket, ZoneSocket } from "../RoomManager";
import { Zone } from "_Model/Zone"; import { Zone } from "_Model/Zone";
import Debug from "debug"; import Debug from "debug";
import { Admin } from "_Model/Admin"; import { Admin } from "_Model/Admin";
@ -65,7 +68,9 @@ function emitZoneMessage(subMessage: SubToPusherMessage, socket: ZoneSocket): vo
} }
export class SocketManager { export class SocketManager {
private rooms: Map<string, GameRoom> = new Map<string, GameRoom>(); //private rooms = new Map<string, GameRoom>();
// List of rooms in process of loading.
private roomsPromises = new Map<string, PromiseLike<GameRoom>>();
constructor() { constructor() {
clientEventsEmitter.registerToClientJoin((clientUUid: string, roomId: string) => { clientEventsEmitter.registerToClientJoin((clientUUid: string, roomId: string) => {
@ -101,6 +106,16 @@ export class SocketManager {
roomJoinedMessage.addItem(itemStateMessage); roomJoinedMessage.addItem(itemStateMessage);
} }
const variables = await room.getVariablesForTags(user.tags);
for (const [name, value] of variables.entries()) {
const variableMessage = new VariableMessage();
variableMessage.setName(name);
variableMessage.setValue(value);
roomJoinedMessage.addVariable(variableMessage);
}
roomJoinedMessage.setCurrentuserid(user.id); roomJoinedMessage.setCurrentuserid(user.id);
const serverToClientMessage = new ServerToClientMessage(); const serverToClientMessage = new ServerToClientMessage();
@ -114,30 +129,25 @@ export class SocketManager {
} }
handleUserMovesMessage(room: GameRoom, user: User, userMovesMessage: UserMovesMessage) { handleUserMovesMessage(room: GameRoom, user: User, userMovesMessage: UserMovesMessage) {
try { const userMoves = userMovesMessage.toObject();
const userMoves = userMovesMessage.toObject(); const position = userMovesMessage.getPosition();
const position = userMovesMessage.getPosition();
// If CPU is high, let's drop messages of users moving (we will only dispatch the final position) // If CPU is high, let's drop messages of users moving (we will only dispatch the final position)
if (cpuTracker.isOverHeating() && userMoves.position?.moving === true) { if (cpuTracker.isOverHeating() && userMoves.position?.moving === true) {
return; return;
}
if (position === undefined) {
throw new Error("Position not found in message");
}
const viewport = userMoves.viewport;
if (viewport === undefined) {
throw new Error("Viewport not found in message");
}
// update position in the world
room.updatePosition(user, ProtobufUtils.toPointInterface(position));
//room.setViewport(client, client.viewport);
} catch (e) {
console.error('An error occurred on "user_position" event');
console.error(e);
} }
if (position === undefined) {
throw new Error("Position not found in message");
}
const viewport = userMoves.viewport;
if (viewport === undefined) {
throw new Error("Viewport not found in message");
}
// update position in the world
room.updatePosition(user, ProtobufUtils.toPointInterface(position));
//room.setViewport(client, client.viewport);
} }
// Useless now, will be useful again if we allow editing details in game // Useless now, will be useful again if we allow editing details in game
@ -156,32 +166,26 @@ export class SocketManager {
}*/ }*/
handleSilentMessage(room: GameRoom, user: User, silentMessage: SilentMessage) { handleSilentMessage(room: GameRoom, user: User, silentMessage: SilentMessage) {
try { room.setSilent(user, silentMessage.getSilent());
room.setSilent(user, silentMessage.getSilent());
} catch (e) {
console.error('An error occurred on "handleSilentMessage"');
console.error(e);
}
} }
handleItemEvent(room: GameRoom, user: User, itemEventMessage: ItemEventMessage) { handleItemEvent(room: GameRoom, user: User, itemEventMessage: ItemEventMessage) {
const itemEvent = ProtobufUtils.toItemEvent(itemEventMessage); const itemEvent = ProtobufUtils.toItemEvent(itemEventMessage);
try { const subMessage = new SubMessage();
const subMessage = new SubMessage(); subMessage.setItemeventmessage(itemEventMessage);
subMessage.setItemeventmessage(itemEventMessage);
// Let's send the event without using the SocketIO room. // Let's send the event without using the SocketIO room.
// TODO: move this in the GameRoom class. // TODO: move this in the GameRoom class.
for (const user of room.getUsers().values()) { for (const user of room.getUsers().values()) {
user.emitInBatch(subMessage); user.emitInBatch(subMessage);
}
room.setItemState(itemEvent.itemId, itemEvent.state);
} catch (e) {
console.error('An error occurred on "item_event"');
console.error(e);
} }
room.setItemState(itemEvent.itemId, itemEvent.state);
}
handleVariableEvent(room: GameRoom, user: User, variableMessage: VariableMessage): Promise<void> {
return room.setVariable(variableMessage.getName(), variableMessage.getValue(), user);
} }
emitVideo(room: GameRoom, user: User, data: WebRtcSignalToServerMessage): void { emitVideo(room: GameRoom, user: User, data: WebRtcSignalToServerMessage): void {
@ -250,7 +254,7 @@ export class SocketManager {
//user leave previous world //user leave previous world
room.leave(user); room.leave(user);
if (room.isEmpty()) { if (room.isEmpty()) {
this.rooms.delete(room.roomUrl); this.roomsPromises.delete(room.roomUrl);
gaugeManager.decNbRoomGauge(); gaugeManager.decNbRoomGauge();
debug('Room is empty. Deleting room "%s"', room.roomUrl); debug('Room is empty. Deleting room "%s"', room.roomUrl);
} }
@ -261,10 +265,10 @@ export class SocketManager {
} }
async getOrCreateRoom(roomId: string): Promise<GameRoom> { async getOrCreateRoom(roomId: string): Promise<GameRoom> {
//check and create new world for a room //check and create new room
let world = this.rooms.get(roomId); let roomPromise = this.roomsPromises.get(roomId);
if (world === undefined) { if (roomPromise === undefined) {
world = new GameRoom( roomPromise = GameRoom.create(
roomId, roomId,
(user: User, group: Group) => this.joinWebRtcRoom(user, group), (user: User, group: Group) => this.joinWebRtcRoom(user, group),
(user: User, group: Group) => this.disConnectedUser(user, group), (user: User, group: Group) => this.disConnectedUser(user, group),
@ -278,11 +282,18 @@ export class SocketManager {
this.onClientLeave(thing, newZone, listener), this.onClientLeave(thing, newZone, listener),
(emoteEventMessage: EmoteEventMessage, listener: ZoneSocket) => (emoteEventMessage: EmoteEventMessage, listener: ZoneSocket) =>
this.onEmote(emoteEventMessage, listener) this.onEmote(emoteEventMessage, listener)
); )
gaugeManager.incNbRoomGauge(); .then((gameRoom) => {
this.rooms.set(roomId, world); gaugeManager.incNbRoomGauge();
return gameRoom;
})
.catch((e) => {
this.roomsPromises.delete(roomId);
throw e;
});
this.roomsPromises.set(roomId, roomPromise);
} }
return Promise.resolve(world); return roomPromise;
} }
private async joinRoom( private async joinRoom(
@ -508,21 +519,16 @@ export class SocketManager {
} }
emitPlayGlobalMessage(room: GameRoom, playGlobalMessage: PlayGlobalMessage) { emitPlayGlobalMessage(room: GameRoom, playGlobalMessage: PlayGlobalMessage) {
try { const serverToClientMessage = new ServerToClientMessage();
const serverToClientMessage = new ServerToClientMessage(); serverToClientMessage.setPlayglobalmessage(playGlobalMessage);
serverToClientMessage.setPlayglobalmessage(playGlobalMessage);
for (const [id, user] of room.getUsers().entries()) { for (const [id, user] of room.getUsers().entries()) {
user.socket.write(serverToClientMessage); user.socket.write(serverToClientMessage);
}
} catch (e) {
console.error('An error occurred on "emitPlayGlobalMessage" event');
console.error(e);
} }
} }
public getWorlds(): Map<string, GameRoom> { public getWorlds(): Map<string, PromiseLike<GameRoom>> {
return this.rooms; return this.roomsPromises;
} }
public handleQueryJitsiJwtMessage(user: User, queryJitsiJwtMessage: QueryJitsiJwtMessage) { public handleQueryJitsiJwtMessage(user: User, queryJitsiJwtMessage: QueryJitsiJwtMessage) {
@ -592,11 +598,10 @@ export class SocketManager {
}, 10000); }, 10000);
} }
public addZoneListener(call: ZoneSocket, roomId: string, x: number, y: number): void { public async addZoneListener(call: ZoneSocket, roomId: string, x: number, y: number): Promise<void> {
const room = this.rooms.get(roomId); const room = await this.roomsPromises.get(roomId);
if (!room) { if (!room) {
console.error("In addZoneListener, could not find room with id '" + roomId + "'"); throw new Error("In addZoneListener, could not find room with id '" + roomId + "'");
return;
} }
const things = room.addZoneListener(call, x, y); const things = room.addZoneListener(call, x, y);
@ -637,16 +642,37 @@ export class SocketManager {
call.write(batchMessage); call.write(batchMessage);
} }
removeZoneListener(call: ZoneSocket, roomId: string, x: number, y: number) { async removeZoneListener(call: ZoneSocket, roomId: string, x: number, y: number): Promise<void> {
const room = this.rooms.get(roomId); const room = await this.roomsPromises.get(roomId);
if (!room) { if (!room) {
console.error("In removeZoneListener, could not find room with id '" + roomId + "'"); throw new Error("In removeZoneListener, could not find room with id '" + roomId + "'");
return;
} }
room.removeZoneListener(call, x, y); room.removeZoneListener(call, x, y);
} }
async addRoomListener(call: RoomSocket, roomId: string) {
const room = await this.getOrCreateRoom(roomId);
if (!room) {
throw new Error("In addRoomListener, could not find room with id '" + roomId + "'");
}
room.addRoomListener(call);
const batchMessage = new BatchToPusherRoomMessage();
call.write(batchMessage);
}
async removeRoomListener(call: RoomSocket, roomId: string) {
const room = await this.roomsPromises.get(roomId);
if (!room) {
throw new Error("In removeRoomListener, could not find room with id '" + roomId + "'");
}
room.removeRoomListener(call);
}
public async handleJoinAdminRoom(admin: Admin, roomId: string): Promise<GameRoom> { public async handleJoinAdminRoom(admin: Admin, roomId: string): Promise<GameRoom> {
const room = await socketManager.getOrCreateRoom(roomId); const room = await socketManager.getOrCreateRoom(roomId);
@ -658,14 +684,14 @@ export class SocketManager {
public leaveAdminRoom(room: GameRoom, admin: Admin) { public leaveAdminRoom(room: GameRoom, admin: Admin) {
room.adminLeave(admin); room.adminLeave(admin);
if (room.isEmpty()) { if (room.isEmpty()) {
this.rooms.delete(room.roomUrl); this.roomsPromises.delete(room.roomUrl);
gaugeManager.decNbRoomGauge(); gaugeManager.decNbRoomGauge();
debug('Room is empty. Deleting room "%s"', room.roomUrl); debug('Room is empty. Deleting room "%s"', room.roomUrl);
} }
} }
public sendAdminMessage(roomId: string, recipientUuid: string, message: string): void { public async sendAdminMessage(roomId: string, recipientUuid: string, message: string): Promise<void> {
const room = this.rooms.get(roomId); const room = await this.roomsPromises.get(roomId);
if (!room) { if (!room) {
console.error( console.error(
"In sendAdminMessage, could not find room with id '" + "In sendAdminMessage, could not find room with id '" +
@ -697,8 +723,8 @@ export class SocketManager {
} }
} }
public banUser(roomId: string, recipientUuid: string, message: string): void { public async banUser(roomId: string, recipientUuid: string, message: string): Promise<void> {
const room = this.rooms.get(roomId); const room = await this.roomsPromises.get(roomId);
if (!room) { if (!room) {
console.error( console.error(
"In banUser, could not find room with id '" + "In banUser, could not find room with id '" +
@ -735,8 +761,8 @@ export class SocketManager {
} }
} }
sendAdminRoomMessage(roomId: string, message: string) { async sendAdminRoomMessage(roomId: string, message: string) {
const room = this.rooms.get(roomId); const room = await this.roomsPromises.get(roomId);
if (!room) { if (!room) {
//todo: this should cause the http call to return a 500 //todo: this should cause the http call to return a 500
console.error( console.error(
@ -759,12 +785,12 @@ export class SocketManager {
}); });
} }
dispatchWorlFullWarning(roomId: string): void { async dispatchWorldFullWarning(roomId: string): Promise<void> {
const room = this.rooms.get(roomId); const room = await this.roomsPromises.get(roomId);
if (!room) { if (!room) {
//todo: this should cause the http call to return a 500 //todo: this should cause the http call to return a 500
console.error( console.error(
"In sendAdminRoomMessage, could not find room with id '" + "In dispatchWorldFullWarning, could not find room with id '" +
roomId + roomId +
"'. Maybe the room was closed a few milliseconds ago and there was a race condition?" "'. Maybe the room was closed a few milliseconds ago and there was a race condition?"
); );
@ -781,8 +807,8 @@ export class SocketManager {
}); });
} }
dispatchRoomRefresh(roomId: string): void { async dispatchRoomRefresh(roomId: string): Promise<void> {
const room = this.rooms.get(roomId); const room = await this.roomsPromises.get(roomId);
if (!room) { if (!room) {
return; return;
} }

View file

@ -0,0 +1,218 @@
/**
* Handles variables shared between the scripting API and the server.
*/
import { ITiledMap, ITiledMapObject, ITiledMapObjectLayer } from "@workadventure/tiled-map-type-guard/dist";
import { User } from "_Model/User";
import { variablesRepository } from "./Repository/VariablesRepository";
import { redisClient } from "./RedisClient";
interface Variable {
defaultValue?: string;
persist?: boolean;
readableBy?: string;
writableBy?: string;
}
export class VariablesManager {
/**
* The actual values of the variables for the current room
*/
private _variables = new Map<string, string>();
/**
* The list of variables that are allowed
*/
private variableObjects: Map<string, Variable> | undefined;
/**
* @param map The map can be "null" if it is hosted on a private network. In this case, we assume this is a test setup and bypass any server-side checks.
*/
constructor(private roomUrl: string, private map: ITiledMap | null) {
// We initialize the list of variable object at room start. The objects cannot be edited later
// (otherwise, this would cause a security issue if the scripting API can edit this list of objects)
if (map) {
this.variableObjects = VariablesManager.findVariablesInMap(map);
// Let's initialize default values
for (const [name, variableObject] of this.variableObjects.entries()) {
if (variableObject.defaultValue !== undefined) {
this._variables.set(name, variableObject.defaultValue);
}
}
}
}
/**
* Let's load data from the Redis backend.
*/
public async init(): Promise<VariablesManager> {
if (!this.shouldPersist()) {
return this;
}
const variables = await variablesRepository.loadVariables(this.roomUrl);
for (const key in variables) {
// Let's only set variables if they are in the map (if the map has changed, maybe stored variables do not exist anymore)
if (this.variableObjects) {
const variableObject = this.variableObjects.get(key);
if (variableObject === undefined) {
continue;
}
if (!variableObject.persist) {
continue;
}
}
this._variables.set(key, variables[key]);
}
return this;
}
/**
* Returns true if saving should be enabled, and false otherwise.
*
* Saving is enabled if REDIS_HOST is set
* unless we are editing a local map
* unless we are in dev mode in which case it is ok to save
*
* @private
*/
private shouldPersist(): boolean {
return redisClient !== null && (this.map !== null || process.env.NODE_ENV === "development");
}
private static findVariablesInMap(map: ITiledMap): Map<string, Variable> {
const objects = new Map<string, Variable>();
for (const layer of map.layers) {
if (layer.type === "objectgroup") {
for (const object of (layer as ITiledMapObjectLayer).objects) {
if (object.type === "variable") {
if (object.template) {
console.warn(
'Warning, a variable object is using a Tiled "template". WorkAdventure does not support objects generated from Tiled templates.'
);
continue;
}
// We store a copy of the object (to make it immutable)
objects.set(object.name, this.iTiledObjectToVariable(object));
}
}
}
}
return objects;
}
private static iTiledObjectToVariable(object: ITiledMapObject): Variable {
const variable: Variable = {};
if (object.properties) {
for (const property of object.properties) {
const value = property.value;
switch (property.name) {
case "default":
variable.defaultValue = JSON.stringify(value);
break;
case "persist":
if (typeof value !== "boolean") {
throw new Error('The persist property of variable "' + object.name + '" must be a boolean');
}
variable.persist = value;
break;
case "writableBy":
if (typeof value !== "string") {
throw new Error(
'The writableBy property of variable "' + object.name + '" must be a string'
);
}
if (value) {
variable.writableBy = value;
}
break;
case "readableBy":
if (typeof value !== "string") {
throw new Error(
'The readableBy property of variable "' + object.name + '" must be a string'
);
}
if (value) {
variable.readableBy = value;
}
break;
}
}
}
return variable;
}
/**
* Sets the variable.
*
* Returns who is allowed to read the variable (the readableby property) or "undefined" if anyone can read it.
* Also, returns "false" if the variable was not modified (because we set it to the value it already has)
*
* @param name
* @param value
* @param user
*/
setVariable(name: string, value: string, user: User): string | undefined | false {
let readableBy: string | undefined;
let variableObject: Variable | undefined;
if (this.variableObjects) {
variableObject = this.variableObjects.get(name);
if (variableObject === undefined) {
throw new Error('Trying to set a variable "' + name + '" that is not defined as an object in the map.');
}
if (variableObject.writableBy && !user.tags.includes(variableObject.writableBy)) {
throw new Error(
'Trying to set a variable "' +
name +
'". User "' +
user.name +
'" does not have sufficient permission. Required tag: "' +
variableObject.writableBy +
'". User tags: ' +
user.tags.join(", ") +
"."
);
}
readableBy = variableObject.readableBy;
}
// If the value is not modified, return false
if (this._variables.get(name) === value) {
return false;
}
this._variables.set(name, value);
if (variableObject !== undefined && variableObject.persist) {
variablesRepository
.saveVariable(this.roomUrl, name, value)
.catch((e) => console.error("Error while saving variable in Redis:", e));
}
return readableBy;
}
public getVariablesForTags(tags: string[]): Map<string, string> {
if (this.variableObjects === undefined) {
return this._variables;
}
const readableVariables = new Map<string, string>();
for (const [key, value] of this._variables.entries()) {
const variableObject = this.variableObjects.get(key);
if (variableObject === undefined) {
throw new Error('Unexpected variable "' + key + '" found has no associated variableObject.');
}
if (!variableObject.readableBy || tags.includes(variableObject.readableBy)) {
readableVariables.set(key, value);
}
}
return readableVariables;
}
}

View file

@ -37,7 +37,7 @@ function createJoinRoomMessage(uuid: string, x: number, y: number): JoinRoomMess
const emote: EmoteCallback = (emoteEventMessage, listener): void => {} const emote: EmoteCallback = (emoteEventMessage, listener): void => {}
describe("GameRoom", () => { describe("GameRoom", () => {
it("should connect user1 and user2", () => { it("should connect user1 and user2", async () => {
let connectCalledNumber: number = 0; let connectCalledNumber: number = 0;
const connect: ConnectCallback = (user: User, group: Group): void => { const connect: ConnectCallback = (user: User, group: Group): void => {
connectCalledNumber++; connectCalledNumber++;
@ -47,8 +47,7 @@ describe("GameRoom", () => {
} }
const world = new GameRoom('_/global/test.json', connect, disconnect, 160, 160, () => {}, () => {}, () => {}, emote); const world = await GameRoom.create('https://play.workadventu.re/_/global/localhost/test.json', connect, disconnect, 160, 160, () => {}, () => {}, () => {}, emote);
const user1 = world.join(createMockUserSocket(), createJoinRoomMessage('1', 100, 100)); const user1 = world.join(createMockUserSocket(), createJoinRoomMessage('1', 100, 100));
@ -67,7 +66,7 @@ describe("GameRoom", () => {
expect(connectCalledNumber).toBe(2); expect(connectCalledNumber).toBe(2);
}); });
it("should connect 3 users", () => { it("should connect 3 users", async () => {
let connectCalled: boolean = false; let connectCalled: boolean = false;
const connect: ConnectCallback = (user: User, group: Group): void => { const connect: ConnectCallback = (user: User, group: Group): void => {
connectCalled = true; connectCalled = true;
@ -76,7 +75,7 @@ describe("GameRoom", () => {
} }
const world = new GameRoom('_/global/test.json', connect, disconnect, 160, 160, () => {}, () => {}, () => {}, emote); const world = await GameRoom.create('https://play.workadventu.re/_/global/localhost/test.json', connect, disconnect, 160, 160, () => {}, () => {}, () => {}, emote);
const user1 = world.join(createMockUserSocket(), createJoinRoomMessage('1', 100, 100)); const user1 = world.join(createMockUserSocket(), createJoinRoomMessage('1', 100, 100));
@ -95,7 +94,7 @@ describe("GameRoom", () => {
expect(connectCalled).toBe(true); expect(connectCalled).toBe(true);
}); });
it("should disconnect user1 and user2", () => { it("should disconnect user1 and user2", async () => {
let connectCalled: boolean = false; let connectCalled: boolean = false;
let disconnectCallNumber: number = 0; let disconnectCallNumber: number = 0;
const connect: ConnectCallback = (user: User, group: Group): void => { const connect: ConnectCallback = (user: User, group: Group): void => {
@ -105,7 +104,7 @@ describe("GameRoom", () => {
disconnectCallNumber++; disconnectCallNumber++;
} }
const world = new GameRoom('_/global/test.json', connect, disconnect, 160, 160, () => {}, () => {}, () => {}, emote); const world = await GameRoom.create('https://play.workadventu.re/_/global/localhost/test.json', connect, disconnect, 160, 160, () => {}, () => {}, () => {}, emote);
const user1 = world.join(createMockUserSocket(), createJoinRoomMessage('1', 100, 100)); const user1 = world.join(createMockUserSocket(), createJoinRoomMessage('1', 100, 100));

View file

@ -0,0 +1,32 @@
import { arrayIntersect } from "../src/Services/ArrayHelper";
import { mapFetcher } from "../src/Services/MapFetcher";
describe("MapFetcher", () => {
it("should return true on localhost ending URLs", async () => {
expect(await mapFetcher.isLocalUrl("https://localhost")).toBeTrue();
expect(await mapFetcher.isLocalUrl("https://foo.localhost")).toBeTrue();
});
it("should return true on DNS resolving to a local domain", async () => {
expect(await mapFetcher.isLocalUrl("https://127.0.0.1.nip.io")).toBeTrue();
});
it("should return true on an IP resolving to a local domain", async () => {
expect(await mapFetcher.isLocalUrl("https://127.0.0.1")).toBeTrue();
expect(await mapFetcher.isLocalUrl("https://192.168.0.1")).toBeTrue();
});
it("should return false on an IP resolving to a global domain", async () => {
expect(await mapFetcher.isLocalUrl("https://51.12.42.42")).toBeFalse();
});
it("should return false on an DNS resolving to a global domain", async () => {
expect(await mapFetcher.isLocalUrl("https://maps.workadventu.re")).toBeFalse();
});
it("should throw error on invalid domain", async () => {
await expectAsync(
mapFetcher.isLocalUrl("https://this.domain.name.doesnotexistfoobgjkgfdjkgldf.com")
).toBeRejected();
});
});

View file

@ -3,7 +3,7 @@
"experimentalDecorators": true, "experimentalDecorators": true,
/* Basic Options */ /* Basic Options */
// "incremental": true, /* Enable incremental compilation */ // "incremental": true, /* Enable incremental compilation */
"target": "es5", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */ "target": "ES2019", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */
"downlevelIteration": true, "downlevelIteration": true,
"module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */ "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. */ // "lib": [], /* Specify library files to be included in the compilation. */

View file

@ -122,6 +122,13 @@
resolved "https://registry.yarnpkg.com/@types/parse-json/-/parse-json-4.0.0.tgz#2f8bb441434d163b35fb8ffdccd7138927ffb8c0" resolved "https://registry.yarnpkg.com/@types/parse-json/-/parse-json-4.0.0.tgz#2f8bb441434d163b35fb8ffdccd7138927ffb8c0"
integrity sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA== integrity sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA==
"@types/redis@^2.8.31":
version "2.8.31"
resolved "https://registry.yarnpkg.com/@types/redis/-/redis-2.8.31.tgz#c11c1b269fec132ac2ec9eb891edf72fc549149e"
integrity sha512-daWrrTDYaa5iSDFbgzZ9gOOzyp2AJmYK59OlG/2KGBgYWF3lfs8GDKm1c//tik5Uc93hDD36O+qLPvzDolChbA==
dependencies:
"@types/node" "*"
"@types/strip-bom@^3.0.0": "@types/strip-bom@^3.0.0":
version "3.0.0" version "3.0.0"
resolved "https://registry.yarnpkg.com/@types/strip-bom/-/strip-bom-3.0.0.tgz#14a8ec3956c2e81edb7520790aecf21c290aebd2" resolved "https://registry.yarnpkg.com/@types/strip-bom/-/strip-bom-3.0.0.tgz#14a8ec3956c2e81edb7520790aecf21c290aebd2"
@ -187,6 +194,13 @@
semver "^7.3.2" semver "^7.3.2"
tsutils "^3.17.1" tsutils "^3.17.1"
"@workadventure/tiled-map-type-guard@^1.0.0":
version "1.0.0"
resolved "https://registry.yarnpkg.com/@workadventure/tiled-map-type-guard/-/tiled-map-type-guard-1.0.0.tgz#02524602ee8b2688429a1f56df1d04da3fc171ba"
integrity sha512-Mc0SE128otQnYlScQWVaQVyu1+CkailU/FTBh09UTrVnBAhyMO+jIn9vT9+Dv244xq+uzgQDpXmiVdjgrYFQ+A==
dependencies:
generic-type-guard "^3.4.1"
abbrev@1: abbrev@1:
version "1.1.1" version "1.1.1"
resolved "https://registry.yarnpkg.com/abbrev/-/abbrev-1.1.1.tgz#f8f2c887ad10bf67f634f005b6987fed3179aac8" resolved "https://registry.yarnpkg.com/abbrev/-/abbrev-1.1.1.tgz#f8f2c887ad10bf67f634f005b6987fed3179aac8"
@ -797,6 +811,11 @@ delegates@^1.0.0:
resolved "https://registry.yarnpkg.com/delegates/-/delegates-1.0.0.tgz#84c6e159b81904fdca59a0ef44cd870d31250f9a" resolved "https://registry.yarnpkg.com/delegates/-/delegates-1.0.0.tgz#84c6e159b81904fdca59a0ef44cd870d31250f9a"
integrity sha1-hMbhWbgZBP3KWaDvRM2HDTElD5o= integrity sha1-hMbhWbgZBP3KWaDvRM2HDTElD5o=
denque@^1.5.0:
version "1.5.0"
resolved "https://registry.yarnpkg.com/denque/-/denque-1.5.0.tgz#773de0686ff2d8ec2ff92914316a47b73b1c73de"
integrity sha512-CYiCSgIF1p6EUByQPlGkKnP1M9g0ZV3qMIrqMqZqdwazygIA/YP2vrbcyl1h/WppKJTdl1F85cXIle+394iDAQ==
detect-libc@^1.0.2: detect-libc@^1.0.2:
version "1.0.3" version "1.0.3"
resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-1.0.3.tgz#fa137c4bd698edf55cd5cd02ac559f91a4c4ba9b" resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-1.0.3.tgz#fa137c4bd698edf55cd5cd02ac559f91a4c4ba9b"
@ -1181,6 +1200,11 @@ generic-type-guard@^3.2.0:
resolved "https://registry.yarnpkg.com/generic-type-guard/-/generic-type-guard-3.3.3.tgz#954b846fecff91047cadb0dcc28930811fcb9dc1" resolved "https://registry.yarnpkg.com/generic-type-guard/-/generic-type-guard-3.3.3.tgz#954b846fecff91047cadb0dcc28930811fcb9dc1"
integrity sha512-SXraZvNW/uTfHVgB48iEwWaD1XFJ1nvZ8QP6qy9pSgaScEyQqFHYN5E6d6rCsJgrvlWKygPrNum7QeJHegzNuQ== integrity sha512-SXraZvNW/uTfHVgB48iEwWaD1XFJ1nvZ8QP6qy9pSgaScEyQqFHYN5E6d6rCsJgrvlWKygPrNum7QeJHegzNuQ==
generic-type-guard@^3.4.1:
version "3.4.1"
resolved "https://registry.yarnpkg.com/generic-type-guard/-/generic-type-guard-3.4.1.tgz#0896dc018de915c890562a34763858076e4676da"
integrity sha512-sXce0Lz3Wfy2rR1W8O8kUemgEriTeG1x8shqSJeWGb0FwJu2qBEkB1M2qXbdSLmpgDnHcIXo0Dj/1VLNJkK/QA==
get-own-enumerable-property-symbols@^3.0.0: get-own-enumerable-property-symbols@^3.0.0:
version "3.0.2" version "3.0.2"
resolved "https://registry.yarnpkg.com/get-own-enumerable-property-symbols/-/get-own-enumerable-property-symbols-3.0.2.tgz#b5fde77f22cbe35f390b4e089922c50bce6ef664" resolved "https://registry.yarnpkg.com/get-own-enumerable-property-symbols/-/get-own-enumerable-property-symbols-3.0.2.tgz#b5fde77f22cbe35f390b4e089922c50bce6ef664"
@ -1417,6 +1441,11 @@ invert-kv@^1.0.0:
resolved "https://registry.yarnpkg.com/invert-kv/-/invert-kv-1.0.0.tgz#104a8e4aaca6d3d8cd157a8ef8bfab2d7a3ffdb6" resolved "https://registry.yarnpkg.com/invert-kv/-/invert-kv-1.0.0.tgz#104a8e4aaca6d3d8cd157a8ef8bfab2d7a3ffdb6"
integrity sha1-EEqOSqym09jNFXqO+L+rLXo//bY= integrity sha1-EEqOSqym09jNFXqO+L+rLXo//bY=
ipaddr.js@^2.0.1:
version "2.0.1"
resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-2.0.1.tgz#eca256a7a877e917aeb368b0a7497ddf42ef81c0"
integrity sha512-1qTgH9NG+IIJ4yfKs2e6Pp1bZg8wbDbKHT21HrLIeYBTRLgMYKnMTPAuI3Lcs61nfx5h1xlXnbJtH1kX5/d/ng==
is-accessor-descriptor@^0.1.6: is-accessor-descriptor@^0.1.6:
version "0.1.6" version "0.1.6"
resolved "https://registry.yarnpkg.com/is-accessor-descriptor/-/is-accessor-descriptor-0.1.6.tgz#a9e12cb3ae8d876727eeef3843f8a0897b5c98d6" resolved "https://registry.yarnpkg.com/is-accessor-descriptor/-/is-accessor-descriptor-0.1.6.tgz#a9e12cb3ae8d876727eeef3843f8a0897b5c98d6"
@ -2424,6 +2453,33 @@ redent@^1.0.0:
indent-string "^2.1.0" indent-string "^2.1.0"
strip-indent "^1.0.1" strip-indent "^1.0.1"
redis-commands@^1.7.0:
version "1.7.0"
resolved "https://registry.yarnpkg.com/redis-commands/-/redis-commands-1.7.0.tgz#15a6fea2d58281e27b1cd1acfb4b293e278c3a89"
integrity sha512-nJWqw3bTFy21hX/CPKHth6sfhZbdiHP6bTawSgQBlKOVRG7EZkfHbbHwQJnrE4vsQf0CMNE+3gJ4Fmm16vdVlQ==
redis-errors@^1.0.0, redis-errors@^1.2.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/redis-errors/-/redis-errors-1.2.0.tgz#eb62d2adb15e4eaf4610c04afe1529384250abad"
integrity sha1-62LSrbFeTq9GEMBK/hUpOEJQq60=
redis-parser@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/redis-parser/-/redis-parser-3.0.0.tgz#b66d828cdcafe6b4b8a428a7def4c6bcac31c8b4"
integrity sha1-tm2CjNyv5rS4pCin3vTGvKwxyLQ=
dependencies:
redis-errors "^1.0.0"
redis@^3.1.2:
version "3.1.2"
resolved "https://registry.yarnpkg.com/redis/-/redis-3.1.2.tgz#766851117e80653d23e0ed536254677ab647638c"
integrity sha512-grn5KoZLr/qrRQVwoSkmzdbw6pwF+/rwODtrOr6vuBRiR/f3rjSTGupbF90Zpqm2oenix8Do6RV7pYEkGwlKkw==
dependencies:
denque "^1.5.0"
redis-commands "^1.7.0"
redis-errors "^1.2.0"
redis-parser "^3.0.0"
regex-not@^1.0.0, regex-not@^1.0.2: regex-not@^1.0.0, regex-not@^1.0.2:
version "1.0.2" version "1.0.2"
resolved "https://registry.yarnpkg.com/regex-not/-/regex-not-1.0.2.tgz#1f4ece27e00b0b65e0247a6810e6a85d83a5752c" resolved "https://registry.yarnpkg.com/regex-not/-/regex-not-1.0.2.tgz#1f4ece27e00b0b65e0247a6810e6a85d83a5752c"

View file

@ -13,7 +13,6 @@ RoomConnection.setWebsocketFactory((url: string) => {
}); });
async function startOneUser(): Promise<void> { async function startOneUser(): Promise<void> {
await connectionManager.anonymousLogin(true);
const onConnect = await connectionManager.connectToRoomSocket(process.env.ROOM_ID ? process.env.ROOM_ID : '_/global/maps.workadventure.localhost/Floor0/floor0.json', 'TEST', ['male3'], const onConnect = await connectionManager.connectToRoomSocket(process.env.ROOM_ID ? process.env.ROOM_ID : '_/global/maps.workadventure.localhost/Floor0/floor0.json', 'TEST', ['male3'],
{ {
x: 783, x: 783,
@ -23,7 +22,7 @@ async function startOneUser(): Promise<void> {
bottom: 200, bottom: 200,
left: 500, left: 500,
right: 800 right: 800
}); }, null);
const connection = onConnect.connection; const connection = onConnect.connection;

View file

@ -22,6 +22,7 @@
"JITSI_URL": env.JITSI_URL, "JITSI_URL": env.JITSI_URL,
"SECRET_JITSI_KEY": env.SECRET_JITSI_KEY, "SECRET_JITSI_KEY": env.SECRET_JITSI_KEY,
"TURN_STATIC_AUTH_SECRET": env.TURN_STATIC_AUTH_SECRET, "TURN_STATIC_AUTH_SECRET": env.TURN_STATIC_AUTH_SECRET,
"REDIS_HOST": "redis",
} + (if adminUrl != null then { } + (if adminUrl != null then {
"ADMIN_API_URL": adminUrl, "ADMIN_API_URL": adminUrl,
} else {}) } else {})
@ -40,6 +41,7 @@
"JITSI_URL": env.JITSI_URL, "JITSI_URL": env.JITSI_URL,
"SECRET_JITSI_KEY": env.SECRET_JITSI_KEY, "SECRET_JITSI_KEY": env.SECRET_JITSI_KEY,
"TURN_STATIC_AUTH_SECRET": env.TURN_STATIC_AUTH_SECRET, "TURN_STATIC_AUTH_SECRET": env.TURN_STATIC_AUTH_SECRET,
"REDIS_HOST": "redis",
} + (if adminUrl != null then { } + (if adminUrl != null then {
"ADMIN_API_URL": adminUrl, "ADMIN_API_URL": adminUrl,
} else {}) } else {})
@ -97,6 +99,9 @@
}, },
"ports": [80] "ports": [80]
}, },
"redis": {
"image": "redis:6",
}
}, },
"config": { "config": {
k8sextension(k8sConf):: k8sextension(k8sConf)::

View file

@ -53,7 +53,7 @@ services:
- "traefik.http.routers.front-ssl.service=front" - "traefik.http.routers.front-ssl.service=front"
pusher: pusher:
image: thecodingmachine/nodejs:12 image: thecodingmachine/nodejs:14
command: yarn dev command: yarn dev
#command: yarn run prod #command: yarn run prod
#command: yarn run profile #command: yarn run profile
@ -66,6 +66,10 @@ services:
API_URL: back:50051 API_URL: back:50051
JITSI_URL: $JITSI_URL JITSI_URL: $JITSI_URL
JITSI_ISS: $JITSI_ISS JITSI_ISS: $JITSI_ISS
FRONT_URL: http://localhost
OPID_CLIENT_ID: $OPID_CLIENT_ID
OPID_CLIENT_SECRET: $OPID_CLIENT_SECRET
OPID_CLIENT_ISSUER: $OPID_CLIENT_ISSUER
volumes: volumes:
- ./pusher:/usr/src/app - ./pusher:/usr/src/app
labels: labels:
@ -120,6 +124,8 @@ services:
JITSI_URL: $JITSI_URL JITSI_URL: $JITSI_URL
JITSI_ISS: $JITSI_ISS JITSI_ISS: $JITSI_ISS
MAX_PER_GROUP: "$MAX_PER_GROUP" MAX_PER_GROUP: "$MAX_PER_GROUP"
REDIS_HOST: redis
NODE_ENV: development
volumes: volumes:
- ./back:/usr/src/app - ./back:/usr/src/app
labels: labels:
@ -168,6 +174,9 @@ services:
- ./front:/usr/src/front - ./front:/usr/src/front
- ./pusher:/usr/src/pusher - ./pusher:/usr/src/pusher
redis:
image: redis:6
# coturn: # coturn:
# image: coturn/coturn:4.5.2 # image: coturn/coturn:4.5.2
# command: # command:

View file

@ -55,7 +55,7 @@ services:
- "traefik.http.routers.front-ssl.service=front" - "traefik.http.routers.front-ssl.service=front"
pusher: pusher:
image: thecodingmachine/nodejs:12 image: thecodingmachine/nodejs:14
command: yarn dev command: yarn dev
environment: environment:
DEBUG: "socket:*" DEBUG: "socket:*"
@ -66,6 +66,10 @@ services:
API_URL: back:50051 API_URL: back:50051
JITSI_URL: $JITSI_URL JITSI_URL: $JITSI_URL
JITSI_ISS: $JITSI_ISS JITSI_ISS: $JITSI_ISS
FRONT_URL: http://play.workadventure.localhost
OPID_CLIENT_ID: $OPID_CLIENT_ID
OPID_CLIENT_SECRET: $OPID_CLIENT_SECRET
OPID_CLIENT_ISSUER: $OPID_CLIENT_ISSUER
volumes: volumes:
- ./pusher:/usr/src/app - ./pusher:/usr/src/app
labels: labels:
@ -115,6 +119,8 @@ services:
JITSI_ISS: $JITSI_ISS JITSI_ISS: $JITSI_ISS
TURN_STATIC_AUTH_SECRET: SomeStaticAuthSecret TURN_STATIC_AUTH_SECRET: SomeStaticAuthSecret
MAX_PER_GROUP: "MAX_PER_GROUP" MAX_PER_GROUP: "MAX_PER_GROUP"
REDIS_HOST: redis
NODE_ENV: development
volumes: volumes:
- ./back:/usr/src/app - ./back:/usr/src/app
labels: labels:
@ -157,6 +163,20 @@ services:
- ./front:/usr/src/front - ./front:/usr/src/front
- ./pusher:/usr/src/pusher - ./pusher:/usr/src/pusher
redis:
image: redis:6
redisinsight:
image: redislabs/redisinsight:latest
labels:
- "traefik.http.routers.redisinsight.rule=Host(`redis.workadventure.localhost`)"
- "traefik.http.routers.redisinsight.entryPoints=web"
- "traefik.http.services.redisinsight.loadbalancer.server.port=8001"
- "traefik.http.routers.redisinsight-ssl.rule=Host(`redis.workadventure.localhost`)"
- "traefik.http.routers.redisinsight-ssl.entryPoints=websecure"
- "traefik.http.routers.redisinsight-ssl.tls=true"
- "traefik.http.routers.redisinsight-ssl.service=redisinsight"
# coturn: # coturn:
# image: coturn/coturn:4.5.2 # image: coturn/coturn:4.5.2
# command: # command:

View file

@ -1,6 +1,62 @@
{.section-title.accent.text-primary} {.section-title.accent.text-primary}
# API Player functions Reference # API Player functions Reference
### Get the player name
```
WA.player.name: string;
```
The player name is available from the `WA.player.name` property.
{.alert.alert-info}
You need to wait for the end of the initialization before accessing `WA.player.name`
```typescript
WA.onInit().then(() => {
console.log('Player name: ', WA.player.name);
})
```
### Get the player ID
```
WA.player.id: string|undefined;
```
The player ID is available from the `WA.player.id` property.
This is a unique identifier for a given player. Anonymous player might not have an id.
{.alert.alert-info}
You need to wait for the end of the initialization before accessing `WA.player.id`
```typescript
WA.onInit().then(() => {
console.log('Player ID: ', WA.player.id);
})
```
### Get the tags of the player
```
WA.player.tags: string[];
```
The player tags are available from the `WA.player.tags` property.
They represent a set of rights the player acquires after login in.
{.alert.alert-warn}
Tags attributed to a user depend on the authentication system you are using. For the hosted version
of WorkAdventure, you can define tags related to the user in the [administration panel](https://workadventu.re/admin-guide/manage-members).
{.alert.alert-info}
You need to wait for the end of the initialization before accessing `WA.player.tags`
```typescript
WA.onInit().then(() => {
console.log('Tags: ', WA.player.tags);
})
```
### Listen to player movement ### Listen to player movement
``` ```
@ -19,4 +75,4 @@ The event has the following attributes :
Example : Example :
```javascript ```javascript
WA.player.onPlayerMove(console.log); WA.player.onPlayerMove(console.log);
``` ```

View file

@ -1,9 +1,11 @@
{.section-title.accent.text-primary} {.section-title.accent.text-primary}
# API Reference # API Reference
- [Start / Init functions](api-start.md)
- [Navigation functions](api-nav.md) - [Navigation functions](api-nav.md)
- [Chat functions](api-chat.md) - [Chat functions](api-chat.md)
- [Room functions](api-room.md) - [Room functions](api-room.md)
- [State related functions](api-state.md)
- [Player functions](api-player.md) - [Player functions](api-player.md)
- [UI functions](api-ui.md) - [UI functions](api-ui.md)
- [Sound functions](api-sound.md) - [Sound functions](api-sound.md)

View file

@ -79,6 +79,58 @@ Example :
WA.room.setProperty('wikiLayer', 'openWebsite', 'https://www.wikipedia.org/'); WA.room.setProperty('wikiLayer', 'openWebsite', 'https://www.wikipedia.org/');
``` ```
### Get the room id
```
WA.room.id: string;
```
The ID of the current room is available from the `WA.room.id` property.
{.alert.alert-info}
You need to wait for the end of the initialization before accessing `WA.room.id`
```typescript
WA.onInit().then(() => {
console.log('Room id: ', WA.room.id);
// Will output something like: 'https://play.workadventu.re/@/myorg/myworld/myroom', or 'https://play.workadventu.re/_/global/mymap.org/map.json"
})
```
### Get the map URL
```
WA.room.mapURL: string;
```
The URL of the map is available from the `WA.room.mapURL` property.
{.alert.alert-info}
You need to wait for the end of the initialization before accessing `WA.room.mapURL`
```typescript
WA.onInit().then(() => {
console.log('Map URL: ', WA.room.mapURL);
// Will output something like: 'https://mymap.org/map.json"
})
```
### Getting map data
```
WA.room.getTiledMap(): Promise<ITiledMap>
```
Returns a promise that resolves to the JSON map file.
```javascript
const map = await WA.room.getTiledMap();
console.log("Map generated with Tiled version ", map.tiledversion);
```
Check the [Tiled documentation to learn more about the format of the JSON map](https://doc.mapeditor.org/en/stable/reference/json-map-format/).
### Changing tiles ### Changing tiles
``` ```
WA.room.setTiles(tiles: TileDescriptor[]): void WA.room.setTiles(tiles: TileDescriptor[]): void

30
docs/maps/api-start.md Normal file
View file

@ -0,0 +1,30 @@
{.section-title.accent.text-primary}
# API start functions Reference
### Waiting for WorkAdventure API to be available
When your script / iFrame loads WorkAdventure, it takes a few milliseconds for your script / iFrame to exchange
data with WorkAdventure. You should wait for the WorkAdventure API to be fully ready using the `WA.onInit()` method.
```
WA.onInit(): Promise<void>
```
Some properties (like the current user name, or the room ID) are not available until `WA.onInit` has completed.
Example:
```typescript
WA.onInit().then(() => {
console.log('Current player name: ', WA.player.name);
});
```
Or the same code, using await/async:
```typescript
(async () => {
await WA.onInit();
console.log('Current player name: ', WA.player.name);
})();
```

136
docs/maps/api-state.md Normal file
View file

@ -0,0 +1,136 @@
{.section-title.accent.text-primary}
# API state related functions Reference
## Saving / loading state
The `WA.state` functions allow you to easily share a common state between all the players in a given room.
Moreover, `WA.state` functions can be used to persist this state across reloads.
```
WA.state.saveVariable(key : string, data : unknown): void
WA.state.loadVariable(key : string) : unknown
WA.state.onVariableChange(key : string).subscribe((data: unknown) => {}) : Subscription
WA.state.[any property]: unknown
```
These methods and properties can be used to save, load and track changes in variables related to the current room.
Variables stored in `WA.state` can be any value that is serializable in JSON.
Please refrain from storing large amounts of data in a room. Those functions are typically useful for saving or restoring
configuration / metadata.
{.alert.alert-warning}
We are in the process of fine-tuning variables, and we will eventually put limits on the maximum size a variable can hold. We will also put limits on the number of calls you can make to saving variables, so don't change the value of a variable every 10ms, this will fail in the future.
Example :
```javascript
WA.state.saveVariable('config', {
'bottomExitUrl': '/@/org/world/castle',
'topExitUrl': '/@/org/world/tower',
'enableBirdSound': true
}).catch(e => console.error('Something went wrong while saving variable', e));
//...
let config = WA.state.loadVariable('config');
```
You can use the shortcut properties to load and save variables. The code above is similar to:
```javascript
WA.state.config = {
'bottomExitUrl': '/@/org/world/castle',
'topExitUrl': '/@/org/world/tower',
'enableBirdSound': true
};
//...
let config = WA.state.config;
```
Note: `saveVariable` returns a promise that will fail in case the variable cannot be saved. This
can happen if your user does not have the required rights (more on that in the next chapter).
In contrast, if you use the WA.state properties, you cannot access the promise and therefore cannot
know for sure if your variable was properly saved.
If you are using Typescript, please note that the type of variables is `unknown`. This is
for security purpose, as we don't know the type of the variable. In order to use the returned value,
you will need to cast it to the correct type (or better, use a [Type guard](https://www.typescriptlang.org/docs/handbook/2/narrowing.html) to actually check at runtime
that you get the expected type).
{.alert.alert-warning}
For security reasons, the list of variables you are allowed to access and modify is **restricted** (otherwise, anyone on your map could set any data).
Variables storage is subject to an authorization process. Read below to learn more.
### Declaring allowed keys
In order to declare allowed keys related to a room, you need to add **objects** in an "object layer" of the map.
Each object will represent a variable.
<div class="row">
<div class="col">
<img src="https://workadventu.re/img/docs/object_variable.png" class="figure-img img-fluid rounded" alt="" />
</div>
</div>
The name of the variable is the name of the object.
The object **type** MUST be **variable**.
You can set a default value for the object in the `default` property.
### Persisting variables state
Use the `persist` property to save the state of the variable in database. If `persist` is false, the variable will stay
in the memory of the WorkAdventure servers but will be wiped out of the memory as soon as the room is empty (or if the
server restarts).
{.alert.alert-info}
Do not use `persist` for highly dynamic values that have a short life spawn.
### Managing access rights to variables
With `readableBy` and `writableBy`, you control who can read of write in this variable. The property accepts a string
representing a "tag". Anyone having this "tag" can read/write in the variable.
{.alert.alert-warning}
`readableBy` and `writableBy` are specific to the "online" version of WorkAdventure because the notion of tags
is not available unless you have an "admin" server (that is not part of the self-hosted version of WorkAdventure).
Finally, the `jsonSchema` property can contain [a complete JSON schema](https://json-schema.org/) to validate the content of the variable.
Trying to set a variable to a value that is not compatible with the schema will fail.
## Tracking variables changes
The properties of the `WA.state` object are shared in real-time between users of a same room. You can listen to modifications
of any property of `WA.state` by using the `WA.state.onVariableChange()` method.
```
WA.state.onVariableChange(name: string): Observable<unknown>
```
Usage:
```javascript
WA.state.onVariableChange('config').subscribe((value) => {
console.log('Variable "config" changed. New value: ', value);
});
```
The `WA.state.onVariableChange` method returns an [RxJS `Observable` object](https://rxjs.dev/guide/observable). This is
an object on which you can add subscriptions using the `subscribe` method.
### Stopping tracking variables
If you want to stop tracking a variable change, the `subscribe` method returns a subscription object with an `unsubscribe` method.
**Example with unsubscription:**
```javascript
const subscription = WA.state.onVariableChange('config').subscribe((value) => {
console.log('Variable "config" changed. New value: ', value);
});
// Later:
subscription.unsubscribe();
```

View file

@ -55,10 +55,10 @@ Start by testing this with a simple message sent to the chat.
**script.js** **script.js**
```javascript ```javascript
WA.sendChatMessage('Hello world', 'Mr Robot'); WA.chat.sendChatMessage('Hello world', 'Mr Robot');
``` ```
The `WA` objects contains a number of useful methods enabling you to interact with the WorkAdventure game. For instance, `WA.sendChatMessage` opens the chat and adds a message in it. The `WA` objects contains a number of useful methods enabling you to interact with the WorkAdventure game. For instance, `WA.chat.sendChatMessage` opens the chat and adds a message in it.
In your browser console, when you open the map, the chat message should be displayed right away. In your browser console, when you open the map, the chat message should be displayed right away.

35
docs/maps/text.md Normal file
View file

@ -0,0 +1,35 @@
{.section-title.accent.text-primary}
# Writing text on a map
## Solution 1: design a specific tileset (recommended)
If you want to write some text on a map, our recommendation is to create a tileset that contains
your text. You will obtain the most pleasant graphical result with this result, since you will be able
to control the fonts you use, and you will be able to disable the antialiasing of the font to get a
"crispy" result easily.
## Solution 2: using a "text" object in Tiled
On "object" layers, Tiled has support for "Text" objects. You can use these objects to add some
text on your map.
WorkAdventure will do its best to display the text properly. However, you need to know that:
- Tiled displays your system fonts.
- Computers have different sets of fonts. Therefore, browsers never rely on system fonts
- Which means if you select a font in Tiled, it is quite unlikely it will render properly in WorkAdventure
To circumvent this problem, in your text object in Tiled, you can add an additional property: `font-family`.
The `font-family` property can contain any "web-font" that can be loaded by your browser.
{.alert.alert-info}
**Pro-tip:** By default, WorkAdventure uses the **'"Press Start 2P"'** font, which is a great pixelated
font that has support for a variety of accents. It renders great when used at *8px* size.
<div>
<figure class="figure">
<img src="https://workadventu.re/img/docs/text-object.png" class="figure-img img-fluid rounded" alt="" style="width: 70%" />
<figcaption class="figure-caption">The "font-family" property</figcaption>
</figure>
</div>

View file

@ -56,7 +56,7 @@ A few things to notice:
## Building walls and "collidable" areas ## Building walls and "collidable" areas
By default, the characters can traverse any tiles. If you want to prevent your characeter from going through a tile (like a wall or a desktop), you must make this tile "collidable". You can do this by settings the `collides` property on a given tile. By default, the characters can traverse any tiles. If you want to prevent your character from going through a tile (like a wall or a desktop), you must make this tile "collidable". You can do this by settings the `collides` property on a given tile.
To make a tile "collidable", you should: To make a tile "collidable", you should:

View file

@ -34,6 +34,7 @@
<title>WorkAdventure</title> <title>WorkAdventure</title>
</head> </head>
<body id="body" style="margin: 0; background-color: #000"> <body id="body" style="margin: 0; background-color: #000">
<div class="main-container" id="main-container"> <div class="main-container" id="main-container">
<!-- Create the editor container --> <!-- Create the editor container -->
<div id="game" class="game"> <div id="game" class="game">

View file

@ -60,6 +60,9 @@
<section> <section>
<button id="enableNotification">Enable notifications</button> <button id="enableNotification">Enable notifications</button>
</section> </section>
<section hidden>
<button id="oidcLogin">Oauth Login</button>
</section>
<section> <section>
<button id="sparkButton">Create map</button> <button id="sparkButton">Create map</button>
</section> </section>

View file

@ -1,18 +0,0 @@
<style>
#warningMain {
border-radius: 5px;
height: 100px;
width: 300px;
background-color: red;
text-align: center;
}
#warningMain h2 {
padding: 5px;
}
</style>
<main id="warningMain">
<h2>Warning!</h2>
<p>This world is close to its limit!</p>
</main>

View file

@ -0,0 +1,62 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport"
content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<!-- TRACK CODE -->
<!-- END TRACK CODE -->
<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">
<link rel="apple-touch-icon" sizes="76x76" href="/static/images/favicons/apple-icon-76x76.png">
<link rel="apple-touch-icon" sizes="114x114" href="/static/images/favicons/apple-icon-114x114.png">
<link rel="apple-touch-icon" sizes="120x120" href="/static/images/favicons/apple-icon-120x120.png">
<link rel="apple-touch-icon" sizes="144x144" href="/static/images/favicons/apple-icon-144x144.png">
<link rel="apple-touch-icon" sizes="152x152" href="/static/images/favicons/apple-icon-152x152.png">
<link rel="apple-touch-icon" sizes="180x180" href="/static/images/favicons/apple-icon-180x180.png">
<link rel="icon" type="image/png" sizes="192x192" href="/static/images/favicons/android-icon-192x192.png">
<link rel="icon" type="image/png" sizes="32x32" href="/static/images/favicons/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="96x96" href="/static/images/favicons/favicon-96x96.png">
<link rel="icon" type="image/png" sizes="16x16" href="/static/images/favicons/favicon-16x16.png">
<meta name="msapplication-TileColor" content="#000000">
<meta name="msapplication-TileImage" content="/static/images/favicons/ms-icon-144x144.png">
<meta name="theme-color" content="#000000">
<title>WorkAdventure PWA</title>
<style>
body{
font-family: Whitney, Lato, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
}
body img{
position: absolute;
top: calc( 50% - 25px);
height: 59px;
width: 307px;
left: calc( 50% - 150px);
}
body p{
position: absolute;
text-align: center;
top: calc( 50% + 50px);
left: calc( 50% - 150px);
height: 59px;
width: 307px;
font-size: 20px;
}
</style>
</head>
<body>
<img src="/static/images/logo.png" alt="WorkAdventure logo"/>
<p>Charging your workspace ...</p>
<script>
setTimeout(() => {
window.location = localStorage.getItem('lastRoomUrl');
}, 4000);
</script>
</body>
</html>

View file

@ -48,6 +48,14 @@ self.addEventListener('fetch', function(event) {
); );
}); });
self.addEventListener('activate', function(event) { self.addEventListener('wait', function(event) {
//TODO activate service worker //TODO wait
});
self.addEventListener('update', function(event) {
//TODO update
});
self.addEventListener('beforeinstallprompt', (e) => {
//TODO change prompt
}); });

View file

@ -128,11 +128,12 @@
"type": "image\/png" "type": "image\/png"
} }
], ],
"start_url": "/", "start_url": "/resources/service-worker.html",
"background_color": "#000000", "background_color": "#000000",
"display_override": ["window-control-overlay", "minimal-ui"], "display_override": ["window-control-overlay", "minimal-ui"],
"display": "standalone", "display": "standalone",
"scope": "/", "orientation": "portrait-primary",
"scope": "/resources/",
"lang": "en", "lang": "en",
"theme_color": "#000000", "theme_color": "#000000",
"shortcuts": [ "shortcuts": [

View file

@ -10,6 +10,7 @@
"@types/mini-css-extract-plugin": "^1.4.3", "@types/mini-css-extract-plugin": "^1.4.3",
"@types/node": "^15.3.0", "@types/node": "^15.3.0",
"@types/quill": "^1.3.7", "@types/quill": "^1.3.7",
"@types/uuidv4": "^5.0.0",
"@types/webpack-dev-server": "^3.11.4", "@types/webpack-dev-server": "^3.11.4",
"@typescript-eslint/eslint-plugin": "^4.23.0", "@typescript-eslint/eslint-plugin": "^4.23.0",
"@typescript-eslint/parser": "^4.23.0", "@typescript-eslint/parser": "^4.23.0",
@ -53,7 +54,8 @@
"rxjs": "^6.6.3", "rxjs": "^6.6.3",
"simple-peer": "^9.11.0", "simple-peer": "^9.11.0",
"socket.io-client": "^2.3.0", "socket.io-client": "^2.3.0",
"standardized-audio-context": "^25.2.4" "standardized-audio-context": "^25.2.4",
"uuidv4": "^6.2.10"
}, },
"scripts": { "scripts": {
"start": "run-p templater serve svelte-check-watch", "start": "run-p templater serve svelte-check-watch",

View file

@ -4,10 +4,11 @@ export const isGameStateEvent = new tg.IsInterface()
.withProperties({ .withProperties({
roomId: tg.isString, roomId: tg.isString,
mapUrl: tg.isString, mapUrl: tg.isString,
nickname: tg.isUnion(tg.isString, tg.isNull), nickname: tg.isString,
uuid: tg.isUnion(tg.isString, tg.isUndefined), uuid: tg.isUnion(tg.isString, tg.isUndefined),
startLayerName: tg.isUnion(tg.isString, tg.isNull), startLayerName: tg.isUnion(tg.isString, tg.isNull),
tags: tg.isArray(tg.isString), tags: tg.isArray(tg.isString),
variables: tg.isObject,
}) })
.get(); .get();
/** /**

View file

@ -1,3 +1,4 @@
import * as tg from "generic-type-guard";
import type { GameStateEvent } from "./GameStateEvent"; import type { GameStateEvent } from "./GameStateEvent";
import type { ButtonClickedEvent } from "./ButtonClickedEvent"; import type { ButtonClickedEvent } from "./ButtonClickedEvent";
import type { ChatEvent } from "./ChatEvent"; import type { ChatEvent } from "./ChatEvent";
@ -9,7 +10,7 @@ import type { OpenCoWebSiteEvent } from "./OpenCoWebSiteEvent";
import type { OpenPopupEvent } from "./OpenPopupEvent"; import type { OpenPopupEvent } from "./OpenPopupEvent";
import type { OpenTabEvent } from "./OpenTabEvent"; import type { OpenTabEvent } from "./OpenTabEvent";
import type { UserInputChatEvent } from "./UserInputChatEvent"; import type { UserInputChatEvent } from "./UserInputChatEvent";
import type { DataLayerEvent } from "./DataLayerEvent"; import type { MapDataEvent } from "./MapDataEvent";
import type { LayerEvent } from "./LayerEvent"; import type { LayerEvent } from "./LayerEvent";
import type { SetPropertyEvent } from "./setPropertyEvent"; import type { SetPropertyEvent } from "./setPropertyEvent";
import type { LoadSoundEvent } from "./LoadSoundEvent"; import type { LoadSoundEvent } from "./LoadSoundEvent";
@ -18,6 +19,10 @@ import type { MenuItemClickedEvent } from "./ui/MenuItemClickedEvent";
import type { MenuItemRegisterEvent } from "./ui/MenuItemRegisterEvent"; import type { MenuItemRegisterEvent } from "./ui/MenuItemRegisterEvent";
import type { HasPlayerMovedEvent } from "./HasPlayerMovedEvent"; import type { HasPlayerMovedEvent } from "./HasPlayerMovedEvent";
import type { SetTilesEvent } from "./SetTilesEvent"; import type { SetTilesEvent } from "./SetTilesEvent";
import type { SetVariableEvent } from "./SetVariableEvent";
import {isGameStateEvent} from "./GameStateEvent";
import {isMapDataEvent} from "./MapDataEvent";
import {isSetVariableEvent} from "./SetVariableEvent";
export interface TypedMessageEvent<T> extends MessageEvent { export interface TypedMessageEvent<T> extends MessageEvent {
data: T; data: T;
@ -43,7 +48,6 @@ export type IframeEventMap = {
showLayer: LayerEvent; showLayer: LayerEvent;
hideLayer: LayerEvent; hideLayer: LayerEvent;
setProperty: SetPropertyEvent; setProperty: SetPropertyEvent;
getDataLayer: undefined;
loadSound: LoadSoundEvent; loadSound: LoadSoundEvent;
playSound: PlaySoundEvent; playSound: PlaySoundEvent;
stopSound: null; stopSound: null;
@ -66,8 +70,8 @@ export interface IframeResponseEventMap {
leaveEvent: EnterLeaveEvent; leaveEvent: EnterLeaveEvent;
buttonClickedEvent: ButtonClickedEvent; buttonClickedEvent: ButtonClickedEvent;
hasPlayerMoved: HasPlayerMovedEvent; hasPlayerMoved: HasPlayerMovedEvent;
dataLayer: DataLayerEvent;
menuItemClicked: MenuItemClickedEvent; menuItemClicked: MenuItemClickedEvent;
setVariable: SetVariableEvent;
} }
export interface IframeResponseEvent<T extends keyof IframeResponseEventMap> { export interface IframeResponseEvent<T extends keyof IframeResponseEventMap> {
type: T; type: T;
@ -81,13 +85,33 @@ export const isIframeResponseEventWrapper = (event: {
/** /**
* List event types sent from an iFrame to WorkAdventure that expect a unique answer from WorkAdventure along the type for the answer from WorkAdventure to the iFrame * List event types sent from an iFrame to WorkAdventure that expect a unique answer from WorkAdventure along the type for the answer from WorkAdventure to the iFrame.
* Types are defined using Type guards that will actually bused to enforce and check types.
*/ */
export type IframeQueryMap = { export const iframeQueryMapTypeGuards = {
getState: { getState: {
query: undefined, query: tg.isUndefined,
answer: GameStateEvent answer: isGameStateEvent,
}, },
getMapData: {
query: tg.isUndefined,
answer: isMapDataEvent,
},
setVariable: {
query: isSetVariableEvent,
answer: tg.isUndefined,
},
}
type GuardedType<T> = T extends (x: unknown) => x is (infer T) ? T : never;
type IframeQueryMapTypeGuardsType = typeof iframeQueryMapTypeGuards;
type UnknownToVoid<T> = undefined extends T ? void : T;
export type IframeQueryMap = {
[key in keyof IframeQueryMapTypeGuardsType]: {
query: GuardedType<IframeQueryMapTypeGuardsType[key]['query']>
answer: UnknownToVoid<GuardedType<IframeQueryMapTypeGuardsType[key]['answer']>>
}
} }
export interface IframeQuery<T extends keyof IframeQueryMap> { export interface IframeQuery<T extends keyof IframeQueryMap> {
@ -100,8 +124,21 @@ export interface IframeQueryWrapper<T extends keyof IframeQueryMap> {
query: IframeQuery<T>; query: IframeQuery<T>;
} }
export const isIframeQueryKey = (type: string): type is keyof IframeQueryMap => {
return type in iframeQueryMapTypeGuards;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
export const isIframeQuery = (event: any): event is IframeQuery<keyof IframeQueryMap> => typeof event.type === 'string'; export const isIframeQuery = (event: any): event is IframeQuery<keyof IframeQueryMap> => {
const type = event.type;
if (typeof type !== 'string') {
return false;
}
if (!isIframeQueryKey(type)) {
return false;
}
return iframeQueryMapTypeGuards[type].query(event.data);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
export const isIframeQueryWrapper = (event: any): event is IframeQueryWrapper<keyof IframeQueryMap> => typeof event.id === 'number' && isIframeQuery(event.query); export const isIframeQueryWrapper = (event: any): event is IframeQueryWrapper<keyof IframeQueryMap> => typeof event.id === 'number' && isIframeQuery(event.query);

View file

@ -1,6 +1,6 @@
import * as tg from "generic-type-guard"; import * as tg from "generic-type-guard";
export const isDataLayerEvent = new tg.IsInterface() export const isMapDataEvent = new tg.IsInterface()
.withProperties({ .withProperties({
data: tg.isObject, data: tg.isObject,
}) })
@ -9,4 +9,4 @@ export const isDataLayerEvent = new tg.IsInterface()
/** /**
* A message sent from the game to the iFrame when the data of the layers change after the iFrame send a message to the game that it want to listen to the data of the layers * A message sent from the game to the iFrame when the data of the layers change after the iFrame send a message to the game that it want to listen to the data of the layers
*/ */
export type DataLayerEvent = tg.GuardedType<typeof isDataLayerEvent>; export type MapDataEvent = tg.GuardedType<typeof isMapDataEvent>;

View file

@ -0,0 +1,18 @@
import * as tg from "generic-type-guard";
import {isMenuItemRegisterEvent} from "./ui/MenuItemRegisterEvent";
export const isSetVariableEvent =
new tg.IsInterface().withProperties({
key: tg.isString,
value: tg.isUnknown,
}).get();
/**
* A message sent from the iFrame to the game to change the value of the property of the layer
*/
export type SetVariableEvent = tg.GuardedType<typeof isSetVariableEvent>;
export const isSetVariableIframeEvent =
new tg.IsInterface().withProperties({
type: tg.isSingletonString("setVariable"),
data: isSetVariableEvent
}).get();

View file

@ -26,20 +26,24 @@ import { isLoadSoundEvent, LoadSoundEvent } from "./Events/LoadSoundEvent";
import { isSetPropertyEvent, SetPropertyEvent } from "./Events/setPropertyEvent"; import { isSetPropertyEvent, SetPropertyEvent } from "./Events/setPropertyEvent";
import { isLayerEvent, LayerEvent } from "./Events/LayerEvent"; import { isLayerEvent, LayerEvent } from "./Events/LayerEvent";
import { isMenuItemRegisterEvent } from "./Events/ui/MenuItemRegisterEvent"; import { isMenuItemRegisterEvent } from "./Events/ui/MenuItemRegisterEvent";
import type { DataLayerEvent } from "./Events/DataLayerEvent"; import type { MapDataEvent } from "./Events/MapDataEvent";
import type { GameStateEvent } from "./Events/GameStateEvent"; import type { GameStateEvent } from "./Events/GameStateEvent";
import type { HasPlayerMovedEvent } from "./Events/HasPlayerMovedEvent"; import type { HasPlayerMovedEvent } from "./Events/HasPlayerMovedEvent";
import { isLoadPageEvent } from "./Events/LoadPageEvent"; import { isLoadPageEvent } from "./Events/LoadPageEvent";
import { handleMenuItemRegistrationEvent, isMenuItemRegisterIframeEvent } from "./Events/ui/MenuItemRegisterEvent"; import { handleMenuItemRegistrationEvent, isMenuItemRegisterIframeEvent } from "./Events/ui/MenuItemRegisterEvent";
import { SetTilesEvent, isSetTilesEvent } from "./Events/SetTilesEvent"; import { SetTilesEvent, isSetTilesEvent } from "./Events/SetTilesEvent";
import type { SetVariableEvent } from "./Events/SetVariableEvent";
type AnswererCallback<T extends keyof IframeQueryMap> = (query: IframeQueryMap[T]['query']) => IframeQueryMap[T]['answer']|Promise<IframeQueryMap[T]['answer']>; type AnswererCallback<T extends keyof IframeQueryMap> = (query: IframeQueryMap[T]["query"], source: MessageEventSource | null) => IframeQueryMap[T]["answer"] | PromiseLike<IframeQueryMap[T]["answer"]>;
/** /**
* Listens to messages from iframes and turn those messages into easy to use observables. * Listens to messages from iframes and turn those messages into easy to use observables.
* Also allows to send messages to those iframes. * Also allows to send messages to those iframes.
*/ */
class IframeListener { class IframeListener {
private readonly _readyStream: Subject<HTMLIFrameElement> = new Subject();
public readonly readyStream = this._readyStream.asObservable();
private readonly _chatStream: Subject<ChatEvent> = new Subject(); private readonly _chatStream: Subject<ChatEvent> = new Subject();
public readonly chatStream = this._chatStream.asObservable(); public readonly chatStream = this._chatStream.asObservable();
@ -85,9 +89,6 @@ class IframeListener {
private readonly _setPropertyStream: Subject<SetPropertyEvent> = new Subject(); private readonly _setPropertyStream: Subject<SetPropertyEvent> = new Subject();
public readonly setPropertyStream = this._setPropertyStream.asObservable(); public readonly setPropertyStream = this._setPropertyStream.asObservable();
private readonly _dataLayerChangeStream: Subject<void> = new Subject();
public readonly dataLayerChangeStream = this._dataLayerChangeStream.asObservable();
private readonly _registerMenuCommandStream: Subject<string> = new Subject(); private readonly _registerMenuCommandStream: Subject<string> = new Subject();
public readonly registerMenuCommandStream = this._registerMenuCommandStream.asObservable(); public readonly registerMenuCommandStream = this._registerMenuCommandStream.asObservable();
@ -111,10 +112,13 @@ class IframeListener {
private readonly scripts = new Map<string, HTMLIFrameElement>(); private readonly scripts = new Map<string, HTMLIFrameElement>();
private sendPlayerMove: boolean = false; private sendPlayerMove: boolean = false;
// Note: we are forced to type this in unknown and later cast with "as" because of https://github.com/microsoft/TypeScript/issues/31904
private answerers: { private answerers: {
[key in keyof IframeQueryMap]?: AnswererCallback<key> [str in keyof IframeQueryMap]?: unknown
} = {}; } = {};
init() { init() {
window.addEventListener( window.addEventListener(
"message", "message",
@ -152,7 +156,7 @@ class IframeListener {
const queryId = payload.id; const queryId = payload.id;
const query = payload.query; const query = payload.query;
const answerer = this.answerers[query.type]; const answerer = this.answerers[query.type] as AnswererCallback<keyof IframeQueryMap> | undefined;
if (answerer === undefined) { if (answerer === undefined) {
const errorMsg = 'The iFrame sent a message of type "'+query.type+'" but there is no service configured to answer these messages.'; const errorMsg = 'The iFrame sent a message of type "'+query.type+'" but there is no service configured to answer these messages.';
console.error(errorMsg); console.error(errorMsg);
@ -164,19 +168,15 @@ class IframeListener {
return; return;
} }
Promise.resolve(answerer(query.data)).then((value) => { const errorHandler = (reason: unknown) => {
iframe?.contentWindow?.postMessage({
id: queryId,
type: query.type,
data: value
}, '*');
}).catch(reason => {
console.error('An error occurred while responding to an iFrame query.', reason); console.error('An error occurred while responding to an iFrame query.', reason);
let reasonMsg: string; let reasonMsg: string = '';
if (reason instanceof Error) { if (reason instanceof Error) {
reasonMsg = reason.message; reasonMsg = reason.message;
} else { } else if (typeof reason === 'object') {
reasonMsg = reason.toString(); reasonMsg = reason ? reason.toString() : '';
} else if (typeof reason === 'string') {
reasonMsg = reason;
} }
iframe?.contentWindow?.postMessage({ iframe?.contentWindow?.postMessage({
@ -184,77 +184,79 @@ class IframeListener {
type: query.type, type: query.type,
error: reasonMsg error: reasonMsg
} as IframeErrorAnswerEvent, '*'); } as IframeErrorAnswerEvent, '*');
}); };
try {
Promise.resolve(answerer(query.data, message.source)).then((value) => {
iframe?.contentWindow?.postMessage({
id: queryId,
type: query.type,
data: value
}, '*');
}).catch(errorHandler);
} catch (reason) {
errorHandler(reason);
}
} else if (isIframeEventWrapper(payload)) { } else if (isIframeEventWrapper(payload)) {
if (payload.type === "showLayer" && isLayerEvent(payload.data)) { if (payload.type === "showLayer" && isLayerEvent(payload.data)) {
this._showLayerStream.next(payload.data); this._showLayerStream.next(payload.data);
} else if (payload.type === "hideLayer" && isLayerEvent(payload.data)) { } else if (payload.type === "hideLayer" && isLayerEvent(payload.data)) {
this._hideLayerStream.next(payload.data); this._hideLayerStream.next(payload.data);
} else if (payload.type === "setProperty" && isSetPropertyEvent(payload.data)) { } else if (payload.type === "setProperty" && isSetPropertyEvent(payload.data)) {
this._setPropertyStream.next(payload.data); this._setPropertyStream.next(payload.data);
} else if (payload.type === "chat" && isChatEvent(payload.data)) { } else if (payload.type === "chat" && isChatEvent(payload.data)) {
this._chatStream.next(payload.data); this._chatStream.next(payload.data);
} else if (payload.type === "openPopup" && isOpenPopupEvent(payload.data)) { } else if (payload.type === "openPopup" && isOpenPopupEvent(payload.data)) {
this._openPopupStream.next(payload.data); this._openPopupStream.next(payload.data);
} else if (payload.type === "closePopup" && isClosePopupEvent(payload.data)) { } else if (payload.type === "closePopup" && isClosePopupEvent(payload.data)) {
this._closePopupStream.next(payload.data); this._closePopupStream.next(payload.data);
} else if (payload.type === "openTab" && isOpenTabEvent(payload.data)) { } else if (payload.type === "openTab" && isOpenTabEvent(payload.data)) {
scriptUtils.openTab(payload.data.url); scriptUtils.openTab(payload.data.url);
} else if (payload.type === "goToPage" && isGoToPageEvent(payload.data)) { } else if (payload.type === "goToPage" && isGoToPageEvent(payload.data)) {
scriptUtils.goToPage(payload.data.url); scriptUtils.goToPage(payload.data.url);
} else if (payload.type === "loadPage" && isLoadPageEvent(payload.data)) { } else if (payload.type === "loadPage" && isLoadPageEvent(payload.data)) {
this._loadPageStream.next(payload.data.url); this._loadPageStream.next(payload.data.url);
} else if (payload.type === "playSound" && isPlaySoundEvent(payload.data)) { } else if (payload.type === "playSound" && isPlaySoundEvent(payload.data)) {
this._playSoundStream.next(payload.data); this._playSoundStream.next(payload.data);
} else if (payload.type === "stopSound" && isStopSoundEvent(payload.data)) { } else if (payload.type === "stopSound" && isStopSoundEvent(payload.data)) {
this._stopSoundStream.next(payload.data); this._stopSoundStream.next(payload.data);
} else if (payload.type === "loadSound" && isLoadSoundEvent(payload.data)) { } else if (payload.type === "loadSound" && isLoadSoundEvent(payload.data)) {
this._loadSoundStream.next(payload.data); this._loadSoundStream.next(payload.data);
} else if (payload.type === "openCoWebSite" && isOpenCoWebsite(payload.data)) { } else if (payload.type === "openCoWebSite" && isOpenCoWebsite(payload.data)) {
scriptUtils.openCoWebsite( scriptUtils.openCoWebsite(
payload.data.url, payload.data.url,
foundSrc, foundSrc,
payload.data.allowApi, payload.data.allowApi,
payload.data.allowPolicy payload.data.allowPolicy
); );
} else if (payload.type === "closeCoWebSite") { } else if (payload.type === "closeCoWebSite") {
scriptUtils.closeCoWebSite(); scriptUtils.closeCoWebSite();
} else if (payload.type === "disablePlayerControls") { } else if (payload.type === "disablePlayerControls") {
this._disablePlayerControlStream.next(); this._disablePlayerControlStream.next();
} else if (payload.type === "restorePlayerControls") { } else if (payload.type === "restorePlayerControls") {
this._enablePlayerControlStream.next(); this._enablePlayerControlStream.next();
} else if (payload.type === "displayBubble") { } else if (payload.type === "displayBubble") {
this._displayBubbleStream.next(); this._displayBubbleStream.next();
} else if (payload.type === "removeBubble") { } else if (payload.type === "removeBubble") {
this._removeBubbleStream.next(); this._removeBubbleStream.next();
} else if (payload.type == "onPlayerMove") { } else if (payload.type == "onPlayerMove") {
this.sendPlayerMove = true; this.sendPlayerMove = true;
} else if (payload.type == "getDataLayer") { } else if (isMenuItemRegisterIframeEvent(payload)) {
this._dataLayerChangeStream.next(); const data = payload.data.menutItem;
} else if (isMenuItemRegisterIframeEvent(payload)) { // @ts-ignore
const data = payload.data.menutItem; this.iframeCloseCallbacks.get(iframe).push(() => {
// @ts-ignore this._unregisterMenuCommandStream.next(data);
this.iframeCloseCallbacks.get(iframe).push(() => { });
this._unregisterMenuCommandStream.next(data); handleMenuItemRegistrationEvent(payload.data);
}); } else if (payload.type == "setTiles" && isSetTilesEvent(payload.data)) {
handleMenuItemRegistrationEvent(payload.data); this._setTilesStream.next(payload.data);
} else if (payload.type == "setTiles" && isSetTilesEvent(payload.data)) {
this._setTilesStream.next(payload.data);
}
} }
}
}, },
false false
); );
} }
sendDataLayerEvent(dataLayerEvent: DataLayerEvent) {
this.postMessage({
type: "dataLayer",
data: dataLayerEvent,
});
}
/** /**
* Allows the passed iFrame to send/receive messages via the API. * Allows the passed iFrame to send/receive messages via the API.
*/ */
@ -394,6 +396,13 @@ class IframeListener {
}); });
} }
setVariable(setVariableEvent: SetVariableEvent) {
this.postMessage({
'type': 'setVariable',
'data': setVariableEvent
});
}
/** /**
* Sends the message... to all allowed iframes. * Sends the message... to all allowed iframes.
*/ */
@ -411,13 +420,28 @@ class IframeListener {
* @param key The "type" of the query we are answering * @param key The "type" of the query we are answering
* @param callback * @param callback
*/ */
public registerAnswerer<T extends keyof IframeQueryMap>(key: T, callback: (query: IframeQueryMap[T]['query']) => IframeQueryMap[T]['answer']|Promise<IframeQueryMap[T]['answer']> ): void { public registerAnswerer<T extends keyof IframeQueryMap>(key: T, callback: AnswererCallback<T> ): void {
this.answerers[key] = callback; this.answerers[key] = callback;
} }
public unregisterAnswerer(key: keyof IframeQueryMap): void { public unregisterAnswerer(key: keyof IframeQueryMap): void {
delete this.answerers[key]; delete this.answerers[key];
} }
dispatchVariableToOtherIframes(key: string, value: unknown, source: MessageEventSource | null) {
// Let's dispatch the message to the other iframes
for (const iframe of this.iframes) {
if (iframe.contentWindow !== source) {
iframe.contentWindow?.postMessage({
'type': 'setVariable',
'data': {
key,
value,
}
}, '*');
}
}
}
} }
export const iframeListener = new IframeListener(); export const iframeListener = new IframeListener();

View file

@ -2,17 +2,28 @@ import { IframeApiContribution, sendToWorkadventure } from "./IframeApiContribut
import type { HasPlayerMovedEvent, HasPlayerMovedEventCallback } from "../Events/HasPlayerMovedEvent"; import type { HasPlayerMovedEvent, HasPlayerMovedEventCallback } from "../Events/HasPlayerMovedEvent";
import { Subject } from "rxjs"; import { Subject } from "rxjs";
import { apiCallback } from "./registeredCallbacks"; import { apiCallback } from "./registeredCallbacks";
import { getGameState } from "./room";
import { isHasPlayerMovedEvent } from "../Events/HasPlayerMovedEvent"; import { isHasPlayerMovedEvent } from "../Events/HasPlayerMovedEvent";
interface User {
id: string | undefined;
nickName: string | null;
tags: string[];
}
const moveStream = new Subject<HasPlayerMovedEvent>(); const moveStream = new Subject<HasPlayerMovedEvent>();
let playerName: string | undefined;
export const setPlayerName = (name: string) => {
playerName = name;
};
let tags: string[] | undefined;
export const setTags = (_tags: string[]) => {
tags = _tags;
};
let uuid: string | undefined;
export const setUuid = (_uuid: string | undefined) => {
uuid = _uuid;
};
export class WorkadventurePlayerCommands extends IframeApiContribution<WorkadventurePlayerCommands> { export class WorkadventurePlayerCommands extends IframeApiContribution<WorkadventurePlayerCommands> {
callbacks = [ callbacks = [
apiCallback({ apiCallback({
@ -31,10 +42,30 @@ export class WorkadventurePlayerCommands extends IframeApiContribution<Workadven
data: null, data: null,
}); });
} }
getCurrentUser(): Promise<User> {
return getGameState().then((gameState) => { get name(): string {
return { id: gameState.uuid, nickName: gameState.nickname, tags: gameState.tags }; if (playerName === undefined) {
}); throw new Error(
"Player name not initialized yet. You should call WA.player.name within a WA.onInit callback."
);
}
return playerName;
}
get tags(): string[] {
if (tags === undefined) {
throw new Error("Tags not initialized yet. You should call WA.player.tags within a WA.onInit callback.");
}
return tags;
}
get id(): string | undefined {
// Note: this is not a type, we are checking if playerName is undefined because playerName cannot be undefined
// while uuid could.
if (playerName === undefined) {
throw new Error("Player id not initialized yet. You should call WA.player.id within a WA.onInit callback.");
}
return uuid;
} }
} }

View file

@ -1,28 +1,14 @@
import { Subject } from "rxjs"; import { Observable, Subject } from "rxjs";
import { isDataLayerEvent } from "../Events/DataLayerEvent";
import { EnterLeaveEvent, isEnterLeaveEvent } from "../Events/EnterLeaveEvent"; import { EnterLeaveEvent, isEnterLeaveEvent } from "../Events/EnterLeaveEvent";
import { isGameStateEvent } from "../Events/GameStateEvent";
import { IframeApiContribution, queryWorkadventure, sendToWorkadventure } from "./IframeApiContribution"; import { IframeApiContribution, queryWorkadventure, sendToWorkadventure } from "./IframeApiContribution";
import { apiCallback } from "./registeredCallbacks"; import { apiCallback } from "./registeredCallbacks";
import type { ITiledMap } from "../../Phaser/Map/ITiledMap"; import type { ITiledMap } from "../../Phaser/Map/ITiledMap";
import type { DataLayerEvent } from "../Events/DataLayerEvent";
import type { GameStateEvent } from "../Events/GameStateEvent";
const enterStreams: Map<string, Subject<EnterLeaveEvent>> = new Map<string, Subject<EnterLeaveEvent>>(); const enterStreams: Map<string, Subject<EnterLeaveEvent>> = new Map<string, Subject<EnterLeaveEvent>>();
const leaveStreams: Map<string, Subject<EnterLeaveEvent>> = new Map<string, Subject<EnterLeaveEvent>>(); const leaveStreams: Map<string, Subject<EnterLeaveEvent>> = new Map<string, Subject<EnterLeaveEvent>>();
const dataLayerResolver = new Subject<DataLayerEvent>();
let immutableDataPromise: Promise<GameStateEvent> | undefined = undefined;
interface Room {
id: string;
mapUrl: string;
map: ITiledMap;
startLayer: string | null;
}
interface TileDescriptor { interface TileDescriptor {
x: number; x: number;
@ -31,19 +17,17 @@ interface TileDescriptor {
layer: string; layer: string;
} }
export function getGameState(): Promise<GameStateEvent> { let roomId: string | undefined;
if (immutableDataPromise === undefined) {
immutableDataPromise = queryWorkadventure({ type: "getState", data: undefined });
}
return immutableDataPromise;
}
function getDataLayer(): Promise<DataLayerEvent> { export const setRoomId = (id: string) => {
return new Promise<DataLayerEvent>((resolver, thrower) => { roomId = id;
dataLayerResolver.subscribe(resolver); };
sendToWorkadventure({ type: "getDataLayer", data: null });
}); let mapURL: string | undefined;
}
export const setMapURL = (url: string) => {
mapURL = url;
};
export class WorkadventureRoomCommands extends IframeApiContribution<WorkadventureRoomCommands> { export class WorkadventureRoomCommands extends IframeApiContribution<WorkadventureRoomCommands> {
callbacks = [ callbacks = [
@ -61,13 +45,6 @@ export class WorkadventureRoomCommands extends IframeApiContribution<Workadventu
leaveStreams.get(payloadData.name)?.next(); leaveStreams.get(payloadData.name)?.next();
}, },
}), }),
apiCallback({
type: "dataLayer",
typeChecker: isDataLayerEvent,
callback: (payloadData) => {
dataLayerResolver.next(payloadData);
},
}),
]; ];
onEnterZone(name: string, callback: () => void): void { onEnterZone(name: string, callback: () => void): void {
@ -102,17 +79,9 @@ export class WorkadventureRoomCommands extends IframeApiContribution<Workadventu
}, },
}); });
} }
getCurrentRoom(): Promise<Room> { async getTiledMap(): Promise<ITiledMap> {
return getGameState().then((gameState) => { const event = await queryWorkadventure({ type: "getMapData", data: undefined });
return getDataLayer().then((mapJson) => { return event.data as ITiledMap;
return {
id: gameState.roomId,
map: mapJson.data as ITiledMap,
mapUrl: gameState.mapUrl,
startLayer: gameState.startLayerName,
};
});
});
} }
setTiles(tiles: TileDescriptor[]) { setTiles(tiles: TileDescriptor[]) {
sendToWorkadventure({ sendToWorkadventure({
@ -120,6 +89,22 @@ export class WorkadventureRoomCommands extends IframeApiContribution<Workadventu
data: tiles, data: tiles,
}); });
} }
get id(): string {
if (roomId === undefined) {
throw new Error("Room id not initialized yet. You should call WA.room.id within a WA.onInit callback.");
}
return roomId;
}
get mapURL(): string {
if (mapURL === undefined) {
throw new Error(
"mapURL is not initialized yet. You should call WA.room.mapURL within a WA.onInit callback."
);
}
return mapURL;
}
} }
export default new WorkadventureRoomCommands(); export default new WorkadventureRoomCommands();

View file

@ -0,0 +1,90 @@
import { Observable, Subject } from "rxjs";
import { EnterLeaveEvent, isEnterLeaveEvent } from "../Events/EnterLeaveEvent";
import { IframeApiContribution, queryWorkadventure, sendToWorkadventure } from "./IframeApiContribution";
import { apiCallback } from "./registeredCallbacks";
import { isSetVariableEvent, SetVariableEvent } from "../Events/SetVariableEvent";
import type { ITiledMap } from "../../Phaser/Map/ITiledMap";
const setVariableResolvers = new Subject<SetVariableEvent>();
const variables = new Map<string, unknown>();
const variableSubscribers = new Map<string, Subject<unknown>>();
export const initVariables = (_variables: Map<string, unknown>): void => {
for (const [name, value] of _variables.entries()) {
// In case the user already decided to put values in the variables (before onInit), let's make sure onInit does not override this.
if (!variables.has(name)) {
variables.set(name, value);
}
}
};
setVariableResolvers.subscribe((event) => {
const oldValue = variables.get(event.key);
// If we are setting the same value, no need to do anything.
// No need to do this check since it is already performed in SharedVariablesManager
/*if (JSON.stringify(oldValue) === JSON.stringify(event.value)) {
return;
}*/
variables.set(event.key, event.value);
const subject = variableSubscribers.get(event.key);
if (subject !== undefined) {
subject.next(event.value);
}
});
export class WorkadventureStateCommands extends IframeApiContribution<WorkadventureStateCommands> {
callbacks = [
apiCallback({
type: "setVariable",
typeChecker: isSetVariableEvent,
callback: (payloadData) => {
setVariableResolvers.next(payloadData);
},
}),
];
saveVariable(key: string, value: unknown): Promise<void> {
variables.set(key, value);
return queryWorkadventure({
type: "setVariable",
data: {
key,
value,
},
});
}
loadVariable(key: string): unknown {
return variables.get(key);
}
onVariableChange(key: string): Observable<unknown> {
let subject = variableSubscribers.get(key);
if (subject === undefined) {
subject = new Subject<unknown>();
variableSubscribers.set(key, subject);
}
return subject.asObservable();
}
}
const proxyCommand = new Proxy(new WorkadventureStateCommands(), {
get(target: WorkadventureStateCommands, p: PropertyKey, receiver: unknown): unknown {
if (p in target) {
return Reflect.get(target, p, receiver);
}
return target.loadVariable(p.toString());
},
set(target: WorkadventureStateCommands, p: PropertyKey, value: unknown, receiver: unknown): boolean {
// Note: when using "set", there is no way to wait, so we ignore the return of the promise.
// User must use WA.state.saveVariable to have error message.
target.saveVariable(p.toString(), value);
return true;
},
}) as WorkadventureStateCommands & { [key: string]: unknown };
export default proxyCommand;

View file

@ -27,6 +27,8 @@
import {gameOverlayVisibilityStore} from "../Stores/GameOverlayStoreVisibility"; import {gameOverlayVisibilityStore} from "../Stores/GameOverlayStoreVisibility";
import {consoleGlobalMessageManagerVisibleStore} from "../Stores/ConsoleGlobalMessageManagerStore"; import {consoleGlobalMessageManagerVisibleStore} from "../Stores/ConsoleGlobalMessageManagerStore";
import ConsoleGlobalMessageManager from "./ConsoleGlobalMessageManager/ConsoleGlobalMessageManager.svelte"; import ConsoleGlobalMessageManager from "./ConsoleGlobalMessageManager/ConsoleGlobalMessageManager.svelte";
import {warningContainerStore} from "../Stores/MenuStore";
import WarningContainer from "./WarningContainer/WarningContainer.svelte";
export let game: Game; export let game: Game;
@ -91,4 +93,7 @@
{#if $chatVisibilityStore} {#if $chatVisibilityStore}
<Chat></Chat> <Chat></Chat>
{/if} {/if}
{#if $warningContainerStore}
<WarningContainer></WarningContainer>
{/if}
</div> </div>

View file

@ -1,12 +1,27 @@
<script lang="typescript"> <script lang="typescript">
import { fly } from 'svelte/transition';
import InputTextGlobalMessage from "./InputTextGlobalMessage.svelte"; import InputTextGlobalMessage from "./InputTextGlobalMessage.svelte";
import UploadAudioGlobalMessage from "./UploadAudioGlobalMessage.svelte"; import UploadAudioGlobalMessage from "./UploadAudioGlobalMessage.svelte";
import {gameManager} from "../../Phaser/Game/GameManager"; import { gameManager } from "../../Phaser/Game/GameManager";
import type {Game} from "../../Phaser/Game/Game"; import type { Game } from "../../Phaser/Game/Game";
import { consoleGlobalMessageManagerVisibleStore } from "../../Stores/ConsoleGlobalMessageManagerStore";
export let game: Game; export let game: Game;
let inputSendTextActive = true; let inputSendTextActive = true;
let uploadMusicActive = false; let uploadMusicActive = false;
let handleSendText: { sendTextMessage(): void };
let handleSendAudio: { sendAudioMessage(): Promise<void> };
let broadcastToWorld = false;
function closeConsoleGlobalMessage() {
consoleGlobalMessageManagerVisibleStore.set(false)
}
function onKeyDown(e:KeyboardEvent) {
if (e.key === 'Escape') {
closeConsoleGlobalMessage();
}
}
function inputSendTextActivate() { function inputSendTextActivate() {
inputSendTextActive = true; inputSendTextActive = true;
@ -17,28 +32,121 @@
uploadMusicActive = true; uploadMusicActive = true;
inputSendTextActive = false; inputSendTextActive = false;
} }
function send() {
if (inputSendTextActive) {
handleSendText.sendTextMessage();
}
if (uploadMusicActive) {
handleSendAudio.sendAudioMessage();
}
}
</script> </script>
<svelte:window on:keydown={onKeyDown}/>
<div class="main-console nes-container is-rounded"> <div class="console-global-message">
<!-- <div class="console nes-container is-rounded"> <div class="menu-console-global-message nes-container is-rounded" transition:fly="{{ x: -1000, duration: 500 }}">
<img class="btn-close" src="resources/logos/send-yellow.svg" alt="Close"> <button type="button" class="nes-btn {inputSendTextActive ? 'is-disabled' : ''}" on:click|preventDefault={inputSendTextActivate}>Message</button>
</div>--> <button type="button" class="nes-btn {uploadMusicActive ? 'is-disabled' : ''}" on:click|preventDefault={inputUploadMusicActivate}>Audio</button>
<div class="main-global-message"> </div>
<h2> Global Message </h2> <div class="main-console-global-message nes-container is-rounded" transition:fly="{{ y: -1000, duration: 500 }}">
<div class="global-message"> <div class="title-console-global-message">
<div class="menu"> <h2>Global Message</h2>
<button class="nes-btn {inputSendTextActive ? 'is-disabled' : ''}" on:click|preventDefault={inputSendTextActivate}>Message</button> <button type="button" class="nes-btn is-error" on:click|preventDefault={closeConsoleGlobalMessage}><i class="nes-icon close is-small"></i></button>
<button class="nes-btn {uploadMusicActive ? 'is-disabled' : ''}" on:click|preventDefault={inputUploadMusicActivate}>Audio</button> </div>
</div> <div class="content-console-global-message">
<div class="main-input"> {#if inputSendTextActive}
{#if inputSendTextActive} <InputTextGlobalMessage game={game} gameManager={gameManager} bind:handleSending={handleSendText}/>
<InputTextGlobalMessage game={game} gameManager={gameManager}></InputTextGlobalMessage> {/if}
{/if} {#if uploadMusicActive}
{#if uploadMusicActive} <UploadAudioGlobalMessage game={game} gameManager={gameManager} bind:handleSending={handleSendAudio}/>
<UploadAudioGlobalMessage game={game} gameManager={gameManager}></UploadAudioGlobalMessage> {/if}
{/if} </div>
</div> <div class="footer-console-global-message">
<label>
<input type="checkbox" class="nes-checkbox is-dark nes-pointer" bind:checked={broadcastToWorld}>
<span>Broadcast to all rooms of the world</span>
</label>
<button class="nes-btn is-primary" on:click|preventDefault={send}>Send</button>
</div> </div>
</div> </div>
</div> </div>
<style lang="scss">
.nes-container {
padding: 0 5px;
}
div.console-global-message {
top: 20vh;
width: 50vw;
height: 50vh;
position: relative;
display: flex;
flex-direction: row;
margin-left: auto;
margin-right: auto;
padding: 0;
pointer-events: auto;
div.menu-console-global-message {
flex: 1 1 auto;
max-width: 180px;
text-align: center;
background-color: #333333;
button {
width: 136px;
margin-bottom: 10px;
}
}
div.main-console-global-message {
flex: 1 1 auto;
display: flex;
flex-direction: column;
background-color: #333333;
div.title-console-global-message {
flex: 0 0 auto;
height: 50px;
margin-bottom: 10px;
text-align: center;
color: whitesmoke;
.nes-btn {
position: absolute;
top: 0;
right: 0;
}
}
div.content-console-global-message {
flex: 1 1 auto;
max-height: calc(100% - 120px);
}
div.footer-console-global-message {
height: 50px;
margin-top: 10px;
text-align: center;
label {
margin: 0;
position: absolute;
left: 0;
max-width: 30%;
}
}
}
}
</style>

View file

@ -1,15 +1,14 @@
<script lang="ts"> <script lang="ts">
import {consoleGlobalMessageManagerFocusStore, consoleGlobalMessageManagerVisibleStore } from "../../Stores/ConsoleGlobalMessageManagerStore"; import { consoleGlobalMessageManagerFocusStore, consoleGlobalMessageManagerVisibleStore } from "../../Stores/ConsoleGlobalMessageManagerStore";
import {onMount} from "svelte"; import { onMount } from "svelte";
import type {Game} from "../../Phaser/Game/Game"; import type { Game } from "../../Phaser/Game/Game";
import type {GameManager} from "../../Phaser/Game/GameManager"; import type { GameManager } from "../../Phaser/Game/GameManager";
import type {PlayGlobalMessageInterface} from "../../Connexion/ConnexionModels"; import type { PlayGlobalMessageInterface } from "../../Connexion/ConnexionModels";
import {AdminMessageEventTypes} from "../../Connexion/AdminMessagesService"; import { AdminMessageEventTypes } from "../../Connexion/AdminMessagesService";
import type {Quill} from "quill"; import type { Quill } from "quill";
import {LoginSceneName} from "../../Phaser/Login/LoginScene";
//toolbar //toolbar
export const toolbarOptions = [ const toolbarOptions = [
['bold', 'italic', 'underline', 'strike'], // toggled buttons ['bold', 'italic', 'underline', 'strike'], // toggled buttons
['blockquote', 'code-block'], ['blockquote', 'code-block'],
@ -35,12 +34,31 @@
export let game: Game; export let game: Game;
export let gameManager: GameManager; export let gameManager: GameManager;
let gameScene = gameManager.getCurrentGameScene(game.scene.getScene(LoginSceneName)); let gameScene = gameManager.getCurrentGameScene(game.findAnyScene());
let quill: Quill; let quill: Quill;
let INPUT_CONSOLE_MESSAGE: HTMLDivElement; let INPUT_CONSOLE_MESSAGE: HTMLDivElement;
const MESSAGE_TYPE = AdminMessageEventTypes.admin; const MESSAGE_TYPE = AdminMessageEventTypes.admin;
export const handleSending = {
sendTextMessage() {
if (gameScene == undefined) {
return;
}
const text = quill.getText(0, quill.getLength());
const GlobalMessage: PlayGlobalMessageInterface = {
id: "1", // FIXME: use another ID?
message: text,
type: MESSAGE_TYPE
};
quill.deleteText(0, quill.getLength());
gameScene.connection?.emitGlobalMessage(GlobalMessage);
disableConsole();
}
}
//Quill //Quill
onMount(async () => { onMount(async () => {
@ -48,6 +66,7 @@
const {default: Quill} = await import("quill"); // eslint-disable-line @typescript-eslint/no-explicit-any const {default: Quill} = await import("quill"); // eslint-disable-line @typescript-eslint/no-explicit-any
quill = new Quill(INPUT_CONSOLE_MESSAGE, { quill = new Quill(INPUT_CONSOLE_MESSAGE, {
placeholder: 'Enter your message here...',
theme: 'snow', theme: 'snow',
modules: { modules: {
toolbar: toolbarOptions toolbar: toolbarOptions
@ -66,31 +85,10 @@
consoleGlobalMessageManagerVisibleStore.set(false); consoleGlobalMessageManagerVisibleStore.set(false);
consoleGlobalMessageManagerFocusStore.set(false); consoleGlobalMessageManagerFocusStore.set(false);
} }
function SendTextMessage() {
if (gameScene == undefined) {
return;
}
const text = quill.getText(0, quill.getLength());
const GlobalMessage: PlayGlobalMessageInterface = {
id: "1", // FIXME: use another ID?
message: text,
type: MESSAGE_TYPE
};
quill.deleteText(0, quill.getLength());
gameScene.connection?.emitGlobalMessage(GlobalMessage);
disableConsole();
}
</script> </script>
<section class="section-input-send-text"> <section class="section-input-send-text">
<div class="input-send-text" bind:this={INPUT_CONSOLE_MESSAGE}></div> <div class="input-send-text" bind:this={INPUT_CONSOLE_MESSAGE}></div>
<div class="btn-action">
<button class="nes-btn is-primary" on:click|preventDefault={SendTextMessage}>Send</button>
</div>
</section> </section>

View file

@ -1,12 +1,11 @@
<script lang="ts"> <script lang="ts">
import {HtmlUtils} from "../../WebRtc/HtmlUtils"; import { HtmlUtils } from "../../WebRtc/HtmlUtils";
import type {Game} from "../../Phaser/Game/Game"; import type { Game } from "../../Phaser/Game/Game";
import type {GameManager} from "../../Phaser/Game/GameManager"; import type { GameManager } from "../../Phaser/Game/GameManager";
import {consoleGlobalMessageManagerFocusStore, consoleGlobalMessageManagerVisibleStore} from "../../Stores/ConsoleGlobalMessageManagerStore"; import { consoleGlobalMessageManagerFocusStore, consoleGlobalMessageManagerVisibleStore } from "../../Stores/ConsoleGlobalMessageManagerStore";
import {AdminMessageEventTypes} from "../../Connexion/AdminMessagesService"; import { AdminMessageEventTypes } from "../../Connexion/AdminMessagesService";
import type {PlayGlobalMessageInterface} from "../../Connexion/ConnexionModels"; import type { PlayGlobalMessageInterface } from "../../Connexion/ConnexionModels";
import uploadFile from "../images/music-file.svg"; import uploadFile from "../images/music-file.svg";
import {LoginSceneName} from "../../Phaser/Login/LoginScene";
interface EventTargetFiles extends EventTarget { interface EventTargetFiles extends EventTarget {
files: Array<File>; files: Array<File>;
@ -15,38 +14,39 @@
export let game: Game; export let game: Game;
export let gameManager: GameManager; export let gameManager: GameManager;
let gameScene = gameManager.getCurrentGameScene(game.scene.getScene(LoginSceneName)); let gameScene = gameManager.getCurrentGameScene(game.findAnyScene());
let fileinput: HTMLInputElement; let fileInput: HTMLInputElement;
let filename: string; let fileName: string;
let filesize: string; let fileSize: string;
let errorfile: boolean; let errorFile: boolean;
const AUDIO_TYPE = AdminMessageEventTypes.audio; const AUDIO_TYPE = AdminMessageEventTypes.audio;
export const handleSending = {
async sendAudioMessage() {
if (gameScene == undefined) {
return;
}
const inputAudio = HtmlUtils.getElementByIdOrFail<HTMLInputElement>("input-send-audio");
const selectedFile = inputAudio.files ? inputAudio.files[0] : null;
if (!selectedFile) {
errorFile = true;
throw 'no file selected';
}
async function SendAudioMessage() { const fd = new FormData();
if (gameScene == undefined) { fd.append('file', selectedFile);
return; const res = await gameScene.connection?.uploadAudio(fd);
}
const inputAudio = HtmlUtils.getElementByIdOrFail<HTMLInputElement>("input-send-audio");
const selectedFile = inputAudio.files ? inputAudio.files[0] : null;
if (!selectedFile) {
errorfile = true;
throw 'no file selected';
}
const fd = new FormData(); const GlobalMessage: PlayGlobalMessageInterface = {
fd.append('file', selectedFile); id: (res as { id: string }).id,
const res = await gameScene.connection?.uploadAudio(fd); message: (res as { path: string }).path,
type: AUDIO_TYPE
const GlobalMessage: PlayGlobalMessageInterface = { }
id: (res as { id: string }).id, inputAudio.value = '';
message: (res as { path: string }).path, gameScene.connection?.emitGlobalMessage(GlobalMessage);
type: AUDIO_TYPE disableConsole();
} }
inputAudio.value = '';
gameScene.connection?.emitGlobalMessage(GlobalMessage);
disableConsole();
} }
function inputAudioFile(event: Event) { function inputAudioFile(event: Event) {
@ -60,9 +60,9 @@
return; return;
} }
filename = file.name; fileName = file.name;
filesize = getFileSize(file.size); fileSize = getFileSize(file.size);
errorfile = false; errorFile = false;
} }
function getFileSize(number: number) { function getFileSize(number: number) {
@ -85,46 +85,46 @@
<section class="section-input-send-audio"> <section class="section-input-send-audio">
<div class="input-send-audio"> <img class="nes-pointer" src="{uploadFile}" alt="Upload a file" on:click|preventDefault={ () => {fileInput.click();}}>
<img src="{uploadFile}" alt="Upload a file" on:click|preventDefault={ () => {fileinput.click();}}> {#if fileName !== undefined}
{#if filename != undefined} <p>{fileName} : {fileSize}</p>
<label for="input-send-audio">{filename} : {filesize}</label> {/if}
{/if} {#if errorFile}
{#if errorfile} <p class="err">No file selected. You need to upload a file before sending it.</p>
<p class="err">No file selected. You need to upload a file before sending it.</p> {/if}
{/if} <input type="file" id="input-send-audio" bind:this={fileInput} on:change={(e) => {inputAudioFile(e)}}>
<input type="file" id="input-send-audio" bind:this={fileinput} on:change={(e) => {inputAudioFile(e)}}>
</div>
<div class="btn-action">
<button class="nes-btn is-primary" on:click|preventDefault={SendAudioMessage}>Send</button>
</div>
</section> </section>
<style lang="scss"> <style lang="scss">
//UploadAudioGlobalMessage section.section-input-send-audio {
.section-input-send-audio { display: flex;
margin: 10px; flex-direction: column;
}
.section-input-send-audio .input-send-audio { height: 100%;
text-align: center; text-align: center;
}
.section-input-send-audio #input-send-audio{ img {
display: none; flex: 1 1 auto;
}
.section-input-send-audio div.input-send-audio label{ max-height: 80%;
color: white; margin-bottom: 20px;
} }
.section-input-send-audio div.input-send-audio p.err { p {
color: #ce372b; flex: 1 1 auto;
text-align: center;
}
.section-input-send-audio div.input-send-audio img{ margin-bottom: 5px;
height: 150px;
cursor: url('../../../style/images/cursor_pointer.png'), pointer; color: whitesmoke;
font-size: 1rem;
&.err {
color: #ce372b;
}
}
input {
display: none;
}
} }
</style> </style>

View file

@ -0,0 +1,37 @@
<script lang="typescript">
import { fly } from 'svelte/transition';
import {userIsAdminStore} from "../../Stores/GameStore";
import {ADMIN_URL} from "../../Enum/EnvironmentVariable";
const upgradeLink = ADMIN_URL+'/pricing';
</script>
<main class="warningMain" transition:fly="{{ y: -200, duration: 500 }}">
<h2>Warning!</h2>
{#if $userIsAdminStore}
<p>This world is close to its limit!. You can upgrade its capacity <a href="{upgradeLink}" target="_blank">here</a></p>
{:else}
<p>This world is close to its limit!</p>
{/if}
</main>
<style lang="scss">
main.warningMain {
pointer-events: auto;
width: 100vw;
background-color: red;
text-align: center;
position: absolute;
left: 50%;
transform: translate(-50%, 0);
font-family: Lato;
min-width: 300px;
opacity: 0.9;
z-index: 2;
h2 {
padding: 5px;
}
}
</style>

View file

@ -1,146 +1,221 @@
import Axios from "axios"; import Axios from "axios";
import {PUSHER_URL, START_ROOM_URL} from "../Enum/EnvironmentVariable"; import { PUSHER_URL, START_ROOM_URL } from "../Enum/EnvironmentVariable";
import {RoomConnection} from "./RoomConnection"; import { RoomConnection } from "./RoomConnection";
import type {OnConnectInterface, PositionInterface, ViewportInterface} from "./ConnexionModels"; import type { OnConnectInterface, PositionInterface, ViewportInterface } from "./ConnexionModels";
import {GameConnexionTypes, urlManager} from "../Url/UrlManager"; import { GameConnexionTypes, urlManager } from "../Url/UrlManager";
import {localUserStore} from "./LocalUserStore"; import { localUserStore } from "./LocalUserStore";
import {CharacterTexture, LocalUser} from "./LocalUser"; import { CharacterTexture, LocalUser } from "./LocalUser";
import {Room} from "./Room"; import { Room } from "./Room";
import { _ServiceWorker } from "../Network/ServiceWorker";
class ConnectionManager { class ConnectionManager {
private localUser!:LocalUser; private localUser!: LocalUser;
private connexionType?: GameConnexionTypes private connexionType?: GameConnexionTypes;
private reconnectingTimeout: NodeJS.Timeout|null = null; private reconnectingTimeout: NodeJS.Timeout | null = null;
private _unloading:boolean = false; private _unloading: boolean = false;
private authToken: string | null = null;
get unloading () { private serviceWorker?: _ServiceWorker;
get unloading() {
return this._unloading; return this._unloading;
} }
constructor() { constructor() {
window.addEventListener('beforeunload', () => { window.addEventListener("beforeunload", () => {
this._unloading = true; this._unloading = true;
if (this.reconnectingTimeout) clearTimeout(this.reconnectingTimeout) if (this.reconnectingTimeout) clearTimeout(this.reconnectingTimeout);
}) });
} }
public loadOpenIDScreen() {
localUserStore.setAuthToken(null);
const state = localUserStore.generateState();
const nonce = localUserStore.generateNonce();
window.location.assign(`http://${PUSHER_URL}/login-screen?state=${state}&nonce=${nonce}`);
}
public logout() {
localUserStore.setAuthToken(null);
window.location.reload();
}
/** /**
* Tries to login to the node server and return the starting map url to be loaded * Tries to login to the node server and return the starting map url to be loaded
*/ */
public async initGameConnexion(): Promise<Room> { public async initGameConnexion(): Promise<Room> {
const connexionType = urlManager.getGameConnexionType(); const connexionType = urlManager.getGameConnexionType();
this.connexionType = connexionType; this.connexionType = connexionType;
if(connexionType === GameConnexionTypes.register) { let room: Room | null = null;
const organizationMemberToken = urlManager.getOrganizationToken(); if (connexionType === GameConnexionTypes.jwt) {
const data = await Axios.post(`${PUSHER_URL}/register`, {organizationMemberToken}).then(res => res.data); const urlParams = new URLSearchParams(window.location.search);
this.localUser = new LocalUser(data.userUuid, data.authToken, data.textures); const code = urlParams.get("code");
const state = urlParams.get("state");
if (!state || !localUserStore.verifyState(state)) {
throw "Could not validate state!";
}
if (!code) {
throw "No Auth code provided";
}
const nonce = localUserStore.getNonce();
const { authToken } = await Axios.get(`${PUSHER_URL}/login-callback`, { params: { code, nonce } }).then(
(res) => res.data
);
localUserStore.setAuthToken(authToken);
this.authToken = authToken;
room = await Room.createRoom(
new URL(localUserStore.getLastRoomUrl())
);
urlManager.pushRoomIdToUrl(room);
} else if (connexionType === GameConnexionTypes.register) {
//@deprecated
const organizationMemberToken = urlManager.getOrganizationToken();
const data = await Axios.post(`${PUSHER_URL}/register`, { organizationMemberToken }).then(
(res) => res.data
);
this.localUser = new LocalUser(data.userUuid, data.textures);
this.authToken = data.authToken;
localUserStore.saveUser(this.localUser); localUserStore.saveUser(this.localUser);
localUserStore.setAuthToken(this.authToken);
const roomUrl = data.roomUrl; const roomUrl = data.roomUrl;
const room = await Room.createRoom(new URL(window.location.protocol + '//' + window.location.host + roomUrl + window.location.search + window.location.hash)); room = await Room.createRoom(
new URL(
window.location.protocol +
"//" +
window.location.host +
roomUrl +
window.location.search +
window.location.hash
)
);
urlManager.pushRoomIdToUrl(room); urlManager.pushRoomIdToUrl(room);
return Promise.resolve(room); } else if (
} else if (connexionType === GameConnexionTypes.organization || connexionType === GameConnexionTypes.anonymous || connexionType === GameConnexionTypes.empty) { connexionType === GameConnexionTypes.organization ||
connexionType === GameConnexionTypes.anonymous ||
let localUser = localUserStore.getLocalUser(); connexionType === GameConnexionTypes.empty
if (localUser && localUser.jwtToken && localUser.uuid && localUser.textures) { ) {
this.localUser = localUser; this.authToken = localUserStore.getAuthToken();
try { //todo: add here some kind of warning if authToken has expired.
await this.verifyToken(localUser.jwtToken); if (!this.authToken) {
} catch(e) {
// If the token is invalid, let's generate an anonymous one.
console.error('JWT token invalid. Did it expire? Login anonymously instead.');
await this.anonymousLogin();
}
}else{
await this.anonymousLogin(); await this.anonymousLogin();
} }
this.localUser = localUserStore.getLocalUser() as LocalUser; //if authToken exist in localStorage then localUser cannot be null
localUser = localUserStore.getLocalUser();
if(!localUser){
throw "Error to store local user data";
}
let roomPath: string; let roomPath: string;
if (connexionType === GameConnexionTypes.empty) { if (connexionType === GameConnexionTypes.empty) {
roomPath = window.location.protocol + '//' + window.location.host + START_ROOM_URL; roomPath = window.location.protocol + "//" + window.location.host + START_ROOM_URL;
} else { } else {
roomPath = window.location.protocol + '//' + window.location.host + window.location.pathname + window.location.search + window.location.hash; roomPath =
window.location.protocol +
"//" +
window.location.host +
window.location.pathname +
window.location.search +
window.location.hash;
} }
//get detail map for anonymous login and set texture in local storage //get detail map for anonymous login and set texture in local storage
const room = await Room.createRoom(new URL(roomPath)); room = await Room.createRoom(new URL(roomPath));
if(room.textures != undefined && room.textures.length > 0) { if (room.textures != undefined && room.textures.length > 0) {
//check if texture was changed //check if texture was changed
if(localUser.textures.length === 0){ if (this.localUser.textures.length === 0) {
localUser.textures = room.textures; this.localUser.textures = room.textures;
}else{ } else {
room.textures.forEach((newTexture) => { room.textures.forEach((newTexture) => {
const alreadyExistTexture = localUser?.textures.find((c) => newTexture.id === c.id); const alreadyExistTexture = this.localUser.textures.find((c) => newTexture.id === c.id);
if(localUser?.textures.findIndex((c) => newTexture.id === c.id) !== -1){ if (this.localUser.textures.findIndex((c) => newTexture.id === c.id) !== -1) {
return; return;
} }
localUser?.textures.push(newTexture) this.localUser.textures.push(newTexture);
}); });
} }
this.localUser = localUser; localUserStore.saveUser(this.localUser);
localUserStore.saveUser(localUser);
} }
return Promise.resolve(room); }
if (room == undefined) {
return Promise.reject(new Error("Invalid URL"));
} }
return Promise.reject(new Error('Invalid URL')); this.serviceWorker = new _ServiceWorker();
} return Promise.resolve(room);
private async verifyToken(token: string): Promise<void> {
await Axios.get(`${PUSHER_URL}/verify`, {params: {token}});
} }
public async anonymousLogin(isBenchmark: boolean = false): Promise<void> { public async anonymousLogin(isBenchmark: boolean = false): Promise<void> {
const data = await Axios.post(`${PUSHER_URL}/anonymLogin`).then(res => res.data); const data = await Axios.post(`${PUSHER_URL}/anonymLogin`).then((res) => res.data);
this.localUser = new LocalUser(data.userUuid, data.authToken, []); this.localUser = new LocalUser(data.userUuid, []);
if (!isBenchmark) { // In benchmark, we don't have a local storage. this.authToken = data.authToken;
if (!isBenchmark) {
// In benchmark, we don't have a local storage.
localUserStore.saveUser(this.localUser); localUserStore.saveUser(this.localUser);
localUserStore.setAuthToken(this.authToken);
} }
} }
public initBenchmark(): void { public initBenchmark(): void {
this.localUser = new LocalUser('', 'test', []); this.localUser = new LocalUser("", []);
} }
public connectToRoomSocket(roomUrl: string, name: string, characterLayers: string[], position: PositionInterface, viewport: ViewportInterface, companion: string|null): Promise<OnConnectInterface> { public connectToRoomSocket(
roomUrl: string,
name: string,
characterLayers: string[],
position: PositionInterface,
viewport: ViewportInterface,
companion: string | null
): Promise<OnConnectInterface> {
return new Promise<OnConnectInterface>((resolve, reject) => { return new Promise<OnConnectInterface>((resolve, reject) => {
const connection = new RoomConnection(this.localUser.jwtToken, roomUrl, name, characterLayers, position, viewport, companion); const connection = new RoomConnection(
this.authToken,
roomUrl,
name,
characterLayers,
position,
viewport,
companion
);
connection.onConnectError((error: object) => { connection.onConnectError((error: object) => {
console.log('An error occurred while connecting to socket server. Retrying'); console.log("An error occurred while connecting to socket server. Retrying");
reject(error); reject(error);
}); });
connection.onConnectingError((event: CloseEvent) => { connection.onConnectingError((event: CloseEvent) => {
console.log('An error occurred while connecting to socket server. Retrying'); console.log("An error occurred while connecting to socket server. Retrying");
reject(new Error('An error occurred while connecting to socket server. Retrying. Code: '+event.code+', Reason: '+event.reason)); reject(
new Error(
"An error occurred while connecting to socket server. Retrying. Code: " +
event.code +
", Reason: " +
event.reason
)
);
}); });
connection.onConnect((connect: OnConnectInterface) => { connection.onConnect((connect: OnConnectInterface) => {
//save last room url connected
localUserStore.setLastRoomUrl(roomUrl);
resolve(connect); resolve(connect);
}); });
}).catch((err) => { }).catch((err) => {
// Let's retry in 4-6 seconds // Let's retry in 4-6 seconds
return new Promise<OnConnectInterface>((resolve, reject) => { return new Promise<OnConnectInterface>((resolve, reject) => {
this.reconnectingTimeout = setTimeout(() => { this.reconnectingTimeout = setTimeout(() => {
//todo: allow a way to break recursion? //todo: allow a way to break recursion?
//todo: find a way to avoid recursive function. Otherwise, the call stack will grow indefinitely. //todo: find a way to avoid recursive function. Otherwise, the call stack will grow indefinitely.
this.connectToRoomSocket(roomUrl, name, characterLayers, position, viewport, companion).then((connection) => resolve(connection)); this.connectToRoomSocket(roomUrl, name, characterLayers, position, viewport, companion).then(
}, 4000 + Math.floor(Math.random() * 2000) ); (connection) => resolve(connection)
);
}, 4000 + Math.floor(Math.random() * 2000));
}); });
}); });
} }
get getConnexionType(){ get getConnexionType() {
return this.connexionType; return this.connexionType;
} }
} }

View file

@ -31,6 +31,7 @@ export enum EventMessage {
TELEPORT = "teleport", TELEPORT = "teleport",
USER_MESSAGE = "user-message", USER_MESSAGE = "user-message",
START_JITSI_ROOM = "start-jitsi-room", START_JITSI_ROOM = "start-jitsi-room",
SET_VARIABLE = "set-variable",
} }
export interface PointInterface { export interface PointInterface {
@ -105,6 +106,7 @@ export interface RoomJoinedMessageInterface {
//users: MessageUserPositionInterface[], //users: MessageUserPositionInterface[],
//groups: GroupCreatedUpdatedMessageInterface[], //groups: GroupCreatedUpdatedMessageInterface[],
items: { [itemId: number]: unknown }; items: { [itemId: number]: unknown };
variables: Map<string, unknown>;
} }
export interface PlayGlobalMessageInterface { export interface PlayGlobalMessageInterface {

View file

@ -1,10 +1,10 @@
import {MAX_USERNAME_LENGTH} from "../Enum/EnvironmentVariable"; import { MAX_USERNAME_LENGTH } from "../Enum/EnvironmentVariable";
export interface CharacterTexture { export interface CharacterTexture {
id: number, id: number;
level: number, level: number;
url: string, url: string;
rights: string rights: string;
} }
export const maxUserNameLength: number = MAX_USERNAME_LENGTH; export const maxUserNameLength: number = MAX_USERNAME_LENGTH;
@ -24,6 +24,5 @@ export function areCharacterLayersValid(value: string[] | null): boolean {
} }
export class LocalUser { export class LocalUser {
constructor(public readonly uuid:string, public readonly jwtToken: string, public textures: CharacterTexture[]) { constructor(public readonly uuid: string, public textures: CharacterTexture[]) {}
}
} }

View file

@ -1,60 +1,65 @@
import {areCharacterLayersValid, isUserNameValid, LocalUser} from "./LocalUser"; import { areCharacterLayersValid, isUserNameValid, LocalUser } from "./LocalUser";
import { v4 as uuidv4 } from "uuid";
const playerNameKey = 'playerName'; const playerNameKey = "playerName";
const selectedPlayerKey = 'selectedPlayer'; const selectedPlayerKey = "selectedPlayer";
const customCursorPositionKey = 'customCursorPosition'; const customCursorPositionKey = "customCursorPosition";
const characterLayersKey = 'characterLayers'; const characterLayersKey = "characterLayers";
const companionKey = 'companion'; const companionKey = "companion";
const gameQualityKey = 'gameQuality'; const gameQualityKey = "gameQuality";
const videoQualityKey = 'videoQuality'; const videoQualityKey = "videoQuality";
const audioPlayerVolumeKey = 'audioVolume'; const audioPlayerVolumeKey = "audioVolume";
const audioPlayerMuteKey = 'audioMute'; const audioPlayerMuteKey = "audioMute";
const helpCameraSettingsShown = 'helpCameraSettingsShown'; const helpCameraSettingsShown = "helpCameraSettingsShown";
const fullscreenKey = 'fullscreen'; const fullscreenKey = "fullscreen";
const lastRoomUrl = "lastRoomUrl";
const authToken = "authToken";
const state = "state";
const nonce = "nonce";
class LocalUserStore { class LocalUserStore {
saveUser(localUser: LocalUser) { saveUser(localUser: LocalUser) {
localStorage.setItem('localUser', JSON.stringify(localUser)); localStorage.setItem("localUser", JSON.stringify(localUser));
} }
getLocalUser(): LocalUser|null { getLocalUser(): LocalUser | null {
const data = localStorage.getItem('localUser'); const data = localStorage.getItem("localUser");
return data ? JSON.parse(data) : null; return data ? JSON.parse(data) : null;
} }
setName(name:string): void { setName(name: string): void {
localStorage.setItem(playerNameKey, name); localStorage.setItem(playerNameKey, name);
} }
getName(): string|null { getName(): string | null {
const value = localStorage.getItem(playerNameKey) || ''; const value = localStorage.getItem(playerNameKey) || "";
return isUserNameValid(value) ? value : null; return isUserNameValid(value) ? value : null;
} }
setPlayerCharacterIndex(playerCharacterIndex: number): void { setPlayerCharacterIndex(playerCharacterIndex: number): void {
localStorage.setItem(selectedPlayerKey, ''+playerCharacterIndex); localStorage.setItem(selectedPlayerKey, "" + playerCharacterIndex);
} }
getPlayerCharacterIndex(): number { getPlayerCharacterIndex(): number {
return parseInt(localStorage.getItem(selectedPlayerKey) || ''); return parseInt(localStorage.getItem(selectedPlayerKey) || "");
} }
setCustomCursorPosition(activeRow:number, selectedLayers: number[]): void { setCustomCursorPosition(activeRow: number, selectedLayers: number[]): void {
localStorage.setItem(customCursorPositionKey, JSON.stringify({activeRow, selectedLayers})); localStorage.setItem(customCursorPositionKey, JSON.stringify({ activeRow, selectedLayers }));
} }
getCustomCursorPosition(): {activeRow:number, selectedLayers:number[]}|null { getCustomCursorPosition(): { activeRow: number; selectedLayers: number[] } | null {
return JSON.parse(localStorage.getItem(customCursorPositionKey) || "null"); return JSON.parse(localStorage.getItem(customCursorPositionKey) || "null");
} }
setCharacterLayers(layers: string[]): void { setCharacterLayers(layers: string[]): void {
localStorage.setItem(characterLayersKey, JSON.stringify(layers)); localStorage.setItem(characterLayersKey, JSON.stringify(layers));
} }
getCharacterLayers(): string[]|null { getCharacterLayers(): string[] | null {
const value = JSON.parse(localStorage.getItem(characterLayersKey) || "null"); const value = JSON.parse(localStorage.getItem(characterLayersKey) || "null");
return areCharacterLayersValid(value) ? value : null; return areCharacterLayersValid(value) ? value : null;
} }
setCompanion(companion: string|null): void { setCompanion(companion: string | null): void {
return localStorage.setItem(companionKey, JSON.stringify(companion)); return localStorage.setItem(companionKey, JSON.stringify(companion));
} }
getCompanion(): string|null { getCompanion(): string | null {
const companion = JSON.parse(localStorage.getItem(companionKey) || "null"); const companion = JSON.parse(localStorage.getItem(companionKey) || "null");
if (typeof companion !== "string" || companion === "") { if (typeof companion !== "string" || companion === "") {
@ -68,45 +73,82 @@ class LocalUserStore {
} }
setGameQualityValue(value: number): void { setGameQualityValue(value: number): void {
localStorage.setItem(gameQualityKey, '' + value); localStorage.setItem(gameQualityKey, "" + value);
} }
getGameQualityValue(): number { getGameQualityValue(): number {
return parseInt(localStorage.getItem(gameQualityKey) || '60'); return parseInt(localStorage.getItem(gameQualityKey) || "60");
} }
setVideoQualityValue(value: number): void { setVideoQualityValue(value: number): void {
localStorage.setItem(videoQualityKey, '' + value); localStorage.setItem(videoQualityKey, "" + value);
} }
getVideoQualityValue(): number { getVideoQualityValue(): number {
return parseInt(localStorage.getItem(videoQualityKey) || '20'); return parseInt(localStorage.getItem(videoQualityKey) || "20");
} }
setAudioPlayerVolume(value: number): void { setAudioPlayerVolume(value: number): void {
localStorage.setItem(audioPlayerVolumeKey, '' + value); localStorage.setItem(audioPlayerVolumeKey, "" + value);
} }
getAudioPlayerVolume(): number { getAudioPlayerVolume(): number {
return parseFloat(localStorage.getItem(audioPlayerVolumeKey) || '1'); return parseFloat(localStorage.getItem(audioPlayerVolumeKey) || "1");
} }
setAudioPlayerMuted(value: boolean): void { setAudioPlayerMuted(value: boolean): void {
localStorage.setItem(audioPlayerMuteKey, value.toString()); localStorage.setItem(audioPlayerMuteKey, value.toString());
} }
getAudioPlayerMuted(): boolean { getAudioPlayerMuted(): boolean {
return localStorage.getItem(audioPlayerMuteKey) === 'true'; return localStorage.getItem(audioPlayerMuteKey) === "true";
} }
setHelpCameraSettingsShown(): void { setHelpCameraSettingsShown(): void {
localStorage.setItem(helpCameraSettingsShown, '1'); localStorage.setItem(helpCameraSettingsShown, "1");
} }
getHelpCameraSettingsShown(): boolean { getHelpCameraSettingsShown(): boolean {
return localStorage.getItem(helpCameraSettingsShown) === '1'; return localStorage.getItem(helpCameraSettingsShown) === "1";
} }
setFullscreen(value: boolean): void { setFullscreen(value: boolean): void {
localStorage.setItem(fullscreenKey, value.toString()); localStorage.setItem(fullscreenKey, value.toString());
} }
getFullscreen(): boolean { getFullscreen(): boolean {
return localStorage.getItem(fullscreenKey) === 'true'; return localStorage.getItem(fullscreenKey) === "true";
}
setLastRoomUrl(roomUrl: string): void {
localStorage.setItem(lastRoomUrl, roomUrl.toString());
}
getLastRoomUrl(): string {
return localStorage.getItem(lastRoomUrl) ?? "";
}
setAuthToken(value: string | null) {
value ? localStorage.setItem(authToken, value) : localStorage.removeItem(authToken);
}
getAuthToken(): string | null {
return localStorage.getItem(authToken);
}
generateState(): string {
const newState = uuidv4();
localStorage.setItem(state, newState);
return newState;
}
verifyState(value: string): boolean {
const oldValue = localStorage.getItem(state);
localStorage.removeItem(state);
return oldValue === value;
}
generateNonce(): string {
const newNonce = uuidv4();
localStorage.setItem(nonce, newNonce);
return newNonce;
}
getNonce(): string | null {
const oldValue = localStorage.getItem(nonce);
localStorage.removeItem(nonce);
return oldValue;
} }
} }

View file

@ -169,6 +169,7 @@ export class Room {
*/ */
public get key(): string { public get key(): string {
const newUrl = new URL(this.roomUrl.toString()); const newUrl = new URL(this.roomUrl.toString());
newUrl.search = "";
newUrl.hash = ""; newUrl.hash = "";
return newUrl.toString(); return newUrl.toString();
} }

View file

@ -32,6 +32,8 @@ import {
EmotePromptMessage, EmotePromptMessage,
SendUserMessage, SendUserMessage,
BanUserMessage, BanUserMessage,
VariableMessage,
ErrorMessage,
} from "../Messages/generated/messages_pb"; } from "../Messages/generated/messages_pb";
import type { UserSimplePeerInterface } from "../WebRtc/SimplePeer"; import type { UserSimplePeerInterface } from "../WebRtc/SimplePeer";
@ -53,9 +55,9 @@ import {
import type { BodyResourceDescriptionInterface } from "../Phaser/Entity/PlayerTextures"; import type { BodyResourceDescriptionInterface } from "../Phaser/Entity/PlayerTextures";
import { adminMessagesService } from "./AdminMessagesService"; import { adminMessagesService } from "./AdminMessagesService";
import { worldFullMessageStream } from "./WorldFullMessageStream"; import { worldFullMessageStream } from "./WorldFullMessageStream";
import { worldFullWarningStream } from "./WorldFullWarningStream";
import { connectionManager } from "./ConnectionManager"; import { connectionManager } from "./ConnectionManager";
import { emoteEventStream } from "./EmoteEventStream"; import { emoteEventStream } from "./EmoteEventStream";
import { warningContainerStore } from "../Stores/MenuStore";
const manualPingDelay = 20000; const manualPingDelay = 20000;
@ -74,7 +76,7 @@ export class RoomConnection implements RoomConnection {
/** /**
* *
* @param token A JWT token containing the UUID of the user * @param token A JWT token containing the email of the user
* @param roomUrl The URL of the room in the form "https://example.com/_/[instance]/[map_url]" or "https://example.com/@/[org]/[event]/[map]" * @param roomUrl The URL of the room in the form "https://example.com/_/[instance]/[map_url]" or "https://example.com/@/[org]/[event]/[map]"
*/ */
public constructor( public constructor(
@ -164,6 +166,12 @@ export class RoomConnection implements RoomConnection {
} else if (subMessage.hasEmoteeventmessage()) { } else if (subMessage.hasEmoteeventmessage()) {
const emoteMessage = subMessage.getEmoteeventmessage() as EmoteEventMessage; const emoteMessage = subMessage.getEmoteeventmessage() as EmoteEventMessage;
emoteEventStream.fire(emoteMessage.getActoruserid(), emoteMessage.getEmote()); emoteEventStream.fire(emoteMessage.getActoruserid(), emoteMessage.getEmote());
} else if (subMessage.hasErrormessage()) {
const errorMessage = subMessage.getErrormessage() as ErrorMessage;
console.error("An error occurred server side: " + errorMessage.getMessage());
} else if (subMessage.hasVariablemessage()) {
event = EventMessage.SET_VARIABLE;
payload = subMessage.getVariablemessage();
} else { } else {
throw new Error("Unexpected batch message type"); throw new Error("Unexpected batch message type");
} }
@ -180,6 +188,22 @@ export class RoomConnection implements RoomConnection {
items[item.getItemid()] = JSON.parse(item.getStatejson()); items[item.getItemid()] = JSON.parse(item.getStatejson());
} }
const variables = new Map<string, unknown>();
for (const variable of roomJoinedMessage.getVariableList()) {
try {
variables.set(variable.getName(), JSON.parse(variable.getValue()));
} catch (e) {
console.error(
'Unable to unserialize value received from server for variable "' +
variable.getName() +
'". Value received: "' +
variable.getValue() +
'". Error: ',
e
);
}
}
this.userId = roomJoinedMessage.getCurrentuserid(); this.userId = roomJoinedMessage.getCurrentuserid();
this.tags = roomJoinedMessage.getTagList(); this.tags = roomJoinedMessage.getTagList();
@ -187,11 +211,15 @@ export class RoomConnection implements RoomConnection {
connection: this, connection: this,
room: { room: {
items, items,
variables,
} as RoomJoinedMessageInterface, } as RoomJoinedMessageInterface,
}); });
} else if (message.hasWorldfullmessage()) { } else if (message.hasWorldfullmessage()) {
worldFullMessageStream.onMessage(); worldFullMessageStream.onMessage();
this.closed = true; this.closed = true;
} else if (message.hasTokenexpiredmessage()) {
connectionManager.loadOpenIDScreen();
this.closed = true; //technically, this isn't needed since loadOpenIDScreen() will do window.location.assign() but I prefer to leave it for consistency
} else if (message.hasWorldconnexionmessage()) { } else if (message.hasWorldconnexionmessage()) {
worldFullMessageStream.onMessage(message.getWorldconnexionmessage()?.getMessage()); worldFullMessageStream.onMessage(message.getWorldconnexionmessage()?.getMessage());
this.closed = true; this.closed = true;
@ -219,7 +247,7 @@ export class RoomConnection implements RoomConnection {
} else if (message.hasBanusermessage()) { } else if (message.hasBanusermessage()) {
adminMessagesService.onSendusermessage(message.getBanusermessage() as BanUserMessage); adminMessagesService.onSendusermessage(message.getBanusermessage() as BanUserMessage);
} else if (message.hasWorldfullwarningmessage()) { } else if (message.hasWorldfullwarningmessage()) {
worldFullWarningStream.onMessage(); warningContainerStore.activateWarningContainer();
} else if (message.hasRefreshroommessage()) { } else if (message.hasRefreshroommessage()) {
//todo: implement a way to notify the user the room was refreshed. //todo: implement a way to notify the user the room was refreshed.
} else { } else {
@ -536,6 +564,17 @@ export class RoomConnection implements RoomConnection {
this.socket.send(clientToServerMessage.serializeBinary().buffer); this.socket.send(clientToServerMessage.serializeBinary().buffer);
} }
emitSetVariableEvent(name: string, value: unknown): void {
const variableMessage = new VariableMessage();
variableMessage.setName(name);
variableMessage.setValue(JSON.stringify(value));
const clientToServerMessage = new ClientToServerMessage();
clientToServerMessage.setVariablemessage(variableMessage);
this.socket.send(clientToServerMessage.serializeBinary().buffer);
}
onActionableEvent(callback: (message: ItemEventMessageInterface) => void): void { onActionableEvent(callback: (message: ItemEventMessageInterface) => void): void {
this.onMessage(EventMessage.ITEM_EVENT, (message: ItemEventMessage) => { this.onMessage(EventMessage.ITEM_EVENT, (message: ItemEventMessage) => {
callback({ callback({
@ -622,6 +661,29 @@ export class RoomConnection implements RoomConnection {
}); });
} }
public onSetVariable(callback: (name: string, value: unknown) => void): void {
this.onMessage(EventMessage.SET_VARIABLE, (message: VariableMessage) => {
const name = message.getName();
const serializedValue = message.getValue();
let value: unknown = undefined;
if (serializedValue) {
try {
value = JSON.parse(serializedValue);
} catch (e) {
console.error(
'Unable to unserialize value received from server for variable "' +
name +
'". Value received: "' +
serializedValue +
'". Error: ',
e
);
}
}
callback(name, value);
});
}
public hasTag(tag: string): boolean { public hasTag(tag: string): boolean {
return this.tags.includes(tag); return this.tags.includes(tag);
} }

View file

@ -1,14 +0,0 @@
import {Subject} from "rxjs";
class WorldFullWarningStream {
private _stream:Subject<void> = new Subject();
public stream = this._stream.asObservable();
onMessage() {
this._stream.next();
}
}
export const worldFullWarningStream = new WorldFullWarningStream();

View file

@ -1,22 +1,24 @@
const DEBUG_MODE: boolean = process.env.DEBUG_MODE == "true"; const DEBUG_MODE: boolean = process.env.DEBUG_MODE == "true";
const START_ROOM_URL : string = process.env.START_ROOM_URL || '/_/global/maps.workadventure.localhost/Floor0/floor0.json'; const START_ROOM_URL: string =
const PUSHER_URL = process.env.PUSHER_URL || '//pusher.workadventure.localhost'; process.env.START_ROOM_URL || "/_/global/maps.workadventure.localhost/Floor1/floor1.json";
const UPLOADER_URL = process.env.UPLOADER_URL || '//uploader.workadventure.localhost'; const PUSHER_URL = process.env.PUSHER_URL || "//pusher.workadventure.localhost";
export const ADMIN_URL = process.env.ADMIN_URL || "//workadventu.re";
const UPLOADER_URL = process.env.UPLOADER_URL || "//uploader.workadventure.localhost";
const STUN_SERVER: string = process.env.STUN_SERVER || "stun:stun.l.google.com:19302"; const STUN_SERVER: string = process.env.STUN_SERVER || "stun:stun.l.google.com:19302";
const TURN_SERVER: string = process.env.TURN_SERVER || ""; const TURN_SERVER: string = process.env.TURN_SERVER || "";
const SKIP_RENDER_OPTIMIZATIONS: boolean = process.env.SKIP_RENDER_OPTIMIZATIONS == "true"; const SKIP_RENDER_OPTIMIZATIONS: boolean = process.env.SKIP_RENDER_OPTIMIZATIONS == "true";
const DISABLE_NOTIFICATIONS: boolean = process.env.DISABLE_NOTIFICATIONS == "true"; const DISABLE_NOTIFICATIONS: boolean = process.env.DISABLE_NOTIFICATIONS == "true";
const TURN_USER: string = process.env.TURN_USER || ''; const TURN_USER: string = process.env.TURN_USER || "";
const TURN_PASSWORD: string = process.env.TURN_PASSWORD || ''; const TURN_PASSWORD: string = process.env.TURN_PASSWORD || "";
const JITSI_URL : string|undefined = (process.env.JITSI_URL === '') ? undefined : process.env.JITSI_URL; const JITSI_URL: string | undefined = process.env.JITSI_URL === "" ? undefined : process.env.JITSI_URL;
const JITSI_PRIVATE_MODE : boolean = process.env.JITSI_PRIVATE_MODE == "true"; const JITSI_PRIVATE_MODE: boolean = process.env.JITSI_PRIVATE_MODE == "true";
const POSITION_DELAY = 200; // Wait 200ms between sending position events const POSITION_DELAY = 200; // Wait 200ms between sending position events
const MAX_EXTRAPOLATION_TIME = 100; // 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 const MAX_USERNAME_LENGTH = parseInt(process.env.MAX_USERNAME_LENGTH || '') || 8; export const MAX_USERNAME_LENGTH = parseInt(process.env.MAX_USERNAME_LENGTH || "") || 8;
export const MAX_PER_GROUP = parseInt(process.env.MAX_PER_GROUP || '4'); export const MAX_PER_GROUP = parseInt(process.env.MAX_PER_GROUP || "4");
export const DISPLAY_TERMS_OF_USE = process.env.DISPLAY_TERMS_OF_USE == 'true'; export const DISPLAY_TERMS_OF_USE = process.env.DISPLAY_TERMS_OF_USE == "true";
export const isMobile = ():boolean => ( ( window.innerWidth <= 800 ) || ( window.innerHeight <= 600 ) ); export const isMobile = (): boolean => window.innerWidth <= 800 || window.innerHeight <= 600;
export { export {
DEBUG_MODE, DEBUG_MODE,
@ -32,5 +34,5 @@ export {
TURN_USER, TURN_USER,
TURN_PASSWORD, TURN_PASSWORD,
JITSI_URL, JITSI_URL,
JITSI_PRIVATE_MODE JITSI_PRIVATE_MODE,
} };

View file

@ -0,0 +1,20 @@
export class _ServiceWorker {
constructor() {
if ("serviceWorker" in navigator) {
this.init();
}
}
init() {
window.addEventListener("load", () => {
navigator.serviceWorker
.register("/resources/service-worker.js")
.then((serviceWorker) => {
console.info("Service Worker registered: ", serviceWorker);
})
.catch((error) => {
console.error("Error registering the Service Worker: ", error);
});
});
}
}

View file

@ -1,35 +1,43 @@
import type {ITiledMapObject} from "../Map/ITiledMap"; import type { ITiledMapObject } from "../Map/ITiledMap";
import type {GameScene} from "../Game/GameScene"; import type { GameScene } from "../Game/GameScene";
import { type } from "os";
export class TextUtils { export class TextUtils {
public static createTextFromITiledMapObject(scene: GameScene, object: ITiledMapObject): void { public static createTextFromITiledMapObject(scene: GameScene, object: ITiledMapObject): void {
if (object.text === undefined) { if (object.text === undefined) {
throw new Error('This object has not textual representation.'); throw new Error("This object has not textual representation.");
} }
const options: { const options: {
fontStyle?: string, fontStyle?: string;
fontSize?: string, fontSize?: string;
fontFamily?: string, fontFamily?: string;
color?: string, color?: string;
align?: string, align?: string;
wordWrap?: { wordWrap?: {
width: number, width: number;
useAdvancedWrap?: boolean useAdvancedWrap?: boolean;
} };
} = {}; } = {};
if (object.text.italic) { if (object.text.italic) {
options.fontStyle = 'italic'; options.fontStyle = "italic";
} }
// Note: there is no support for "strikeout" and "underline" // Note: there is no support for "strikeout" and "underline"
let fontSize: number = 16; let fontSize: number = 16;
if (object.text.pixelsize) { if (object.text.pixelsize) {
fontSize = object.text.pixelsize; fontSize = object.text.pixelsize;
} }
options.fontSize = fontSize + 'px'; options.fontSize = fontSize + "px";
if (object.text.fontfamily) { if (object.text.fontfamily) {
options.fontFamily = '"'+object.text.fontfamily+'"'; options.fontFamily = '"' + object.text.fontfamily + '"';
} }
let color = '#000000'; if (object.properties !== undefined) {
for (const property of object.properties) {
if (property.name === "font-family" && typeof property.value === "string") {
options.fontFamily = property.value;
}
}
}
let color = "#000000";
if (object.text.color !== undefined) { if (object.text.color !== undefined) {
color = object.text.color; color = object.text.color;
} }
@ -38,7 +46,7 @@ export class TextUtils {
options.wordWrap = { options.wordWrap = {
width: object.width, width: object.width,
//useAdvancedWrap: true //useAdvancedWrap: true
} };
} }
if (object.text.halign !== undefined) { if (object.text.halign !== undefined) {
options.align = object.text.halign; options.align = object.text.halign;

View file

@ -1,14 +0,0 @@
export const warningContainerKey = 'warningContainer';
export const warningContainerHtml = 'resources/html/warningContainer.html';
export class WarningContainer extends Phaser.GameObjects.DOMElement {
constructor(scene: Phaser.Scene) {
super(scene, 100, 0);
this.setOrigin(0, 0);
this.createFromCache(warningContainerKey);
this.scene.add.existing(this);
}
}

View file

@ -107,7 +107,7 @@ export const createLoadingPromise = (
loadPlugin.spritesheet(playerResourceDescriptor.name, playerResourceDescriptor.img, frameConfig); loadPlugin.spritesheet(playerResourceDescriptor.name, playerResourceDescriptor.img, frameConfig);
const errorCallback = (file: { src: string }) => { const errorCallback = (file: { src: string }) => {
if (file.src !== playerResourceDescriptor.img) return; if (file.src !== playerResourceDescriptor.img) return;
console.error("failed loading player ressource: ", playerResourceDescriptor); console.error("failed loading player resource: ", playerResourceDescriptor);
rej(playerResourceDescriptor); rej(playerResourceDescriptor);
loadPlugin.off("filecomplete-spritesheet-" + playerResourceDescriptor.name, successCallback); loadPlugin.off("filecomplete-spritesheet-" + playerResourceDescriptor.name, successCallback);
loadPlugin.off("loaderror", errorCallback); loadPlugin.off("loaderror", errorCallback);

View file

@ -1,5 +1,5 @@
import type {PointInterface} from "../../Connexion/ConnexionModels"; import type { PointInterface } from "../../Connexion/ConnexionModels";
import type {PlayerInterface} from "./PlayerInterface"; import type { PlayerInterface } from "./PlayerInterface";
export interface AddPlayerInterface extends PlayerInterface { export interface AddPlayerInterface extends PlayerInterface {
position: PointInterface; position: PointInterface;

View file

@ -1,7 +1,7 @@
import {SKIP_RENDER_OPTIMIZATIONS} from "../../Enum/EnvironmentVariable"; import { SKIP_RENDER_OPTIMIZATIONS } from "../../Enum/EnvironmentVariable";
import {coWebsiteManager} from "../../WebRtc/CoWebsiteManager"; import { coWebsiteManager } from "../../WebRtc/CoWebsiteManager";
import {waScaleManager} from "../Services/WaScaleManager"; import { waScaleManager } from "../Services/WaScaleManager";
import {ResizableScene} from "../Login/ResizableScene"; import { ResizableScene } from "../Login/ResizableScene";
const Events = Phaser.Core.Events; const Events = Phaser.Core.Events;
@ -14,10 +14,8 @@ const Events = Phaser.Core.Events;
* It also automatically calls "onResize" on any scenes extending ResizableScene. * It also automatically calls "onResize" on any scenes extending ResizableScene.
*/ */
export class Game extends Phaser.Game { export class Game extends Phaser.Game {
private _isDirty = false; private _isDirty = false;
constructor(GameConfig: Phaser.Types.Core.GameConfig) { constructor(GameConfig: Phaser.Types.Core.GameConfig) {
super(GameConfig); super(GameConfig);
@ -27,7 +25,7 @@ export class Game extends Phaser.Game {
scene.onResize(); scene.onResize();
} }
} }
}) });
/*window.addEventListener('resize', (event) => { /*window.addEventListener('resize', (event) => {
// Let's trigger the onResize method of any active scene that is a ResizableScene // Let's trigger the onResize method of any active scene that is a ResizableScene
@ -39,11 +37,9 @@ export class Game extends Phaser.Game {
});*/ });*/
} }
public step(time: number, delta: number) public step(time: number, delta: number) {
{
// @ts-ignore // @ts-ignore
if (this.pendingDestroy) if (this.pendingDestroy) {
{
// @ts-ignore // @ts-ignore
return this.runDestroy(); return this.runDestroy();
} }
@ -100,15 +96,17 @@ export class Game extends Phaser.Game {
} }
// Loop through the scenes in forward order // Loop through the scenes in forward order
for (let i = 0; i < this.scene.scenes.length; i++) for (let i = 0; i < this.scene.scenes.length; i++) {
{
const scene = this.scene.scenes[i]; const scene = this.scene.scenes[i];
const sys = scene.sys; const sys = scene.sys;
if (sys.settings.visible && sys.settings.status >= Phaser.Scenes.LOADING && sys.settings.status < Phaser.Scenes.SLEEPING) if (
{ sys.settings.visible &&
sys.settings.status >= Phaser.Scenes.LOADING &&
sys.settings.status < Phaser.Scenes.SLEEPING
) {
// @ts-ignore // @ts-ignore
if(typeof scene.isDirty === 'function') { if (typeof scene.isDirty === "function") {
// @ts-ignore // @ts-ignore
const isDirty = scene.isDirty() || scene.tweens.getAllTweens().length > 0; const isDirty = scene.isDirty() || scene.tweens.getAllTweens().length > 0;
if (isDirty) { if (isDirty) {
@ -129,4 +127,11 @@ export class Game extends Phaser.Game {
public markDirty(): void { public markDirty(): void {
this._isDirty = true; this._isDirty = true;
} }
/**
* Return the first scene found in the game
*/
public findAnyScene(): Phaser.Scene {
return this.scene.getScenes()[0];
}
} }

View file

@ -1,4 +1,4 @@
import type { ITiledMap, ITiledMapLayer, ITiledMapLayerProperty } from "../Map/ITiledMap"; import type { ITiledMap, ITiledMapLayer, ITiledMapProperty } from "../Map/ITiledMap";
import { flattenGroupLayersMap } from "../Map/LayersFlattener"; import { flattenGroupLayersMap } from "../Map/LayersFlattener";
import TilemapLayer = Phaser.Tilemaps.TilemapLayer; import TilemapLayer = Phaser.Tilemaps.TilemapLayer;
import { DEPTH_OVERLAY_INDEX } from "./DepthIndexes"; import { DEPTH_OVERLAY_INDEX } from "./DepthIndexes";
@ -19,7 +19,7 @@ export class GameMap {
private callbacks = new Map<string, Array<PropertyChangeCallback>>(); private callbacks = new Map<string, Array<PropertyChangeCallback>>();
private tileNameMap = new Map<string, number>(); private tileNameMap = new Map<string, number>();
private tileSetPropertyMap: { [tile_index: number]: Array<ITiledMapLayerProperty> } = {}; private tileSetPropertyMap: { [tile_index: number]: Array<ITiledMapProperty> } = {};
public readonly flatLayers: ITiledMapLayer[]; public readonly flatLayers: ITiledMapLayer[];
public readonly phaserLayers: TilemapLayer[] = []; public readonly phaserLayers: TilemapLayer[] = [];
@ -61,7 +61,7 @@ export class GameMap {
} }
} }
public getPropertiesForIndex(index: number): Array<ITiledMapLayerProperty> { public getPropertiesForIndex(index: number): Array<ITiledMapProperty> {
if (this.tileSetPropertyMap[index]) { if (this.tileSetPropertyMap[index]) {
return this.tileSetPropertyMap[index]; return this.tileSetPropertyMap[index];
} }
@ -151,7 +151,7 @@ export class GameMap {
return this.map; return this.map;
} }
private getTileProperty(index: number): Array<ITiledMapLayerProperty> { private getTileProperty(index: number): Array<ITiledMapProperty> {
if (this.tileSetPropertyMap[index]) { if (this.tileSetPropertyMap[index]) {
return this.tileSetPropertyMap[index]; return this.tileSetPropertyMap[index];
} }

View file

@ -47,13 +47,7 @@ import { RemotePlayer } from "../Entity/RemotePlayer";
import type { ActionableItem } from "../Items/ActionableItem"; import type { ActionableItem } from "../Items/ActionableItem";
import type { ItemFactoryInterface } from "../Items/ItemFactoryInterface"; import type { ItemFactoryInterface } from "../Items/ItemFactoryInterface";
import { SelectCharacterScene, SelectCharacterSceneName } from "../Login/SelectCharacterScene"; import { SelectCharacterScene, SelectCharacterSceneName } from "../Login/SelectCharacterScene";
import type { import type { ITiledMap, ITiledMapLayer, ITiledMapProperty, ITiledMapObject, ITiledTileSet } from "../Map/ITiledMap";
ITiledMap,
ITiledMapLayer,
ITiledMapLayerProperty,
ITiledMapObject,
ITiledTileSet,
} from "../Map/ITiledMap";
import { MenuScene, MenuSceneName } from "../Menu/MenuScene"; import { MenuScene, MenuSceneName } from "../Menu/MenuScene";
import { PlayerAnimationDirections } from "../Player/Animation"; import { PlayerAnimationDirections } from "../Player/Animation";
import { hasMovedEventName, Player, requestEmoteEventName } from "../Player/Player"; import { hasMovedEventName, Player, requestEmoteEventName } from "../Player/Player";
@ -91,8 +85,10 @@ import { soundManager } from "./SoundManager";
import { peerStore, screenSharingPeerStore } from "../../Stores/PeerStore"; import { peerStore, screenSharingPeerStore } from "../../Stores/PeerStore";
import { videoFocusStore } from "../../Stores/VideoFocusStore"; import { videoFocusStore } from "../../Stores/VideoFocusStore";
import { biggestAvailableAreaStore } from "../../Stores/BiggestAvailableAreaStore"; import { biggestAvailableAreaStore } from "../../Stores/BiggestAvailableAreaStore";
import { SharedVariablesManager } from "./SharedVariablesManager";
import { playersStore } from "../../Stores/PlayersStore"; import { playersStore } from "../../Stores/PlayersStore";
import { chatVisibilityStore } from "../../Stores/ChatStore"; import { chatVisibilityStore } from "../../Stores/ChatStore";
import { userIsAdminStore } from "../../Stores/GameStore";
export interface GameSceneInitInterface { export interface GameSceneInitInterface {
initPosition: PointInterface | null; initPosition: PointInterface | null;
@ -199,10 +195,11 @@ export class GameScene extends DirtyScene {
private popUpElements: Map<number, DOMElement> = new Map<number, Phaser.GameObjects.DOMElement>(); private popUpElements: Map<number, DOMElement> = new Map<number, Phaser.GameObjects.DOMElement>();
private originalMapUrl: string | undefined; private originalMapUrl: string | undefined;
private pinchManager: PinchManager | undefined; private pinchManager: PinchManager | undefined;
private mapTransitioning: boolean = false; //used to prevent transitions happenning at the same time. private mapTransitioning: boolean = false; //used to prevent transitions happening at the same time.
private emoteManager!: EmoteManager; private emoteManager!: EmoteManager;
private preloading: boolean = true; private preloading: boolean = true;
startPositionCalculator!: StartPositionCalculator; private startPositionCalculator!: StartPositionCalculator;
private sharedVariablesManager!: SharedVariablesManager;
constructor(private room: Room, MapUrlFile: string, customKey?: string | undefined) { constructor(private room: Room, MapUrlFile: string, customKey?: string | undefined) {
super({ super({
@ -440,7 +437,7 @@ export class GameScene extends DirtyScene {
this.characterLayers = gameManager.getCharacterLayers(); this.characterLayers = gameManager.getCharacterLayers();
this.companion = gameManager.getCompanion(); this.companion = gameManager.getCompanion();
//initalise map //initialise map
this.Map = this.add.tilemap(this.MapUrlFile); this.Map = this.add.tilemap(this.MapUrlFile);
const mapDirUrl = this.MapUrlFile.substr(0, this.MapUrlFile.lastIndexOf("/")); const mapDirUrl = this.MapUrlFile.substr(0, this.MapUrlFile.lastIndexOf("/"));
this.mapFile.tilesets.forEach((tileset: ITiledTileSet) => { this.mapFile.tilesets.forEach((tileset: ITiledTileSet) => {
@ -608,6 +605,8 @@ export class GameScene extends DirtyScene {
playersStore.connectToRoomConnection(this.connection); playersStore.connectToRoomConnection(this.connection);
userIsAdminStore.set(this.connection.hasTag("admin"));
this.connection.onUserJoins((message: MessageUserJoined) => { this.connection.onUserJoins((message: MessageUserJoined) => {
const userMessage: AddPlayerInterface = { const userMessage: AddPlayerInterface = {
userId: message.userId, userId: message.userId,
@ -718,6 +717,13 @@ export class GameScene extends DirtyScene {
this.gameMap.setPosition(event.x, event.y); this.gameMap.setPosition(event.x, event.y);
}); });
// Set up variables manager
this.sharedVariablesManager = new SharedVariablesManager(
this.connection,
this.gameMap,
onConnect.room.variables
);
//this.initUsersPosition(roomJoinedMessage.users); //this.initUsersPosition(roomJoinedMessage.users);
this.connectionAnswerPromiseResolve(onConnect.room); this.connectionAnswerPromiseResolve(onConnect.room);
// Analyze tags to find if we are admin. If yes, show console. // Analyze tags to find if we are admin. If yes, show console.
@ -1053,20 +1059,24 @@ ${escapedMessage}
}) })
); );
this.iframeSubscriptionList.push( iframeListener.registerAnswerer("getMapData", () => {
iframeListener.dataLayerChangeStream.subscribe(() => { return {
iframeListener.sendDataLayerEvent({ data: this.gameMap.getMap() }); data: this.gameMap.getMap(),
}) };
); });
iframeListener.registerAnswerer("getState", () => { iframeListener.registerAnswerer("getState", async () => {
// The sharedVariablesManager is not instantiated before the connection is established. So we need to wait
// for the connection to send back the answer.
await this.connectionAnswerPromise;
return { return {
mapUrl: this.MapUrlFile, mapUrl: this.MapUrlFile,
startLayerName: this.startPositionCalculator.startLayerName, startLayerName: this.startPositionCalculator.startLayerName,
uuid: localUserStore.getLocalUser()?.uuid, uuid: localUserStore.getLocalUser()?.uuid,
nickname: localUserStore.getName(), nickname: this.playerName,
roomId: this.roomUrl, roomId: this.roomUrl,
tags: this.connection ? this.connection.getAllTags() : [], tags: this.connection ? this.connection.getAllTags() : [],
variables: this.sharedVariablesManager.variables,
}; };
}); });
this.iframeSubscriptionList.push( this.iframeSubscriptionList.push(
@ -1197,6 +1207,7 @@ ${escapedMessage}
this.chatVisibilityUnsubscribe(); this.chatVisibilityUnsubscribe();
this.biggestAvailableAreaStoreUnsubscribe(); this.biggestAvailableAreaStoreUnsubscribe();
iframeListener.unregisterAnswerer("getState"); iframeListener.unregisterAnswerer("getState");
this.sharedVariablesManager?.close();
mediaManager.hideGameOverlay(); mediaManager.hideGameOverlay();
@ -1236,12 +1247,12 @@ ${escapedMessage}
} }
private getProperty(layer: ITiledMapLayer | ITiledMap, name: string): string | boolean | number | undefined { private getProperty(layer: ITiledMapLayer | ITiledMap, name: string): string | boolean | number | undefined {
const properties: ITiledMapLayerProperty[] | undefined = layer.properties; const properties: ITiledMapProperty[] | undefined = layer.properties;
if (!properties) { if (!properties) {
return undefined; return undefined;
} }
const obj = properties.find( const obj = properties.find(
(property: ITiledMapLayerProperty) => property.name.toLowerCase() === name.toLowerCase() (property: ITiledMapProperty) => property.name.toLowerCase() === name.toLowerCase()
); );
if (obj === undefined) { if (obj === undefined) {
return undefined; return undefined;
@ -1250,12 +1261,12 @@ ${escapedMessage}
} }
private getProperties(layer: ITiledMapLayer | ITiledMap, name: string): (string | number | boolean | undefined)[] { private getProperties(layer: ITiledMapLayer | ITiledMap, name: string): (string | number | boolean | undefined)[] {
const properties: ITiledMapLayerProperty[] | undefined = layer.properties; const properties: ITiledMapProperty[] | undefined = layer.properties;
if (!properties) { if (!properties) {
return []; return [];
} }
return properties return properties
.filter((property: ITiledMapLayerProperty) => property.name.toLowerCase() === name.toLowerCase()) .filter((property: ITiledMapProperty) => property.name.toLowerCase() === name.toLowerCase())
.map((property) => property.value); .map((property) => property.value);
} }

View file

@ -0,0 +1,167 @@
import type { RoomConnection } from "../../Connexion/RoomConnection";
import { iframeListener } from "../../Api/IframeListener";
import type { Subscription } from "rxjs";
import type { GameMap } from "./GameMap";
import type { ITile, ITiledMapObject } from "../Map/ITiledMap";
import type { Var } from "svelte/types/compiler/interfaces";
import { init } from "svelte/internal";
interface Variable {
defaultValue: unknown;
readableBy?: string;
writableBy?: string;
}
/**
* Stores variables and provides a bridge between scripts and the pusher server.
*/
export class SharedVariablesManager {
private _variables = new Map<string, unknown>();
private variableObjects: Map<string, Variable>;
constructor(
private roomConnection: RoomConnection,
private gameMap: GameMap,
serverVariables: Map<string, unknown>
) {
// We initialize the list of variable object at room start. The objects cannot be edited later
// (otherwise, this would cause a security issue if the scripting API can edit this list of objects)
this.variableObjects = SharedVariablesManager.findVariablesInMap(gameMap);
// Let's initialize default values
for (const [name, variableObject] of this.variableObjects.entries()) {
if (variableObject.readableBy && !this.roomConnection.hasTag(variableObject.readableBy)) {
// Do not initialize default value for variables that are not readable
continue;
}
this._variables.set(name, variableObject.defaultValue);
}
// Override default values with the variables from the server:
for (const [name, value] of serverVariables) {
this._variables.set(name, value);
}
roomConnection.onSetVariable((name, value) => {
this._variables.set(name, value);
// On server change, let's notify the iframes
iframeListener.setVariable({
key: name,
value: value,
});
});
// When a variable is modified from an iFrame
iframeListener.registerAnswerer("setVariable", (event, source) => {
const key = event.key;
const object = this.variableObjects.get(key);
if (object === undefined) {
const errMsg =
'A script is trying to modify variable "' +
key +
'" but this variable is not defined in the map.' +
'There should be an object in the map whose name is "' +
key +
'" and whose type is "variable"';
console.error(errMsg);
throw new Error(errMsg);
}
if (object.writableBy && !this.roomConnection.hasTag(object.writableBy)) {
const errMsg =
'A script is trying to modify variable "' +
key +
'" but this variable is only writable for users with tag "' +
object.writableBy +
'".';
console.error(errMsg);
throw new Error(errMsg);
}
// Let's stop any propagation of the value we set is the same as the existing value.
if (JSON.stringify(event.value) === JSON.stringify(this._variables.get(key))) {
return;
}
this._variables.set(key, event.value);
// Dispatch to the room connection.
this.roomConnection.emitSetVariableEvent(key, event.value);
// Dispatch to other iframes
iframeListener.dispatchVariableToOtherIframes(key, event.value, source);
});
}
private static findVariablesInMap(gameMap: GameMap): Map<string, Variable> {
const objects = new Map<string, Variable>();
for (const layer of gameMap.getMap().layers) {
if (layer.type === "objectgroup") {
for (const object of layer.objects) {
if (object.type === "variable") {
if (object.template) {
console.warn(
'Warning, a variable object is using a Tiled "template". WorkAdventure does not support objects generated from Tiled templates.'
);
}
// We store a copy of the object (to make it immutable)
objects.set(object.name, this.iTiledObjectToVariable(object));
}
}
}
}
return objects;
}
private static iTiledObjectToVariable(object: ITiledMapObject): Variable {
const variable: Variable = {
defaultValue: undefined,
};
if (object.properties) {
for (const property of object.properties) {
const value = property.value;
switch (property.name) {
case "default":
variable.defaultValue = value;
break;
case "writableBy":
if (typeof value !== "string") {
throw new Error(
'The writableBy property of variable "' + object.name + '" must be a string'
);
}
if (value) {
variable.writableBy = value;
}
break;
case "readableBy":
if (typeof value !== "string") {
throw new Error(
'The readableBy property of variable "' + object.name + '" must be a string'
);
}
if (value) {
variable.readableBy = value;
}
break;
}
}
}
return variable;
}
public close(): void {
iframeListener.unregisterAnswerer("setVariable");
}
get variables(): Map<string, unknown> {
return this._variables;
}
}

View file

@ -1,5 +1,5 @@
import type { PositionInterface } from "../../Connexion/ConnexionModels"; import type { PositionInterface } from "../../Connexion/ConnexionModels";
import type { ITiledMap, ITiledMapLayer, ITiledMapLayerProperty, ITiledMapTileLayer } from "../Map/ITiledMap"; import type { ITiledMap, ITiledMapLayer, ITiledMapProperty, ITiledMapTileLayer } from "../Map/ITiledMap";
import type { GameMap } from "./GameMap"; import type { GameMap } from "./GameMap";
const defaultStartLayerName = "start"; const defaultStartLayerName = "start";
@ -45,7 +45,7 @@ export class StartPositionCalculator {
/** /**
* *
* @param selectedLayer this is always the layer that is selected with the hash in the url * @param selectedLayer this is always the layer that is selected with the hash in the url
* @param selectedOrDefaultLayer this can also be the {defaultStartLayerName} if the {selectedLayer} didnt yield any start points * @param selectedOrDefaultLayer this can also be the {defaultStartLayerName} if the {selectedLayer} did not yield any start points
*/ */
public initPositionFromLayerName(selectedOrDefaultLayer: string | null, selectedLayer: string | null) { public initPositionFromLayerName(selectedOrDefaultLayer: string | null, selectedLayer: string | null) {
if (!selectedOrDefaultLayer) { if (!selectedOrDefaultLayer) {
@ -73,7 +73,7 @@ export class StartPositionCalculator {
/** /**
* *
* @param selectedLayer this is always the layer that is selected with the hash in the url * @param selectedLayer this is always the layer that is selected with the hash in the url
* @param selectedOrDefaultLayer this can also be the default layer if the {selectedLayer} didnt yield any start points * @param selectedOrDefaultLayer this can also be the default layer if the {selectedLayer} did not yield any start points
*/ */
private startUser(selectedOrDefaultLayer: ITiledMapTileLayer, selectedLayer: string | null): PositionInterface { private startUser(selectedOrDefaultLayer: ITiledMapTileLayer, selectedLayer: string | null): PositionInterface {
const tiles = selectedOrDefaultLayer.data; const tiles = selectedOrDefaultLayer.data;
@ -112,12 +112,12 @@ export class StartPositionCalculator {
} }
private getProperty(layer: ITiledMapLayer | ITiledMap, name: string): string | boolean | number | undefined { private getProperty(layer: ITiledMapLayer | ITiledMap, name: string): string | boolean | number | undefined {
const properties: ITiledMapLayerProperty[] | undefined = layer.properties; const properties: ITiledMapProperty[] | undefined = layer.properties;
if (!properties) { if (!properties) {
return undefined; return undefined;
} }
const obj = properties.find( const obj = properties.find(
(property: ITiledMapLayerProperty) => property.name.toLowerCase() === name.toLowerCase() (property: ITiledMapProperty) => property.name.toLowerCase() === name.toLowerCase()
); );
if (obj === undefined) { if (obj === undefined) {
return undefined; return undefined;

View file

@ -16,7 +16,7 @@ export interface ITiledMap {
* Map orientation (orthogonal) * Map orientation (orthogonal)
*/ */
orientation: string; orientation: string;
properties?: ITiledMapLayerProperty[]; properties?: ITiledMapProperty[];
/** /**
* Render order (right-down) * Render order (right-down)
@ -33,7 +33,7 @@ export interface ITiledMap {
type?: string; type?: string;
} }
export interface ITiledMapLayerProperty { export interface ITiledMapProperty {
name: string; name: string;
type: string; type: string;
value: string | boolean | number | undefined; value: string | boolean | number | undefined;
@ -51,7 +51,7 @@ export interface ITiledMapGroupLayer {
id?: number; id?: number;
name: string; name: string;
opacity: number; opacity: number;
properties?: ITiledMapLayerProperty[]; properties?: ITiledMapProperty[];
type: "group"; type: "group";
visible: boolean; visible: boolean;
@ -69,7 +69,7 @@ export interface ITiledMapTileLayer {
height: number; height: number;
name: string; name: string;
opacity: number; opacity: number;
properties?: ITiledMapLayerProperty[]; properties?: ITiledMapProperty[];
encoding?: string; encoding?: string;
compression?: string; compression?: string;
@ -91,7 +91,7 @@ export interface ITiledMapObjectLayer {
height: number; height: number;
name: string; name: string;
opacity: number; opacity: number;
properties?: ITiledMapLayerProperty[]; properties?: ITiledMapProperty[];
encoding?: string; encoding?: string;
compression?: string; compression?: string;
@ -117,7 +117,7 @@ export interface ITiledMapObject {
gid: number; gid: number;
height: number; height: number;
name: string; name: string;
properties: { [key: string]: string }; properties?: ITiledMapProperty[];
rotation: number; rotation: number;
type: string; type: string;
visible: boolean; visible: boolean;
@ -141,6 +141,7 @@ export interface ITiledMapObject {
polyline: { x: number; y: number }[]; polyline: { x: number; y: number }[];
text?: ITiledText; text?: ITiledText;
template?: string;
} }
export interface ITiledText { export interface ITiledText {
@ -163,7 +164,7 @@ export interface ITiledTileSet {
imagewidth: number; imagewidth: number;
margin: number; margin: number;
name: string; name: string;
properties: { [key: string]: string }; properties?: ITiledMapProperty[];
spacing: number; spacing: number;
tilecount: number; tilecount: number;
tileheight: number; tileheight: number;
@ -182,7 +183,7 @@ export interface ITile {
id: number; id: number;
type?: string; type?: string;
properties?: Array<ITiledMapLayerProperty>; properties?: ITiledMapProperty[];
} }
export interface ITiledMapTerrain { export interface ITiledMapTerrain {

View file

@ -6,8 +6,6 @@ import { localUserStore } from "../../Connexion/LocalUserStore";
import { gameReportKey, gameReportRessource, ReportMenu } from "./ReportMenu"; import { gameReportKey, gameReportRessource, ReportMenu } from "./ReportMenu";
import { connectionManager } from "../../Connexion/ConnectionManager"; import { connectionManager } from "../../Connexion/ConnectionManager";
import { GameConnexionTypes } from "../../Url/UrlManager"; import { GameConnexionTypes } from "../../Url/UrlManager";
import { WarningContainer, warningContainerHtml, warningContainerKey } from "../Components/WarningContainer";
import { worldFullWarningStream } from "../../Connexion/WorldFullWarningStream";
import { menuIconVisible } from "../../Stores/MenuStore"; import { menuIconVisible } from "../../Stores/MenuStore";
import { videoConstraintStore } from "../../Stores/MediaStore"; import { videoConstraintStore } from "../../Stores/MediaStore";
import { showReportScreenStore } from "../../Stores/ShowReportScreenStore"; import { showReportScreenStore } from "../../Stores/ShowReportScreenStore";
@ -21,6 +19,7 @@ import { get } from "svelte/store";
import { playersStore } from "../../Stores/PlayersStore"; import { playersStore } from "../../Stores/PlayersStore";
import { mediaManager } from "../../WebRtc/MediaManager"; import { mediaManager } from "../../WebRtc/MediaManager";
import { chatVisibilityStore } from "../../Stores/ChatStore"; import { chatVisibilityStore } from "../../Stores/ChatStore";
import { ADMIN_URL } from "../../Enum/EnvironmentVariable";
export const MenuSceneName = "MenuScene"; export const MenuSceneName = "MenuScene";
const gameMenuKey = "gameMenu"; const gameMenuKey = "gameMenu";
@ -45,8 +44,6 @@ export class MenuScene extends Phaser.Scene {
private gameQualityValue: number; private gameQualityValue: number;
private videoQualityValue: number; private videoQualityValue: number;
private menuButton!: Phaser.GameObjects.DOMElement; private menuButton!: Phaser.GameObjects.DOMElement;
private warningContainer: WarningContainer | null = null;
private warningContainerTimeout: NodeJS.Timeout | null = null;
private subscriptions = new Subscription(); private subscriptions = new Subscription();
constructor() { constructor() {
super({ key: MenuSceneName }); super({ key: MenuSceneName });
@ -91,7 +88,6 @@ export class MenuScene extends Phaser.Scene {
this.load.html(gameSettingsMenuKey, "resources/html/gameQualityMenu.html"); this.load.html(gameSettingsMenuKey, "resources/html/gameQualityMenu.html");
this.load.html(gameShare, "resources/html/gameShare.html"); this.load.html(gameShare, "resources/html/gameShare.html");
this.load.html(gameReportKey, gameReportRessource); this.load.html(gameReportKey, gameReportRessource);
this.load.html(warningContainerKey, warningContainerHtml);
} }
create() { create() {
@ -147,7 +143,6 @@ export class MenuScene extends Phaser.Scene {
this.menuElement.addListener("click"); this.menuElement.addListener("click");
this.menuElement.on("click", this.onMenuClick.bind(this)); this.menuElement.on("click", this.onMenuClick.bind(this));
worldFullWarningStream.stream.subscribe(() => this.showWorldCapacityWarning());
chatVisibilityStore.subscribe((v) => { chatVisibilityStore.subscribe((v) => {
this.menuButton.setVisible(!v); this.menuButton.setVisible(!v);
}); });
@ -194,20 +189,6 @@ export class MenuScene extends Phaser.Scene {
}); });
} }
private showWorldCapacityWarning() {
if (!this.warningContainer) {
this.warningContainer = new WarningContainer(this);
}
if (this.warningContainerTimeout) {
clearTimeout(this.warningContainerTimeout);
}
this.warningContainerTimeout = setTimeout(() => {
this.warningContainer?.destroy();
this.warningContainer = null;
this.warningContainerTimeout = null;
}, 120000);
}
private closeSideMenu(): void { private closeSideMenu(): void {
if (!this.sideMenuOpened) return; if (!this.sideMenuOpened) return;
this.sideMenuOpened = false; this.sideMenuOpened = false;
@ -363,6 +344,9 @@ export class MenuScene extends Phaser.Scene {
case "editGameSettingsButton": case "editGameSettingsButton":
this.openGameSettingsMenu(); this.openGameSettingsMenu();
break; break;
case "oidcLogin":
connectionManager.loadOpenIDScreen();
break;
case "toggleFullscreen": case "toggleFullscreen":
this.toggleFullscreen(); this.toggleFullscreen();
break; break;
@ -403,7 +387,7 @@ export class MenuScene extends Phaser.Scene {
private gotToCreateMapPage() { private gotToCreateMapPage() {
//const sparkHost = 'https://'+window.location.host.replace('play.', '')+'/choose-map.html'; //const sparkHost = 'https://'+window.location.host.replace('play.', '')+'/choose-map.html';
//TODO fix me: this button can to send us on WorkAdventure BO. //TODO fix me: this button can to send us on WorkAdventure BO.
const sparkHost = "https://workadventu.re/getting-started"; const sparkHost = ADMIN_URL + "/getting-started";
window.open(sparkHost, "_blank"); window.open(sparkHost, "_blank");
} }

View file

@ -1,14 +1,14 @@
import {TextField} from "../Components/TextField"; import { TextField } from "../Components/TextField";
import Image = Phaser.GameObjects.Image; import Image = Phaser.GameObjects.Image;
import Sprite = Phaser.GameObjects.Sprite; import Sprite = Phaser.GameObjects.Sprite;
import Text = Phaser.GameObjects.Text; import Text = Phaser.GameObjects.Text;
import ScenePlugin = Phaser.Scenes.ScenePlugin; import ScenePlugin = Phaser.Scenes.ScenePlugin;
import {WAError} from "./WAError"; import { WAError } from "./WAError";
export const ErrorSceneName = "ErrorScene"; export const ErrorSceneName = "ErrorScene";
enum Textures { enum Textures {
icon = "icon", icon = "icon",
mainFont = "main_font" mainFont = "main_font",
} }
export class ErrorScene extends Phaser.Scene { export class ErrorScene extends Phaser.Scene {
@ -23,25 +23,21 @@ export class ErrorScene extends Phaser.Scene {
constructor() { constructor() {
super({ super({
key: ErrorSceneName key: ErrorSceneName,
}); });
} }
init({title, subTitle, message}: { title?: string, subTitle?: string, message?: string }) { init({ title, subTitle, message }: { title?: string; subTitle?: string; message?: string }) {
this.title = title ? title : ''; this.title = title ? title : "";
this.subTitle = subTitle ? subTitle : ''; this.subTitle = subTitle ? subTitle : "";
this.message = message ? message : ''; this.message = message ? message : "";
} }
preload() { preload() {
this.load.image(Textures.icon, "static/images/favicons/favicon-32x32.png"); this.load.image(Textures.icon, "static/images/favicons/favicon-32x32.png");
// Note: arcade.png from the Phaser 3 examples at: https://github.com/photonstorm/phaser3-examples/tree/master/public/assets/fonts/bitmap // Note: arcade.png from the Phaser 3 examples at: https://github.com/photonstorm/phaser3-examples/tree/master/public/assets/fonts/bitmap
this.load.bitmapFont(Textures.mainFont, 'resources/fonts/arcade.png', 'resources/fonts/arcade.xml'); this.load.bitmapFont(Textures.mainFont, "resources/fonts/arcade.png", "resources/fonts/arcade.xml");
this.load.spritesheet( this.load.spritesheet("cat", "resources/characters/pipoya/Cat 01-1.png", { frameWidth: 32, frameHeight: 32 });
'cat',
'resources/characters/pipoya/Cat 01-1.png',
{frameWidth: 32, frameHeight: 32}
);
} }
create() { create() {
@ -50,15 +46,25 @@ export class ErrorScene extends Phaser.Scene {
this.titleField = new TextField(this, this.game.renderer.width / 2, this.game.renderer.height / 2, this.title); this.titleField = new TextField(this, this.game.renderer.width / 2, this.game.renderer.height / 2, this.title);
this.subTitleField = new TextField(this, this.game.renderer.width / 2, this.game.renderer.height / 2 + 24, this.subTitle); this.subTitleField = new TextField(
this,
this.game.renderer.width / 2,
this.game.renderer.height / 2 + 24,
this.subTitle
);
this.messageField = this.add.text(this.game.renderer.width / 2, this.game.renderer.height / 2 + 48, this.message, { this.messageField = this.add.text(
fontFamily: 'Georgia, "Goudy Bookletter 1911", Times, serif', this.game.renderer.width / 2,
fontSize: '10px' this.game.renderer.height / 2 + 48,
}); this.message,
{
fontFamily: 'Georgia, "Goudy Bookletter 1911", Times, serif',
fontSize: "10px",
}
);
this.messageField.setOrigin(0.5, 0.5); this.messageField.setOrigin(0.5, 0.5);
this.cat = this.physics.add.sprite(this.game.renderer.width / 2, this.game.renderer.height / 2 - 32, 'cat', 6); this.cat = this.physics.add.sprite(this.game.renderer.width / 2, this.game.renderer.height / 2 - 32, "cat", 6);
this.cat.flipY = true; this.cat.flipY = true;
} }
@ -69,38 +75,38 @@ export class ErrorScene extends Phaser.Scene {
public static showError(error: any, scene: ScenePlugin): void { public static showError(error: any, scene: ScenePlugin): void {
console.error(error); console.error(error);
if (typeof error === 'string' || error instanceof String) { if (typeof error === "string" || error instanceof String) {
scene.start(ErrorSceneName, { scene.start(ErrorSceneName, {
title: 'An error occurred', title: "An error occurred",
subTitle: error subTitle: error,
}); });
} else if (error instanceof WAError) { } else if (error instanceof WAError) {
scene.start(ErrorSceneName, { scene.start(ErrorSceneName, {
title: error.title, title: error.title,
subTitle: error.subTitle, subTitle: error.subTitle,
message: error.details message: error.details,
}); });
} else if (error.response) { } else if (error.response) {
// Axios HTTP error // Axios HTTP error
// client received an error response (5xx, 4xx) // client received an error response (5xx, 4xx)
scene.start(ErrorSceneName, { scene.start(ErrorSceneName, {
title: 'HTTP ' + error.response.status + ' - ' + error.response.statusText, title: "HTTP " + error.response.status + " - " + error.response.statusText,
subTitle: 'An error occurred while accessing URL:', subTitle: "An error occurred while accessing URL:",
message: error.response.config.url message: error.response.config.url,
}); });
} else if (error.request) { } else if (error.request) {
// Axios HTTP error // Axios HTTP error
// client never received a response, or request never left // client never received a response, or request never left
scene.start(ErrorSceneName, { scene.start(ErrorSceneName, {
title: 'Network error', title: "Network error",
subTitle: error.message subTitle: error.message,
}); });
} else if (error instanceof Error) { } else if (error instanceof Error) {
// Error // Error
scene.start(ErrorSceneName, { scene.start(ErrorSceneName, {
title: 'An error occurred', title: "An error occurred",
subTitle: error.name, subTitle: error.name,
message: error.message message: error.message,
}); });
} else { } else {
throw error; throw error;
@ -114,7 +120,7 @@ export class ErrorScene extends Phaser.Scene {
scene.start(ErrorSceneName, { scene.start(ErrorSceneName, {
title, title,
subTitle, subTitle,
message message,
}); });
} }
} }

View file

@ -1,11 +1,11 @@
import {TextField} from "../Components/TextField"; import { TextField } from "../Components/TextField";
import Image = Phaser.GameObjects.Image; import Image = Phaser.GameObjects.Image;
import Sprite = Phaser.GameObjects.Sprite; import Sprite = Phaser.GameObjects.Sprite;
export const ReconnectingSceneName = "ReconnectingScene"; export const ReconnectingSceneName = "ReconnectingScene";
enum ReconnectingTextures { enum ReconnectingTextures {
icon = "icon", icon = "icon",
mainFont = "main_font" mainFont = "main_font",
} }
export class ReconnectingScene extends Phaser.Scene { export class ReconnectingScene extends Phaser.Scene {
@ -14,35 +14,40 @@ export class ReconnectingScene extends Phaser.Scene {
constructor() { constructor() {
super({ super({
key: ReconnectingSceneName key: ReconnectingSceneName,
}); });
} }
preload() { preload() {
this.load.image(ReconnectingTextures.icon, "static/images/favicons/favicon-32x32.png"); this.load.image(ReconnectingTextures.icon, "static/images/favicons/favicon-32x32.png");
// Note: arcade.png from the Phaser 3 examples at: https://github.com/photonstorm/phaser3-examples/tree/master/public/assets/fonts/bitmap // Note: arcade.png from the Phaser 3 examples at: https://github.com/photonstorm/phaser3-examples/tree/master/public/assets/fonts/bitmap
this.load.bitmapFont(ReconnectingTextures.mainFont, 'resources/fonts/arcade.png', 'resources/fonts/arcade.xml'); this.load.bitmapFont(ReconnectingTextures.mainFont, "resources/fonts/arcade.png", "resources/fonts/arcade.xml");
this.load.spritesheet( this.load.spritesheet("cat", "resources/characters/pipoya/Cat 01-1.png", { frameWidth: 32, frameHeight: 32 });
'cat',
'resources/characters/pipoya/Cat 01-1.png',
{frameWidth: 32, frameHeight: 32}
);
} }
create() { create() {
this.logo = new Image(this, this.game.renderer.width - 30, this.game.renderer.height - 20, ReconnectingTextures.icon); this.logo = new Image(
this,
this.game.renderer.width - 30,
this.game.renderer.height - 20,
ReconnectingTextures.icon
);
this.add.existing(this.logo); this.add.existing(this.logo);
this.reconnectingField = new TextField(this, this.game.renderer.width / 2, this.game.renderer.height / 2, "Connection lost. Reconnecting..."); this.reconnectingField = new TextField(
this,
this.game.renderer.width / 2,
this.game.renderer.height / 2,
"Connection lost. Reconnecting..."
);
const cat = this.physics.add.sprite(this.game.renderer.width / 2, this.game.renderer.height / 2 - 32, 'cat'); const cat = this.physics.add.sprite(this.game.renderer.width / 2, this.game.renderer.height / 2 - 32, "cat");
this.anims.create({ this.anims.create({
key: 'right', key: "right",
frames: this.anims.generateFrameNumbers('cat', { start: 6, end: 8 }), frames: this.anims.generateFrameNumbers("cat", { start: 6, end: 8 }),
frameRate: 10, frameRate: 10,
repeat: -1 repeat: -1,
}); });
cat.play('right'); cat.play("right");
} }
} }

View file

@ -21,7 +21,7 @@ export enum UserInputEvent {
} }
//we cannot use a map structure so we have to create a replacment //we cannot use a map structure so we have to create a replacement
export class ActiveEventList { export class ActiveEventList {
private eventMap : Map<UserInputEvent, boolean> = new Map<UserInputEvent, boolean>(); private eventMap : Map<UserInputEvent, boolean> = new Map<UserInputEvent, boolean>();

View file

@ -2,4 +2,6 @@ import { writable } from "svelte/store";
export const userMovingStore = writable(false); export const userMovingStore = writable(false);
export const requestVisitCardsStore = writable<string|null>(null); export const requestVisitCardsStore = writable<string | null>(null);
export const userIsAdminStore = writable(false);

View file

@ -274,12 +274,12 @@ export const mediaStreamConstraintsStore = derived(
currentAudioConstraint = false; currentAudioConstraint = false;
} }
// Disable webcam for privacy reasons (the game is not visible and we were talking to noone) // Disable webcam for privacy reasons (the game is not visible and we were talking to no one)
if ($privacyShutdownStore === true) { if ($privacyShutdownStore === true) {
currentVideoConstraint = false; currentVideoConstraint = false;
} }
// Disable webcam for energy reasons (the user is not moving and we are talking to noone) // Disable webcam for energy reasons (the user is not moving and we are talking to no one)
if ($cameraEnergySavingStore === true) { if ($cameraEnergySavingStore === true) {
currentVideoConstraint = false; currentVideoConstraint = false;
currentAudioConstraint = false; currentAudioConstraint = false;

View file

@ -1,3 +1,23 @@
import { derived, writable, Writable } from "svelte/store"; import { writable } from "svelte/store";
import Timeout = NodeJS.Timeout;
export const menuIconVisible = writable(false); export const menuIconVisible = writable(false);
let warningContainerTimeout: Timeout | null = null;
function createWarningContainerStore() {
const { subscribe, set } = writable<boolean>(false);
return {
subscribe,
activateWarningContainer() {
set(true);
if (warningContainerTimeout) clearTimeout(warningContainerTimeout);
warningContainerTimeout = setTimeout(() => {
set(false);
warningContainerTimeout = null;
}, 120000);
},
};
}
export const warningContainerStore = createWarningContainerStore();

View file

@ -1,45 +1,46 @@
import type {Room} from "../Connexion/Room"; import type { Room } from "../Connexion/Room";
export enum GameConnexionTypes { export enum GameConnexionTypes {
anonymous=1, anonymous = 1,
organization, organization,
register, register,
empty, empty,
unknown, unknown,
jwt,
} }
//this class is responsible with analysing and editing the game's url //this class is responsible with analysing and editing the game's url
class UrlManager { class UrlManager {
//todo: use that to detect if we can find a token in localstorage
public getGameConnexionType(): GameConnexionTypes { public getGameConnexionType(): GameConnexionTypes {
const url = window.location.pathname.toString(); const url = window.location.pathname.toString();
if (url.includes('_/')) { if (url === "/jwt") {
return GameConnexionTypes.jwt;
} else if (url.includes("_/")) {
return GameConnexionTypes.anonymous; return GameConnexionTypes.anonymous;
} else if (url.includes('@/')) { } else if (url.includes("@/")) {
return GameConnexionTypes.organization; return GameConnexionTypes.organization;
} else if(url.includes('register/')) { } else if (url.includes("register/")) {
return GameConnexionTypes.register; return GameConnexionTypes.register;
} else if(url === '/') { } else if (url === "/") {
return GameConnexionTypes.empty; return GameConnexionTypes.empty;
} else { } else {
return GameConnexionTypes.unknown; return GameConnexionTypes.unknown;
} }
} }
public getOrganizationToken(): string|null { public getOrganizationToken(): string | null {
const match = /\/register\/(.+)/.exec(window.location.pathname.toString()); const match = /\/register\/(.+)/.exec(window.location.pathname.toString());
return match ? match [1] : null; return match ? match[1] : null;
} }
public pushRoomIdToUrl(room:Room): void { public pushRoomIdToUrl(room: Room): void {
if (window.location.pathname === room.id) return; if (window.location.pathname === room.id) return;
const hash = window.location.hash; const hash = window.location.hash;
const search = room.search.toString(); const search = room.search.toString();
history.pushState({}, 'WorkAdventure', room.id+(search?'?'+search:'')+hash); history.pushState({}, "WorkAdventure", room.id + (search ? "?" + search : "") + hash);
} }
public getStartLayerNameFromUrl(): string|null { public getStartLayerNameFromUrl(): string | null {
const hash = window.location.hash; const hash = window.location.hash;
return hash.length > 1 ? hash.substring(1) : null; return hash.length > 1 ? hash.substring(1) : null;
} }

View file

@ -2,9 +2,9 @@ export class HtmlUtils {
public static getElementByIdOrFail<T extends HTMLElement>(id: string): T { public static getElementByIdOrFail<T extends HTMLElement>(id: string): T {
const elem = document.getElementById(id); const elem = document.getElementById(id);
if (HtmlUtils.isHtmlElement<T>(elem)) { if (HtmlUtils.isHtmlElement<T>(elem)) {
return elem; return elem;
} }
throw new Error("Cannot find HTML element with id '"+id+"'"); throw new Error("Cannot find HTML element with id '" + id + "'");
} }
public static querySelectorOrFail<T extends HTMLElement>(selector: string): T { public static querySelectorOrFail<T extends HTMLElement>(selector: string): T {
@ -12,7 +12,7 @@ export class HtmlUtils {
if (HtmlUtils.isHtmlElement<T>(elem)) { if (HtmlUtils.isHtmlElement<T>(elem)) {
return elem; return elem;
} }
throw new Error("Cannot find HTML element with selector '"+selector+"'"); throw new Error("Cannot find HTML element with selector '" + selector + "'");
} }
public static removeElementByIdOrFail<T extends HTMLElement>(id: string): T { public static removeElementByIdOrFail<T extends HTMLElement>(id: string): T {
@ -21,12 +21,12 @@ export class HtmlUtils {
elem.remove(); elem.remove();
return elem; return elem;
} }
throw new Error("Cannot find HTML element with id '"+id+"'"); throw new Error("Cannot find HTML element with id '" + id + "'");
} }
public static escapeHtml(html: string): string { public static escapeHtml(html: string): string {
const text = document.createTextNode(html.replace(/(\r\n|\r|\n)/g,'<br/>')); const text = document.createTextNode(html.replace(/(\r\n|\r|\n)/g, "<br/>"));
const p = document.createElement('p'); const p = document.createElement("p");
p.appendChild(text); p.appendChild(text);
return p.innerHTML; return p.innerHTML;
} }
@ -35,7 +35,7 @@ export class HtmlUtils {
const urlRegex = /(https?:\/\/[^\s]+)/g; const urlRegex = /(https?:\/\/[^\s]+)/g;
text = HtmlUtils.escapeHtml(text); text = HtmlUtils.escapeHtml(text);
return text.replace(urlRegex, (url: string) => { return text.replace(urlRegex, (url: string) => {
const link = document.createElement('a'); const link = document.createElement("a");
link.href = url; link.href = url;
link.target = "_blank"; link.target = "_blank";
const text = document.createTextNode(url); const text = document.createTextNode(url);

View file

@ -295,7 +295,7 @@ export class SimplePeer {
// I do understand the method closeConnection is called twice, but I don't understand how they manage to run in parallel. // I do understand the method closeConnection is called twice, but I don't understand how they manage to run in parallel.
peer.destroy(); peer.destroy();
//Comment this peer connexion because if we delete and try to reshare screen, the RTCPeerConnection send renegociate event. This array will be remove when user left circle discussion //Comment this peer connection because if we delete and try to reshare screen, the RTCPeerConnection send renegotiate event. This array will be remove when user left circle discussion
/*if(!this.PeerScreenSharingConnectionArray.delete(userId)){ /*if(!this.PeerScreenSharingConnectionArray.delete(userId)){
throw 'Couln\'t delete peer screen sharing connexion'; throw 'Couln\'t delete peer screen sharing connexion';
}*/ }*/
@ -370,14 +370,14 @@ export class SimplePeer {
console.error( console.error(
'Could not find peer whose ID is "' + data.userId + '" in receiveWebrtcScreenSharingSignal' 'Could not find peer whose ID is "' + data.userId + '" in receiveWebrtcScreenSharingSignal'
); );
console.info("Attempt to create new peer connexion"); console.info("Attempt to create new peer connection");
if (stream) { if (stream) {
this.sendLocalScreenSharingStreamToUser(data.userId, stream); this.sendLocalScreenSharingStreamToUser(data.userId, stream);
} }
} }
} catch (e) { } catch (e) {
console.error(`receiveWebrtcSignal => ${data.userId}`, e); console.error(`receiveWebrtcSignal => ${data.userId}`, e);
//Comment this peer connexion because if we delete and try to reshare screen, the RTCPeerConnection send renegociate event. This array will be remove when user left circle discussion //Comment this peer connection because if we delete and try to reshare screen, the RTCPeerConnection send renegotiate event. This array will be remove when user left circle discussion
//this.PeerScreenSharingConnectionArray.delete(data.userId); //this.PeerScreenSharingConnectionArray.delete(data.userId);
this.receiveWebrtcScreenSharingSignal(data); this.receiveWebrtcScreenSharingSignal(data);
} }
@ -485,7 +485,7 @@ export class SimplePeer {
if (!PeerConnectionScreenSharing.isReceivingScreenSharingStream()) { if (!PeerConnectionScreenSharing.isReceivingScreenSharingStream()) {
PeerConnectionScreenSharing.destroy(); PeerConnectionScreenSharing.destroy();
//Comment this peer connexion because if we delete and try to reshare screen, the RTCPeerConnection send renegociate event. This array will be remove when user left circle discussion //Comment this peer connection because if we delete and try to reshare screen, the RTCPeerConnection send renegotiate event. This array will be remove when user left circle discussion
//this.PeerScreenSharingConnectionArray.delete(userId); //this.PeerScreenSharingConnectionArray.delete(userId);
} }
} }

View file

@ -1,7 +1,9 @@
import { registeredCallbacks } from "./Api/iframe/registeredCallbacks"; import { registeredCallbacks } from "./Api/iframe/registeredCallbacks";
import { import {
IframeResponseEvent, IframeResponseEvent,
IframeResponseEventMap, isIframeAnswerEvent, isIframeErrorAnswerEvent, IframeResponseEventMap,
isIframeAnswerEvent,
isIframeErrorAnswerEvent,
isIframeResponseEventWrapper, isIframeResponseEventWrapper,
TypedMessageEvent, TypedMessageEvent,
} from "./Api/Events/IframeEvent"; } from "./Api/Events/IframeEvent";
@ -11,12 +13,26 @@ import nav from "./Api/iframe/nav";
import controls from "./Api/iframe/controls"; import controls from "./Api/iframe/controls";
import ui from "./Api/iframe/ui"; import ui from "./Api/iframe/ui";
import sound from "./Api/iframe/sound"; import sound from "./Api/iframe/sound";
import room from "./Api/iframe/room"; import room, { setMapURL, setRoomId } from "./Api/iframe/room";
import player from "./Api/iframe/player"; import state, { initVariables } from "./Api/iframe/state";
import player, { setPlayerName, setTags, setUuid } from "./Api/iframe/player";
import type { ButtonDescriptor } from "./Api/iframe/Ui/ButtonDescriptor"; import type { ButtonDescriptor } from "./Api/iframe/Ui/ButtonDescriptor";
import type { Popup } from "./Api/iframe/Ui/Popup"; import type { Popup } from "./Api/iframe/Ui/Popup";
import type { Sound } from "./Api/iframe/Sound/Sound"; import type { Sound } from "./Api/iframe/Sound/Sound";
import { answerPromises, sendToWorkadventure} from "./Api/iframe/IframeApiContribution"; import { answerPromises, queryWorkadventure, sendToWorkadventure } from "./Api/iframe/IframeApiContribution";
// Notify WorkAdventure that we are ready to receive data
const initPromise = queryWorkadventure({
type: "getState",
data: undefined,
}).then((state) => {
setPlayerName(state.nickname);
setRoomId(state.roomId);
setMapURL(state.mapUrl);
setTags(state.tags);
setUuid(state.uuid);
initVariables(state.variables as Map<string, unknown>);
});
const wa = { const wa = {
ui, ui,
@ -26,6 +42,11 @@ const wa = {
sound, sound,
room, room,
player, player,
state,
onInit(): Promise<void> {
return initPromise;
},
// All methods below are deprecated and should not be used anymore. // All methods below are deprecated and should not be used anymore.
// They are kept here for backward compatibility. // They are kept here for backward compatibility.
@ -123,7 +144,7 @@ const wa = {
}, },
/** /**
* @deprecated Use WA.controls.restorePlayerControls instead * @deprecated Use WA.ui.openPopup instead
*/ */
openPopup(targetObject: string, message: string, buttons: ButtonDescriptor[]): Popup { openPopup(targetObject: string, message: string, buttons: ButtonDescriptor[]): Popup {
console.warn("Method WA.openPopup is deprecated. Please use WA.ui.openPopup instead"); console.warn("Method WA.openPopup is deprecated. Please use WA.ui.openPopup instead");
@ -164,38 +185,39 @@ declare global {
window.WA = wa; window.WA = wa;
window.addEventListener( window.addEventListener(
"message", <T extends keyof IframeResponseEventMap>(message: TypedMessageEvent<IframeResponseEvent<T>>) => { "message",
if (message.source !== window.parent) { <T extends keyof IframeResponseEventMap>(message: TypedMessageEvent<IframeResponseEvent<T>>) => {
return; // Skip message in this event listener if (message.source !== window.parent) {
} return; // Skip message in this event listener
const payload = message.data;
console.debug(payload);
if (isIframeAnswerEvent(payload)) {
const queryId = payload.id;
const payloadData = payload.data;
const resolver = answerPromises.get(queryId);
if (resolver === undefined) {
throw new Error('In Iframe API, got an answer for a question that we have no track of.');
} }
resolver.resolve(payloadData); const payload = message.data;
answerPromises.delete(queryId); //console.debug(payload);
} else if (isIframeErrorAnswerEvent(payload)) {
const queryId = payload.id;
const payloadError = payload.error;
const resolver = answerPromises.get(queryId); if (isIframeErrorAnswerEvent(payload)) {
if (resolver === undefined) { const queryId = payload.id;
throw new Error('In Iframe API, got an error answer for a question that we have no track of.'); const payloadError = payload.error;
}
resolver.reject(payloadError);
answerPromises.delete(queryId); const resolver = answerPromises.get(queryId);
} else if (isIframeResponseEventWrapper(payload)) { if (resolver === undefined) {
const payloadData = payload.data; throw new Error("In Iframe API, got an error answer for a question that we have no track of.");
}
resolver.reject(new Error(payloadError));
answerPromises.delete(queryId);
} else if (isIframeAnswerEvent(payload)) {
const queryId = payload.id;
const payloadData = payload.data;
const resolver = answerPromises.get(queryId);
if (resolver === undefined) {
throw new Error("In Iframe API, got an answer for a question that we have no track of.");
}
resolver.resolve(payloadData);
answerPromises.delete(queryId);
} else if (isIframeResponseEventWrapper(payload)) {
const payloadData = payload.data;
const callback = registeredCallbacks[payload.type] as IframeCallback<T> | undefined; const callback = registeredCallbacks[payload.type] as IframeCallback<T> | undefined;
if (callback?.typeChecker(payloadData)) { if (callback?.typeChecker(payloadData)) {

View file

@ -1,35 +1,34 @@
import 'phaser'; import "phaser";
import GameConfig = Phaser.Types.Core.GameConfig; import GameConfig = Phaser.Types.Core.GameConfig;
import "../style/index.scss"; import "../style/index.scss";
import {DEBUG_MODE, isMobile} from "./Enum/EnvironmentVariable"; import { DEBUG_MODE, isMobile } from "./Enum/EnvironmentVariable";
import {LoginScene} from "./Phaser/Login/LoginScene"; import { LoginScene } from "./Phaser/Login/LoginScene";
import {ReconnectingScene} from "./Phaser/Reconnecting/ReconnectingScene"; import { ReconnectingScene } from "./Phaser/Reconnecting/ReconnectingScene";
import {SelectCharacterScene} from "./Phaser/Login/SelectCharacterScene"; import { SelectCharacterScene } from "./Phaser/Login/SelectCharacterScene";
import {SelectCompanionScene} from "./Phaser/Login/SelectCompanionScene"; import { SelectCompanionScene } from "./Phaser/Login/SelectCompanionScene";
import {EnableCameraScene} from "./Phaser/Login/EnableCameraScene"; import { EnableCameraScene } from "./Phaser/Login/EnableCameraScene";
import {CustomizeScene} from "./Phaser/Login/CustomizeScene"; import { CustomizeScene } from "./Phaser/Login/CustomizeScene";
import WebFontLoaderPlugin from 'phaser3-rex-plugins/plugins/webfontloader-plugin.js'; import WebFontLoaderPlugin from "phaser3-rex-plugins/plugins/webfontloader-plugin.js";
import OutlinePipelinePlugin from 'phaser3-rex-plugins/plugins/outlinepipeline-plugin.js'; import OutlinePipelinePlugin from "phaser3-rex-plugins/plugins/outlinepipeline-plugin.js";
import {EntryScene} from "./Phaser/Login/EntryScene"; import { EntryScene } from "./Phaser/Login/EntryScene";
import {coWebsiteManager} from "./WebRtc/CoWebsiteManager"; import { coWebsiteManager } from "./WebRtc/CoWebsiteManager";
import {MenuScene} from "./Phaser/Menu/MenuScene"; import { MenuScene } from "./Phaser/Menu/MenuScene";
import {localUserStore} from "./Connexion/LocalUserStore"; import { localUserStore } from "./Connexion/LocalUserStore";
import {ErrorScene} from "./Phaser/Reconnecting/ErrorScene"; import { ErrorScene } from "./Phaser/Reconnecting/ErrorScene";
import {iframeListener} from "./Api/IframeListener"; import { iframeListener } from "./Api/IframeListener";
import { SelectCharacterMobileScene } from './Phaser/Login/SelectCharacterMobileScene'; import { SelectCharacterMobileScene } from "./Phaser/Login/SelectCharacterMobileScene";
import {HdpiManager} from "./Phaser/Services/HdpiManager"; import { HdpiManager } from "./Phaser/Services/HdpiManager";
import {waScaleManager} from "./Phaser/Services/WaScaleManager"; import { waScaleManager } from "./Phaser/Services/WaScaleManager";
import {Game} from "./Phaser/Game/Game"; import { Game } from "./Phaser/Game/Game";
import App from './Components/App.svelte'; import App from "./Components/App.svelte";
import {HtmlUtils} from "./WebRtc/HtmlUtils"; import { HtmlUtils } from "./WebRtc/HtmlUtils";
import WebGLRenderer = Phaser.Renderer.WebGL.WebGLRenderer; import WebGLRenderer = Phaser.Renderer.WebGL.WebGLRenderer;
const { width, height } = coWebsiteManager.getGameSize();
const {width, height} = coWebsiteManager.getGameSize();
const valueGameQuality = localUserStore.getGameQualityValue(); const valueGameQuality = localUserStore.getGameQualityValue();
const fps : Phaser.Types.Core.FPSConfig = { const fps: Phaser.Types.Core.FPSConfig = {
/** /**
* The minimum acceptable rendering rate, in frames per second. * The minimum acceptable rendering rate, in frames per second.
*/ */
@ -53,30 +52,30 @@ const fps : Phaser.Types.Core.FPSConfig = {
/** /**
* Apply delta smoothing during the game update to help avoid spikes? * Apply delta smoothing during the game update to help avoid spikes?
*/ */
smoothStep: false smoothStep: false,
} };
// the ?phaserMode=canvas parameter can be used to force Canvas usage // the ?phaserMode=canvas parameter can be used to force Canvas usage
const params = new URLSearchParams(document.location.search.substring(1)); const params = new URLSearchParams(document.location.search.substring(1));
const phaserMode = params.get("phaserMode"); const phaserMode = params.get("phaserMode");
let mode: number; let mode: number;
switch (phaserMode) { switch (phaserMode) {
case 'auto': case "auto":
case null: case null:
mode = Phaser.AUTO; mode = Phaser.AUTO;
break; break;
case 'canvas': case "canvas":
mode = Phaser.CANVAS; mode = Phaser.CANVAS;
break; break;
case 'webgl': case "webgl":
mode = Phaser.WEBGL; mode = Phaser.WEBGL;
break; break;
default: default:
throw new Error('phaserMode parameter must be one of "auto", "canvas" or "webgl"'); throw new Error('phaserMode parameter must be one of "auto", "canvas" or "webgl"');
} }
const hdpiManager = new HdpiManager(640*480, 196*196); const hdpiManager = new HdpiManager(640 * 480, 196 * 196);
const { game: gameSize, real: realSize } = hdpiManager.getOptimalGameSize({width, height}); const { game: gameSize, real: realSize } = hdpiManager.getOptimalGameSize({ width, height });
const config: GameConfig = { const config: GameConfig = {
type: mode, type: mode,
@ -87,9 +86,10 @@ const config: GameConfig = {
height: gameSize.height, height: gameSize.height,
zoom: realSize.width / gameSize.width, zoom: realSize.width / gameSize.width,
autoRound: true, autoRound: true,
resizeInterval: 999999999999 resizeInterval: 999999999999,
}, },
scene: [EntryScene, scene: [
EntryScene,
LoginScene, LoginScene,
isMobile() ? SelectCharacterMobileScene : SelectCharacterScene, isMobile() ? SelectCharacterMobileScene : SelectCharacterScene,
SelectCompanionScene, SelectCompanionScene,
@ -102,37 +102,39 @@ const config: GameConfig = {
//resolution: window.devicePixelRatio / 2, //resolution: window.devicePixelRatio / 2,
fps: fps, fps: fps,
dom: { dom: {
createContainer: true createContainer: true,
}, },
render: { render: {
pixelArt: true, pixelArt: true,
roundPixels: true, roundPixels: true,
antialias: false antialias: false,
}, },
plugins: { plugins: {
global: [{ global: [
key: 'rexWebFontLoader', {
plugin: WebFontLoaderPlugin, key: "rexWebFontLoader",
start: true plugin: WebFontLoaderPlugin,
}] start: true,
},
],
}, },
physics: { physics: {
default: "arcade", default: "arcade",
arcade: { arcade: {
debug: DEBUG_MODE, debug: DEBUG_MODE,
} },
}, },
// Instruct systems with 2 GPU to choose the low power one. We don't need that extra power and we want to save battery // Instruct systems with 2 GPU to choose the low power one. We don't need that extra power and we want to save battery
powerPreference: "low-power", powerPreference: "low-power",
callbacks: { callbacks: {
postBoot: game => { postBoot: (game) => {
// Install rexOutlinePipeline only if the renderer is WebGL. // Install rexOutlinePipeline only if the renderer is WebGL.
const renderer = game.renderer; const renderer = game.renderer;
if (renderer instanceof WebGLRenderer) { if (renderer instanceof WebGLRenderer) {
game.plugins.install('rexOutlinePipeline', OutlinePipelinePlugin, true); game.plugins.install("rexOutlinePipeline", OutlinePipelinePlugin, true);
} }
} },
} },
}; };
//const game = new Phaser.Game(config); //const game = new Phaser.Game(config);
@ -140,7 +142,7 @@ const game = new Game(config);
waScaleManager.setGame(game); waScaleManager.setGame(game);
window.addEventListener('resize', function (event) { window.addEventListener("resize", function (event) {
coWebsiteManager.resetStyle(); coWebsiteManager.resetStyle();
waScaleManager.applyNewSize(); waScaleManager.applyNewSize();
@ -153,22 +155,10 @@ coWebsiteManager.onResize.subscribe(() => {
iframeListener.init(); iframeListener.init();
const app = new App({ const app = new App({
target: HtmlUtils.getElementByIdOrFail('svelte-overlay'), target: HtmlUtils.getElementByIdOrFail("svelte-overlay"),
props: { props: {
game: game game: game,
}, },
}) });
export default app export default app;
if ('serviceWorker' in navigator) {
window.addEventListener('load', function () {
navigator.serviceWorker.register('/resources/service-worker.js')
.then(serviceWorker => {
console.log("Service Worker registered: ", serviceWorker);
})
.catch(error => {
console.error("Error registering the Service Worker: ", error);
});
});
}

View file

@ -3,4 +3,4 @@
@import "style"; @import "style";
@import "mobile-style.scss"; @import "mobile-style.scss";
@import "fonts.scss"; @import "fonts.scss";
@import "svelte-style.scss"; @import "inputTextGlobalMessageSvelte-Style.scss";

View file

@ -0,0 +1,24 @@
//InputTextGlobalMessage
section.section-input-send-text {
height: 100%;
.ql-toolbar{
max-height: 100px;
background: whitesmoke;
}
div.input-send-text{
height: auto;
max-height: calc(100% - 100px);
overflow: auto;
color: whitesmoke;
font-size: 1rem;
.ql-editor.ql-blank::before {
color: whitesmoke;
font-size: 1rem;
}
}
}

View file

@ -1,60 +0,0 @@
//Contains all styles not unique to a svelte component.
//ConsoleGlobalMessage
div.main-console.nes-container {
pointer-events: auto;
margin-left: auto;
margin-right: auto;
top: 20vh;
width: 50vw;
height: 50vh;
padding: 0;
background-color: #333333;
.btn-action{
margin: 10px;
text-align: center;
}
.main-global-message {
width: 100%;
max-height: 100%;
}
.main-global-message h2 {
text-align: center;
color: white;
}
div.global-message {
display: flex;
max-height: 100%;
width: 100%;
}
div.menu {
flex: auto;
}
div.menu button {
margin: 7px;
}
.main-input {
width: 95%;
}
//InputTextGlobalMessage
.section-input-send-text {
margin: 10px;
}
.section-input-send-text .input-send-text .ql-editor{
color: white;
min-height: 200px;
}
.section-input-send-text .ql-toolbar{
background: white;
}
}

View file

@ -8,19 +8,19 @@ describe("getRessourceDescriptor()", () => {
expect(desc.img).toEqual('url'); expect(desc.img).toEqual('url');
}); });
it(", if given a string as parameter, should search trough hardcoded values", () => { it(", if given a string as parameter, should search through hardcoded values", () => {
const desc = getRessourceDescriptor('male1'); const desc = getRessourceDescriptor('male1');
expect(desc.name).toEqual('male1'); expect(desc.name).toEqual('male1');
expect(desc.img).toEqual("resources/characters/pipoya/Male 01-1.png"); expect(desc.img).toEqual("resources/characters/pipoya/Male 01-1.png");
}); });
it(", if given a string as parameter, should search trough hardcoded values (bis)", () => { it(", if given a string as parameter, should search through hardcoded values (bis)", () => {
const desc = getRessourceDescriptor('color_2'); const desc = getRessourceDescriptor('color_2');
expect(desc.name).toEqual('color_2'); expect(desc.name).toEqual('color_2');
expect(desc.img).toEqual("resources/customisation/character_color/character_color1.png"); expect(desc.img).toEqual("resources/customisation/character_color/character_color1.png");
}); });
it(", if given a descriptor without url as parameter, should search trough hardcoded values", () => { it(", if given a descriptor without url as parameter, should search through hardcoded values", () => {
const desc = getRessourceDescriptor({name: 'male1', img: ''}); const desc = getRessourceDescriptor({name: 'male1', img: ''});
expect(desc.name).toEqual('male1'); expect(desc.name).toEqual('male1');
expect(desc.img).toEqual("resources/characters/pipoya/Male 01-1.png"); expect(desc.img).toEqual("resources/characters/pipoya/Male 01-1.png");

View file

@ -189,7 +189,7 @@ module.exports = {
DISABLE_NOTIFICATIONS: false, DISABLE_NOTIFICATIONS: false,
PUSHER_URL: undefined, PUSHER_URL: undefined,
UPLOADER_URL: null, UPLOADER_URL: null,
ADMIN_URL: null, ADMIN_URL: undefined,
DEBUG_MODE: null, DEBUG_MODE: null,
STUN_SERVER: null, STUN_SERVER: null,
TURN_SERVER: null, TURN_SERVER: null,

View file

@ -291,6 +291,18 @@
dependencies: dependencies:
source-map "^0.6.1" source-map "^0.6.1"
"@types/uuid@8.3.0":
version "8.3.0"
resolved "https://registry.yarnpkg.com/@types/uuid/-/uuid-8.3.0.tgz#215c231dff736d5ba92410e6d602050cce7e273f"
integrity sha512-eQ9qFW/fhfGJF8WKHGEHZEyVWfZxrT+6CLIJGBcZPfxUh/+BnEj+UCGYMlr9qZuX/2AltsvwrGqp0LhEW8D0zQ==
"@types/uuidv4@^5.0.0":
version "5.0.0"
resolved "https://registry.yarnpkg.com/@types/uuidv4/-/uuidv4-5.0.0.tgz#2c94e67b0c06d5adb28fb7ced1a1b5f0866ecd50"
integrity sha512-xUrhYSJnkTq9CP79cU3svoKTLPCIbMMnu9Twf/tMpHATYSHCAAeDNeb2a/29YORhk5p4atHhCTMsIBU/tvdh6A==
dependencies:
uuidv4 "*"
"@types/webpack-dev-server@^3.11.4": "@types/webpack-dev-server@^3.11.4":
version "3.11.4" version "3.11.4"
resolved "https://registry.yarnpkg.com/@types/webpack-dev-server/-/webpack-dev-server-3.11.4.tgz#90d47dd660b696d409431ab8c1e9fa3615103a07" resolved "https://registry.yarnpkg.com/@types/webpack-dev-server/-/webpack-dev-server-3.11.4.tgz#90d47dd660b696d409431ab8c1e9fa3615103a07"
@ -5775,11 +5787,24 @@ utils-merge@1.0.1:
resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713" resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713"
integrity sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM= integrity sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM=
uuid@8.3.2:
version "8.3.2"
resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.3.2.tgz#80d5b5ced271bb9af6c445f21a1a04c606cefbe2"
integrity sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==
uuid@^3.3.2, uuid@^3.4.0: uuid@^3.3.2, uuid@^3.4.0:
version "3.4.0" version "3.4.0"
resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.4.0.tgz#b23e4358afa8a202fe7a100af1f5f883f02007ee" resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.4.0.tgz#b23e4358afa8a202fe7a100af1f5f883f02007ee"
integrity sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A== integrity sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==
uuidv4@*, uuidv4@^6.2.10:
version "6.2.10"
resolved "https://registry.yarnpkg.com/uuidv4/-/uuidv4-6.2.10.tgz#42fc1c12b6f85ad536c2c5c1e836079d1e15003c"
integrity sha512-FMo1exd9l5UvoUPHRR6NrtJ/OJRePh0ca7IhPwBuMNuYRqjtuh8lE3WDxAUvZ4Yss5FbCOsPFjyWJf9lVTEmnw==
dependencies:
"@types/uuid" "8.3.0"
uuid "8.3.2"
v8-compile-cache@^2.0.3, v8-compile-cache@^2.2.0: v8-compile-cache@^2.0.3, v8-compile-cache@^2.2.0:
version "2.3.0" version "2.3.0"
resolved "https://registry.yarnpkg.com/v8-compile-cache/-/v8-compile-cache-2.3.0.tgz#2de19618c66dc247dcfb6f99338035d8245a2cee" resolved "https://registry.yarnpkg.com/v8-compile-cache/-/v8-compile-cache-2.3.0.tgz#2de19618c66dc247dcfb6f99338035d8245a2cee"

View file

@ -176,7 +176,7 @@ Tuomo Untinen CC-BY-3.0
Casper Nilsson Casper Nilsson
~~~~~~~~~~~~~~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~~~~~~~~~~~~~
- Asain themed shrine including red lantern - Asian themed shrine including red lantern
- foodog statue - foodog statue
- Toro - Toro
- Cherry blossom tree - Cherry blossom tree

View file

@ -1,13 +1,20 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<script src="http://play.workadventure.localhost/iframe_api.js"></script> <script>
</head> var script = document.createElement('script');
<body> // Don't do this at home kids! The "document.referrer" part is actually inserting a XSS security.
<script> // We are OK in this precise case because the HTML page is hosted on the "maps" domain that contains only static files.
WA.ui.registerMenuCommand("test", () => { script.setAttribute('src', document.referrer + 'iframe_api.js');
WA.chat.sendChatMessage("test clicked", "menu cmd") document.head.appendChild(script);
window.addEventListener('load', () => {
WA.ui.registerMenuCommand("test", () => {
WA.chat.sendChatMessage("test clicked", "menu cmd")
})
}) })
</script> </script>
</head>
<body>
<p>Add a custom menu</p>
</body> </body>
</html> </html>

View file

@ -1,16 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<script src="http://play.workadventure.localhost/iframe_api.js"></script>
</head>
<body>
<script>
WA.room.getCurrentRoom().then((room) => {
console.log('id : ', room.id);
console.log('map : ', room.map);
console.log('mapUrl : ', room.mapUrl);
console.log('startLayer : ', room.startLayer);
})
</script>
</body>
</html>

View file

@ -0,0 +1,11 @@
WA.onInit().then(() => {
console.log('id: ', WA.room.id);
console.log('Map URL: ', WA.room.mapURL);
console.log('Player name: ', WA.player.name);
console.log('Player id: ', WA.player.id);
console.log('Player tags: ', WA.player.tags);
});
WA.room.getTiledMap().then((data) => {
console.log('Map data', data);
})

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