Merge pull request #1239 from thecodingmachine/scripting_api_room_metadata

Allowing loading/saving "metadata" from a room
This commit is contained in:
David Négrier 2021-07-22 11:28:12 +02:00 committed by GitHub
commit 9b2914cc63
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
69 changed files with 2737 additions and 907 deletions

View File

@ -8,14 +8,24 @@
- 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)
- 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.hideLayer(): void` to hide 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.getCurrentUser(): Promise<User>` to get the ID, name and tags 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.ui.registerMenuCommand(): void` to add a custom menu
- Use `WA.player.id: string|undefined` to get the ID of the current player
- Use `WA.player.name: string` to get the name of the current player
- 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.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.
- The text chat was redesigned to be prettier and to use more features :
- The chat is now persistent bewteen discussions and always accesible

View File

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

View File

@ -5,35 +5,63 @@ import { PositionInterface } from "_Model/PositionInterface";
import { EmoteCallback, EntersCallback, LeavesCallback, MovesCallback } from "_Model/Zone";
import { PositionNotifier } from "./PositionNotifier";
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 { ZoneSocket } from "src/RoomManager";
import { RoomSocket, ZoneSocket } from "src/RoomManager";
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 DisconnectCallback = (user: User, group: Group) => void;
export class GameRoom {
private readonly minDistance: number;
private readonly groupRadius: number;
// Users, sorted by ID
private readonly users: Map<number, User>;
private readonly usersByUuid: Map<string, User>;
private readonly groups: Set<Group>;
private readonly admins: Set<Admin>;
private readonly users = new Map<number, User>();
private readonly usersByUuid = new Map<string, User>();
private readonly groups = new Set<Group>();
private readonly admins = new Set<Admin>();
private readonly connectCallback: ConnectCallback;
private readonly disconnectCallback: DisconnectCallback;
private itemsState: Map<number, unknown> = new Map<number, unknown>();
private itemsState = new Map<number, unknown>();
private readonly positionNotifier: PositionNotifier;
public readonly roomUrl: string;
private versionNumber: 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,
connectCallback: ConnectCallback,
disconnectCallback: DisconnectCallback,
@ -43,19 +71,23 @@ export class GameRoom {
onMoves: MovesCallback,
onLeaves: LeavesCallback,
onEmote: EmoteCallback
) {
this.roomUrl = roomUrl;
): Promise<GameRoom> {
const mapDetails = await GameRoom.getMapDetails(roomUrl);
this.users = new Map<number, User>();
this.usersByUuid = new Map<string, User>();
this.admins = new Set<Admin>();
this.groups = new Set<Group>();
this.connectCallback = connectCallback;
this.disconnectCallback = disconnectCallback;
this.minDistance = minDistance;
this.groupRadius = groupRadius;
// A zone is 10 sprites wide.
this.positionNotifier = new PositionNotifier(320, 320, onEnters, onMoves, onLeaves, onEmote);
const gameRoom = new GameRoom(
roomUrl,
mapDetails.mapUrl,
connectCallback,
disconnectCallback,
minDistance,
groupRadius,
onEnters,
onMoves,
onLeaves,
onEmote
);
return gameRoom;
}
public getGroups(): Group[] {
@ -289,6 +321,32 @@ export class GameRoom {
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);
// 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> {
return this.positionNotifier.addZoneListener(call, x, y);
}
@ -318,4 +376,98 @@ export class GameRoom {
public emitEmoteEvent(user: User, emoteEventMessage: 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

@ -5,6 +5,8 @@ import {
AdminPusherToBackMessage,
AdminRoomMessage,
BanMessage,
BatchToPusherMessage,
BatchToPusherRoomMessage,
EmotePromptMessage,
EmptyMessage,
ItemEventMessage,
@ -13,17 +15,18 @@ import {
PusherToBackMessage,
QueryJitsiJwtMessage,
RefreshRoomPromptMessage,
RoomMessage,
ServerToAdminClientMessage,
ServerToClientMessage,
SilentMessage,
UserMovesMessage,
VariableMessage,
WebRtcSignalToServerMessage,
WorldFullWarningToRoomMessage,
ZoneMessage,
} from "./Messages/generated/messages_pb";
import { sendUnaryData, ServerDuplexStream, ServerUnaryCall, ServerWritableStream } from "grpc";
import { socketManager } from "./Services/SocketManager";
import { emitError } from "./Services/MessageHelpers";
import { emitError, emitErrorOnRoomSocket, emitErrorOnZoneSocket } from "./Services/MessageHelpers";
import { User, UserSocket } from "./Model/User";
import { GameRoom } from "./Model/GameRoom";
import Debug from "debug";
@ -32,7 +35,8 @@ import { Admin } from "./Model/Admin";
const debug = Debug("roommanager");
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 = {
joinRoom: (call: UserSocket): void => {
@ -41,7 +45,7 @@ const roomManager: IRoomManagerServer = {
let room: GameRoom | null = null;
let user: User | null = null;
call.on("data", (message: PusherToBackMessage) => {
call.on("data", async (message: PusherToBackMessage) => {
try {
if (room === null || user === null) {
if (message.hasJoinroommessage()) {
@ -55,7 +59,8 @@ const roomManager: IRoomManagerServer = {
//Connexion may have been closed before the init was finished, so we have to manually disconnect the user.
socketManager.leaveRoom(gameRoom, myUser);
}
});
})
.catch((e) => emitError(call, e));
} else {
throw new Error("The first message sent MUST be of type JoinRoomMessage");
}
@ -72,6 +77,12 @@ const roomManager: IRoomManagerServer = {
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,
@ -112,6 +123,7 @@ const roomManager: IRoomManagerServer = {
}
}
} catch (e) {
console.error(e);
emitError(call, e);
call.end();
}
@ -136,20 +148,54 @@ const roomManager: IRoomManagerServer = {
debug("listenZone called");
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", () => {
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.on("close", () => {
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) => {
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();
});
},
@ -165,9 +211,12 @@ const roomManager: IRoomManagerServer = {
if (room === null) {
if (message.hasSubscribetoroom()) {
const roomId = message.getSubscribetoroom();
socketManager.handleJoinAdminRoom(admin, roomId).then((gameRoom: GameRoom) => {
socketManager
.handleJoinAdminRoom(admin, roomId)
.then((gameRoom: GameRoom) => {
room = gameRoom;
});
})
.catch((e) => console.error(e));
} else {
throw new Error("The first message sent MUST be of type JoinRoomMessage");
}
@ -192,11 +241,9 @@ const roomManager: IRoomManagerServer = {
});
},
sendAdminMessage(call: ServerUnaryCall<AdminMessage>, callback: sendUnaryData<EmptyMessage>): void {
socketManager.sendAdminMessage(
call.request.getRoomid(),
call.request.getRecipientuuid(),
call.request.getMessage()
);
socketManager
.sendAdminMessage(call.request.getRoomid(), call.request.getRecipientuuid(), call.request.getMessage())
.catch((e) => console.error(e));
callback(null, new EmptyMessage());
},
@ -207,26 +254,33 @@ const roomManager: IRoomManagerServer = {
},
ban(call: ServerUnaryCall<BanMessage>, callback: sendUnaryData<EmptyMessage>): void {
// 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());
},
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());
},
sendWorldFullWarningToRoom(
call: ServerUnaryCall<WorldFullWarningToRoomMessage>,
callback: sendUnaryData<EmptyMessage>
): 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());
},
sendRefreshRoomPrompt(
call: ServerUnaryCall<RefreshRoomPromptMessage>,
callback: sendUnaryData<EmptyMessage>
): 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());
},
};

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 { RoomSocket, ZoneSocket } from "../RoomManager";
export function emitError(Client: UserSocket, message: string): void {
const errorMessage = new ErrorMessage();
@ -13,3 +22,39 @@ export function emitError(Client: UserSocket, message: string): void {
//}
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,
RefreshRoomMessage,
EmotePromptMessage,
VariableMessage,
BatchToPusherRoomMessage,
SubToPusherRoomMessage,
} from "../Messages/generated/messages_pb";
import { User, UserSocket } from "../Model/User";
import { ProtobufUtils } from "../Model/Websocket/ProtobufUtils";
@ -48,7 +51,7 @@ import Jwt from "jsonwebtoken";
import { JITSI_URL } from "../Enum/EnvironmentVariable";
import { clientEventsEmitter } from "./ClientEventsEmitter";
import { gaugeManager } from "./GaugeManager";
import { ZoneSocket } from "../RoomManager";
import { RoomSocket, ZoneSocket } from "../RoomManager";
import { Zone } from "_Model/Zone";
import Debug from "debug";
import { Admin } from "_Model/Admin";
@ -65,7 +68,9 @@ function emitZoneMessage(subMessage: SubToPusherMessage, socket: ZoneSocket): vo
}
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() {
clientEventsEmitter.registerToClientJoin((clientUUid: string, roomId: string) => {
@ -101,6 +106,16 @@ export class SocketManager {
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);
const serverToClientMessage = new ServerToClientMessage();
@ -114,7 +129,6 @@ export class SocketManager {
}
handleUserMovesMessage(room: GameRoom, user: User, userMovesMessage: UserMovesMessage) {
try {
const userMoves = userMovesMessage.toObject();
const position = userMovesMessage.getPosition();
@ -134,10 +148,6 @@ export class SocketManager {
// 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);
}
}
// Useless now, will be useful again if we allow editing details in game
@ -156,18 +166,12 @@ export class SocketManager {
}*/
handleSilentMessage(room: GameRoom, user: User, silentMessage: SilentMessage) {
try {
room.setSilent(user, silentMessage.getSilent());
} catch (e) {
console.error('An error occurred on "handleSilentMessage"');
console.error(e);
}
}
handleItemEvent(room: GameRoom, user: User, itemEventMessage: ItemEventMessage) {
const itemEvent = ProtobufUtils.toItemEvent(itemEventMessage);
try {
const subMessage = new SubMessage();
subMessage.setItemeventmessage(itemEventMessage);
@ -178,10 +182,10 @@ export class SocketManager {
}
room.setItemState(itemEvent.itemId, itemEvent.state);
} catch (e) {
console.error('An error occurred on "item_event"');
console.error(e);
}
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 {
@ -250,7 +254,7 @@ export class SocketManager {
//user leave previous world
room.leave(user);
if (room.isEmpty()) {
this.rooms.delete(room.roomUrl);
this.roomsPromises.delete(room.roomUrl);
gaugeManager.decNbRoomGauge();
debug('Room is empty. Deleting room "%s"', room.roomUrl);
}
@ -261,10 +265,10 @@ export class SocketManager {
}
async getOrCreateRoom(roomId: string): Promise<GameRoom> {
//check and create new world for a room
let world = this.rooms.get(roomId);
if (world === undefined) {
world = new GameRoom(
//check and create new room
let roomPromise = this.roomsPromises.get(roomId);
if (roomPromise === undefined) {
roomPromise = GameRoom.create(
roomId,
(user: User, group: Group) => this.joinWebRtcRoom(user, group),
(user: User, group: Group) => this.disConnectedUser(user, group),
@ -278,11 +282,18 @@ export class SocketManager {
this.onClientLeave(thing, newZone, listener),
(emoteEventMessage: EmoteEventMessage, listener: ZoneSocket) =>
this.onEmote(emoteEventMessage, listener)
);
)
.then((gameRoom) => {
gaugeManager.incNbRoomGauge();
this.rooms.set(roomId, world);
return gameRoom;
})
.catch((e) => {
this.roomsPromises.delete(roomId);
throw e;
});
this.roomsPromises.set(roomId, roomPromise);
}
return Promise.resolve(world);
return roomPromise;
}
private async joinRoom(
@ -508,21 +519,16 @@ export class SocketManager {
}
emitPlayGlobalMessage(room: GameRoom, playGlobalMessage: PlayGlobalMessage) {
try {
const serverToClientMessage = new ServerToClientMessage();
serverToClientMessage.setPlayglobalmessage(playGlobalMessage);
for (const [id, user] of room.getUsers().entries()) {
user.socket.write(serverToClientMessage);
}
} catch (e) {
console.error('An error occurred on "emitPlayGlobalMessage" event');
console.error(e);
}
}
public getWorlds(): Map<string, GameRoom> {
return this.rooms;
public getWorlds(): Map<string, PromiseLike<GameRoom>> {
return this.roomsPromises;
}
public handleQueryJitsiJwtMessage(user: User, queryJitsiJwtMessage: QueryJitsiJwtMessage) {
@ -592,11 +598,10 @@ export class SocketManager {
}, 10000);
}
public addZoneListener(call: ZoneSocket, roomId: string, x: number, y: number): void {
const room = this.rooms.get(roomId);
public async addZoneListener(call: ZoneSocket, roomId: string, x: number, y: number): Promise<void> {
const room = await this.roomsPromises.get(roomId);
if (!room) {
console.error("In addZoneListener, could not find room with id '" + roomId + "'");
return;
throw new Error("In addZoneListener, could not find room with id '" + roomId + "'");
}
const things = room.addZoneListener(call, x, y);
@ -637,16 +642,37 @@ export class SocketManager {
call.write(batchMessage);
}
removeZoneListener(call: ZoneSocket, roomId: string, x: number, y: number) {
const room = this.rooms.get(roomId);
async removeZoneListener(call: ZoneSocket, roomId: string, x: number, y: number): Promise<void> {
const room = await this.roomsPromises.get(roomId);
if (!room) {
console.error("In removeZoneListener, could not find room with id '" + roomId + "'");
return;
throw new Error("In removeZoneListener, could not find room with id '" + roomId + "'");
}
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> {
const room = await socketManager.getOrCreateRoom(roomId);
@ -658,14 +684,14 @@ export class SocketManager {
public leaveAdminRoom(room: GameRoom, admin: Admin) {
room.adminLeave(admin);
if (room.isEmpty()) {
this.rooms.delete(room.roomUrl);
this.roomsPromises.delete(room.roomUrl);
gaugeManager.decNbRoomGauge();
debug('Room is empty. Deleting room "%s"', room.roomUrl);
}
}
public sendAdminMessage(roomId: string, recipientUuid: string, message: string): void {
const room = this.rooms.get(roomId);
public async sendAdminMessage(roomId: string, recipientUuid: string, message: string): Promise<void> {
const room = await this.roomsPromises.get(roomId);
if (!room) {
console.error(
"In sendAdminMessage, could not find room with id '" +
@ -695,8 +721,8 @@ export class SocketManager {
recipient.socket.write(serverToClientMessage);
}
public banUser(roomId: string, recipientUuid: string, message: string): void {
const room = this.rooms.get(roomId);
public async banUser(roomId: string, recipientUuid: string, message: string): Promise<void> {
const room = await this.roomsPromises.get(roomId);
if (!room) {
console.error(
"In banUser, could not find room with id '" +
@ -731,8 +757,8 @@ export class SocketManager {
recipient.socket.end();
}
sendAdminRoomMessage(roomId: string, message: string) {
const room = this.rooms.get(roomId);
async sendAdminRoomMessage(roomId: string, message: string) {
const room = await this.roomsPromises.get(roomId);
if (!room) {
//todo: this should cause the http call to return a 500
console.error(
@ -755,8 +781,8 @@ export class SocketManager {
});
}
dispatchWorlFullWarning(roomId: string): void {
const room = this.rooms.get(roomId);
async dispatchWorldFullWarning(roomId: string): Promise<void> {
const room = await this.roomsPromises.get(roomId);
if (!room) {
//todo: this should cause the http call to return a 500
console.error(
@ -777,8 +803,8 @@ export class SocketManager {
});
}
dispatchRoomRefresh(roomId: string): void {
const room = this.rooms.get(roomId);
async dispatchRoomRefresh(roomId: string): Promise<void> {
const room = await this.roomsPromises.get(roomId);
if (!room) {
return;
}

View File

@ -0,0 +1,187 @@
/**
* 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) {
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;
}
setVariable(name: string, value: string, user: User): string | undefined {
let readableBy: string | undefined;
if (this.variableObjects) {
const 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;
}
this._variables.set(name, value);
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 => {}
describe("GameRoom", () => {
it("should connect user1 and user2", () => {
it("should connect user1 and user2", async () => {
let connectCalledNumber: number = 0;
const connect: ConnectCallback = (user: User, group: Group): void => {
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));
@ -67,7 +66,7 @@ describe("GameRoom", () => {
expect(connectCalledNumber).toBe(2);
});
it("should connect 3 users", () => {
it("should connect 3 users", async () => {
let connectCalled: boolean = false;
const connect: ConnectCallback = (user: User, group: Group): void => {
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));
@ -95,7 +94,7 @@ describe("GameRoom", () => {
expect(connectCalled).toBe(true);
});
it("should disconnect user1 and user2", () => {
it("should disconnect user1 and user2", async () => {
let connectCalled: boolean = false;
let disconnectCallNumber: number = 0;
const connect: ConnectCallback = (user: User, group: Group): void => {
@ -105,7 +104,7 @@ describe("GameRoom", () => {
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));

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,
/* Basic Options */
// "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,
"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. */

View File

@ -122,6 +122,13 @@
resolved "https://registry.yarnpkg.com/@types/parse-json/-/parse-json-4.0.0.tgz#2f8bb441434d163b35fb8ffdccd7138927ffb8c0"
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":
version "3.0.0"
resolved "https://registry.yarnpkg.com/@types/strip-bom/-/strip-bom-3.0.0.tgz#14a8ec3956c2e81edb7520790aecf21c290aebd2"
@ -187,6 +194,13 @@
semver "^7.3.2"
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:
version "1.1.1"
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"
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:
version "1.0.3"
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"
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:
version "3.0.2"
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"
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:
version "0.1.6"
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"
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:
version "1.0.2"
resolved "https://registry.yarnpkg.com/regex-not/-/regex-not-1.0.2.tgz#1f4ece27e00b0b65e0247a6810e6a85d83a5752c"

View File

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

View File

@ -120,6 +120,8 @@ services:
JITSI_URL: $JITSI_URL
JITSI_ISS: $JITSI_ISS
MAX_PER_GROUP: "$MAX_PER_GROUP"
REDIS_HOST: redis
NODE_ENV: development
volumes:
- ./back:/usr/src/app
labels:
@ -168,6 +170,9 @@ services:
- ./front:/usr/src/front
- ./pusher:/usr/src/pusher
redis:
image: redis:6
# coturn:
# image: coturn/coturn:4.5.2
# command:

View File

@ -115,6 +115,8 @@ services:
JITSI_ISS: $JITSI_ISS
TURN_STATIC_AUTH_SECRET: SomeStaticAuthSecret
MAX_PER_GROUP: "MAX_PER_GROUP"
REDIS_HOST: redis
NODE_ENV: development
volumes:
- ./back:/usr/src/app
labels:
@ -157,6 +159,20 @@ services:
- ./front:/usr/src/front
- ./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:
# image: coturn/coturn:4.5.2
# command:

View File

@ -1,6 +1,62 @@
{.section-title.accent.text-primary}
# 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
```

View File

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

View File

@ -79,6 +79,58 @@ Example :
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
```
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

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

View File

@ -1,3 +1,4 @@
import * as tg from "generic-type-guard";
import type { GameStateEvent } from "./GameStateEvent";
import type { ButtonClickedEvent } from "./ButtonClickedEvent";
import type { ChatEvent } from "./ChatEvent";
@ -9,7 +10,7 @@ import type { OpenCoWebSiteEvent } from "./OpenCoWebSiteEvent";
import type { OpenPopupEvent } from "./OpenPopupEvent";
import type { OpenTabEvent } from "./OpenTabEvent";
import type { UserInputChatEvent } from "./UserInputChatEvent";
import type { DataLayerEvent } from "./DataLayerEvent";
import type { MapDataEvent } from "./MapDataEvent";
import type { LayerEvent } from "./LayerEvent";
import type { SetPropertyEvent } from "./setPropertyEvent";
import type { LoadSoundEvent } from "./LoadSoundEvent";
@ -18,6 +19,10 @@ import type { MenuItemClickedEvent } from "./ui/MenuItemClickedEvent";
import type { MenuItemRegisterEvent } from "./ui/MenuItemRegisterEvent";
import type { HasPlayerMovedEvent } from "./HasPlayerMovedEvent";
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 {
data: T;
@ -43,7 +48,6 @@ export type IframeEventMap = {
showLayer: LayerEvent;
hideLayer: LayerEvent;
setProperty: SetPropertyEvent;
getDataLayer: undefined;
loadSound: LoadSoundEvent;
playSound: PlaySoundEvent;
stopSound: null;
@ -66,8 +70,8 @@ export interface IframeResponseEventMap {
leaveEvent: EnterLeaveEvent;
buttonClickedEvent: ButtonClickedEvent;
hasPlayerMoved: HasPlayerMovedEvent;
dataLayer: DataLayerEvent;
menuItemClicked: MenuItemClickedEvent;
setVariable: SetVariableEvent;
}
export interface IframeResponseEvent<T extends keyof IframeResponseEventMap> {
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: {
query: undefined,
answer: GameStateEvent
query: tg.isUndefined,
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> {
@ -100,8 +124,21 @@ export interface IframeQueryWrapper<T extends keyof IframeQueryMap> {
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
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
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";
export const isDataLayerEvent = new tg.IsInterface()
export const isMapDataEvent = new tg.IsInterface()
.withProperties({
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
*/
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 { isLayerEvent, LayerEvent } from "./Events/LayerEvent";
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 { HasPlayerMovedEvent } from "./Events/HasPlayerMovedEvent";
import { isLoadPageEvent } from "./Events/LoadPageEvent";
import { handleMenuItemRegistrationEvent, isMenuItemRegisterIframeEvent } from "./Events/ui/MenuItemRegisterEvent";
import { SetTilesEvent, isSetTilesEvent } from "./Events/SetTilesEvent";
import { isSetVariableIframeEvent, 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']) => IframeQueryMap[T]['answer']|PromiseLike<IframeQueryMap[T]['answer']>;
/**
* Listens to messages from iframes and turn those messages into easy to use observables.
* Also allows to send messages to those iframes.
*/
class IframeListener {
private readonly _readyStream: Subject<HTMLIFrameElement> = new Subject();
public readonly readyStream = this._readyStream.asObservable();
private readonly _chatStream: Subject<ChatEvent> = new Subject();
public readonly chatStream = this._chatStream.asObservable();
@ -85,9 +89,6 @@ class IframeListener {
private readonly _setPropertyStream: Subject<SetPropertyEvent> = new Subject();
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();
public readonly registerMenuCommandStream = this._registerMenuCommandStream.asObservable();
@ -111,10 +112,13 @@ class IframeListener {
private readonly scripts = new Map<string, HTMLIFrameElement>();
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: {
[key in keyof IframeQueryMap]?: AnswererCallback<key>
[str in keyof IframeQueryMap]?: unknown
} = {};
init() {
window.addEventListener(
"message",
@ -152,7 +156,7 @@ class IframeListener {
const queryId = payload.id;
const query = payload.query;
const answerer = this.answerers[query.type];
const answerer = this.answerers[query.type] as AnswererCallback<keyof IframeQueryMap> | 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.';
console.error(errorMsg);
@ -164,19 +168,15 @@ class IframeListener {
return;
}
Promise.resolve(answerer(query.data)).then((value) => {
iframe?.contentWindow?.postMessage({
id: queryId,
type: query.type,
data: value
}, '*');
}).catch(reason => {
const errorHandler = (reason: unknown) => {
console.error('An error occurred while responding to an iFrame query.', reason);
let reasonMsg: string;
let reasonMsg: string = '';
if (reason instanceof Error) {
reasonMsg = reason.message;
} else {
reasonMsg = reason.toString();
} else if (typeof reason === 'object') {
reasonMsg = reason ? reason.toString() : '';
} else if (typeof reason === 'string') {
reasonMsg = reason;
}
iframe?.contentWindow?.postMessage({
@ -184,8 +184,31 @@ class IframeListener {
type: query.type,
error: reasonMsg
} as IframeErrorAnswerEvent, '*');
});
};
try {
Promise.resolve(answerer(query.data)).then((value) => {
iframe?.contentWindow?.postMessage({
id: queryId,
type: query.type,
data: value
}, '*');
}).catch(errorHandler);
} catch (reason) {
errorHandler(reason);
}
if (isSetVariableIframeEvent(payload.query)) {
// Let's dispatch the message to the other iframes
for (iframe of this.iframes) {
if (iframe.contentWindow !== message.source) {
iframe.contentWindow?.postMessage({
'type': 'setVariable',
'data': payload.query.data
}, '*');
}
}
}
} else if (isIframeEventWrapper(payload)) {
if (payload.type === "showLayer" && isLayerEvent(payload.data)) {
this._showLayerStream.next(payload.data);
@ -230,8 +253,6 @@ class IframeListener {
this._removeBubbleStream.next();
} else if (payload.type == "onPlayerMove") {
this.sendPlayerMove = true;
} else if (payload.type == "getDataLayer") {
this._dataLayerChangeStream.next();
} else if (isMenuItemRegisterIframeEvent(payload)) {
const data = payload.data.menutItem;
// @ts-ignore
@ -248,13 +269,6 @@ class IframeListener {
);
}
sendDataLayerEvent(dataLayerEvent: DataLayerEvent) {
this.postMessage({
type: "dataLayer",
data: dataLayerEvent,
});
}
/**
* Allows the passed iFrame to send/receive messages via the API.
*/
@ -394,6 +408,13 @@ class IframeListener {
});
}
setVariable(setVariableEvent: SetVariableEvent) {
this.postMessage({
'type': 'setVariable',
'data': setVariableEvent
});
}
/**
* Sends the message... to all allowed iframes.
*/
@ -411,7 +432,7 @@ class IframeListener {
* @param key The "type" of the query we are answering
* @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;
}

View File

@ -2,17 +2,28 @@ import { IframeApiContribution, sendToWorkadventure } from "./IframeApiContribut
import type { HasPlayerMovedEvent, HasPlayerMovedEventCallback } from "../Events/HasPlayerMovedEvent";
import { Subject } from "rxjs";
import { apiCallback } from "./registeredCallbacks";
import { getGameState } from "./room";
import { isHasPlayerMovedEvent } from "../Events/HasPlayerMovedEvent";
interface User {
id: string | undefined;
nickName: string | null;
tags: string[];
}
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> {
callbacks = [
apiCallback({
@ -31,10 +42,30 @@ export class WorkadventurePlayerCommands extends IframeApiContribution<Workadven
data: null,
});
}
getCurrentUser(): Promise<User> {
return getGameState().then((gameState) => {
return { id: gameState.uuid, nickName: gameState.nickname, tags: gameState.tags };
});
get name(): string {
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 { isGameStateEvent } from "../Events/GameStateEvent";
import { IframeApiContribution, queryWorkadventure, sendToWorkadventure } from "./IframeApiContribution";
import { apiCallback } from "./registeredCallbacks";
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 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 {
x: number;
@ -31,19 +17,17 @@ interface TileDescriptor {
layer: string;
}
export function getGameState(): Promise<GameStateEvent> {
if (immutableDataPromise === undefined) {
immutableDataPromise = queryWorkadventure({ type: "getState", data: undefined });
}
return immutableDataPromise;
}
let roomId: string | undefined;
function getDataLayer(): Promise<DataLayerEvent> {
return new Promise<DataLayerEvent>((resolver, thrower) => {
dataLayerResolver.subscribe(resolver);
sendToWorkadventure({ type: "getDataLayer", data: null });
});
}
export const setRoomId = (id: string) => {
roomId = id;
};
let mapURL: string | undefined;
export const setMapURL = (url: string) => {
mapURL = url;
};
export class WorkadventureRoomCommands extends IframeApiContribution<WorkadventureRoomCommands> {
callbacks = [
@ -61,13 +45,6 @@ export class WorkadventureRoomCommands extends IframeApiContribution<Workadventu
leaveStreams.get(payloadData.name)?.next();
},
}),
apiCallback({
type: "dataLayer",
typeChecker: isDataLayerEvent,
callback: (payloadData) => {
dataLayerResolver.next(payloadData);
},
}),
];
onEnterZone(name: string, callback: () => void): void {
@ -102,17 +79,9 @@ export class WorkadventureRoomCommands extends IframeApiContribution<Workadventu
},
});
}
getCurrentRoom(): Promise<Room> {
return getGameState().then((gameState) => {
return getDataLayer().then((mapJson) => {
return {
id: gameState.roomId,
map: mapJson.data as ITiledMap,
mapUrl: gameState.mapUrl,
startLayer: gameState.startLayerName,
};
});
});
async getTiledMap(): Promise<ITiledMap> {
const event = await queryWorkadventure({ type: "getMapData", data: undefined });
return event.data as ITiledMap;
}
setTiles(tiles: TileDescriptor[]) {
sendToWorkadventure({
@ -120,6 +89,22 @@ export class WorkadventureRoomCommands extends IframeApiContribution<Workadventu
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();

View File

@ -0,0 +1,92 @@
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.
if (oldValue === 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;
}
});
export default proxyCommand;

View File

@ -7,11 +7,10 @@ import {localUserStore} from "./LocalUserStore";
import { CharacterTexture, LocalUser } from "./LocalUser";
import { Room } from "./Room";
class ConnectionManager {
private localUser!: LocalUser;
private connexionType?: GameConnexionTypes
private connexionType?: GameConnexionTypes;
private reconnectingTimeout: NodeJS.Timeout | null = null;
private _unloading: boolean = false;
@ -20,31 +19,44 @@ class ConnectionManager {
}
constructor() {
window.addEventListener('beforeunload', () => {
window.addEventListener("beforeunload", () => {
this._unloading = true;
if (this.reconnectingTimeout) clearTimeout(this.reconnectingTimeout)
})
if (this.reconnectingTimeout) clearTimeout(this.reconnectingTimeout);
});
}
/**
* Tries to login to the node server and return the starting map url to be loaded
*/
public async initGameConnexion(): Promise<Room> {
const connexionType = urlManager.getGameConnexionType();
this.connexionType = connexionType;
if (connexionType === GameConnexionTypes.register) {
const organizationMemberToken = urlManager.getOrganizationToken();
const data = await Axios.post(`${PUSHER_URL}/register`, {organizationMemberToken}).then(res => res.data);
const data = await Axios.post(`${PUSHER_URL}/register`, { organizationMemberToken }).then(
(res) => res.data
);
this.localUser = new LocalUser(data.userUuid, data.authToken, data.textures);
localUserStore.saveUser(this.localUser);
const roomUrl = data.roomUrl;
const room = await Room.createRoom(new URL(window.location.protocol + '//' + window.location.host + roomUrl + window.location.search + window.location.hash));
const room = await Room.createRoom(
new URL(
window.location.protocol +
"//" +
window.location.host +
roomUrl +
window.location.search +
window.location.hash
)
);
urlManager.pushRoomIdToUrl(room);
return Promise.resolve(room);
} else if (connexionType === GameConnexionTypes.organization || connexionType === GameConnexionTypes.anonymous || connexionType === GameConnexionTypes.empty) {
} else if (
connexionType === GameConnexionTypes.organization ||
connexionType === GameConnexionTypes.anonymous ||
connexionType === GameConnexionTypes.empty
) {
let localUser = localUserStore.getLocalUser();
if (localUser && localUser.jwtToken && localUser.uuid && localUser.textures) {
this.localUser = localUser;
@ -52,7 +64,7 @@ class ConnectionManager {
await this.verifyToken(localUser.jwtToken);
} catch (e) {
// If the token is invalid, let's generate an anonymous one.
console.error('JWT token invalid. Did it expire? Login anonymously instead.');
console.error("JWT token invalid. Did it expire? Login anonymously instead.");
await this.anonymousLogin();
}
} else {
@ -66,9 +78,15 @@ class ConnectionManager {
let roomPath: string;
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 {
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
@ -83,7 +101,7 @@ class ConnectionManager {
if (localUser?.textures.findIndex((c) => newTexture.id === c.id) !== -1) {
return;
}
localUser?.textures.push(newTexture)
localUser?.textures.push(newTexture);
});
}
this.localUser = localUser;
@ -92,7 +110,7 @@ class ConnectionManager {
return Promise.resolve(room);
}
return Promise.reject(new Error('Invalid URL'));
return Promise.reject(new Error("Invalid URL"));
}
private async verifyToken(token: string): Promise<void> {
@ -100,41 +118,65 @@ class ConnectionManager {
}
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, []);
if (!isBenchmark) { // In benchmark, we don't have a local storage.
if (!isBenchmark) {
// In benchmark, we don't have a local storage.
localUserStore.saveUser(this.localUser);
}
}
public initBenchmark(): void {
this.localUser = new LocalUser('', 'test', []);
this.localUser = new LocalUser("", "test", []);
}
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) => {
const connection = new RoomConnection(this.localUser.jwtToken, roomUrl, name, characterLayers, position, viewport, companion);
const connection = new RoomConnection(
this.localUser.jwtToken,
roomUrl,
name,
characterLayers,
position,
viewport,
companion
);
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);
});
connection.onConnectingError((event: CloseEvent) => {
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));
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
)
);
});
connection.onConnect((connect: OnConnectInterface) => {
resolve(connect);
});
}).catch((err) => {
// Let's retry in 4-6 seconds
return new Promise<OnConnectInterface>((resolve, reject) => {
this.reconnectingTimeout = setTimeout(() => {
//todo: allow a way to break recursion?
//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(
(connection) => resolve(connection)
);
}, 4000 + Math.floor(Math.random() * 2000));
});
});

View File

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

View File

@ -32,6 +32,7 @@ import {
EmotePromptMessage,
SendUserMessage,
BanUserMessage,
VariableMessage, ErrorMessage,
} from "../Messages/generated/messages_pb";
import type { UserSimplePeerInterface } from "../WebRtc/SimplePeer";
@ -164,6 +165,12 @@ export class RoomConnection implements RoomConnection {
} else if (subMessage.hasEmoteeventmessage()) {
const emoteMessage = subMessage.getEmoteeventmessage() as EmoteEventMessage;
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 {
throw new Error("Unexpected batch message type");
}
@ -180,6 +187,15 @@ export class RoomConnection implements RoomConnection {
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.tags = roomJoinedMessage.getTagList();
@ -187,6 +203,7 @@ export class RoomConnection implements RoomConnection {
connection: this,
room: {
items,
variables,
} as RoomJoinedMessageInterface,
});
} else if (message.hasWorldfullmessage()) {
@ -536,6 +553,17 @@ export class RoomConnection implements RoomConnection {
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 {
this.onMessage(EventMessage.ITEM_EVENT, (message: ItemEventMessage) => {
callback({
@ -622,6 +650,22 @@ 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 {
return this.tags.includes(tag);
}

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 TilemapLayer = Phaser.Tilemaps.TilemapLayer;
import { DEPTH_OVERLAY_INDEX } from "./DepthIndexes";
@ -19,7 +19,7 @@ export class GameMap {
private callbacks = new Map<string, Array<PropertyChangeCallback>>();
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 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]) {
return this.tileSetPropertyMap[index];
}
@ -151,7 +151,7 @@ export class GameMap {
return this.map;
}
private getTileProperty(index: number): Array<ITiledMapLayerProperty> {
private getTileProperty(index: number): Array<ITiledMapProperty> {
if (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 { ItemFactoryInterface } from "../Items/ItemFactoryInterface";
import { SelectCharacterScene, SelectCharacterSceneName } from "../Login/SelectCharacterScene";
import type {
ITiledMap,
ITiledMapLayer,
ITiledMapLayerProperty,
ITiledMapObject,
ITiledTileSet,
} from "../Map/ITiledMap";
import type { ITiledMap, ITiledMapLayer, ITiledMapProperty, ITiledMapObject, ITiledTileSet } from "../Map/ITiledMap";
import { MenuScene, MenuSceneName } from "../Menu/MenuScene";
import { PlayerAnimationDirections } from "../Player/Animation";
import { hasMovedEventName, Player, requestEmoteEventName } from "../Player/Player";
@ -91,6 +85,7 @@ import { soundManager } from "./SoundManager";
import { peerStore, screenSharingPeerStore } from "../../Stores/PeerStore";
import { videoFocusStore } from "../../Stores/VideoFocusStore";
import { biggestAvailableAreaStore } from "../../Stores/BiggestAvailableAreaStore";
import { SharedVariablesManager } from "./SharedVariablesManager";
import { playersStore } from "../../Stores/PlayersStore";
import { chatVisibilityStore } from "../../Stores/ChatStore";
@ -202,7 +197,8 @@ export class GameScene extends DirtyScene {
private mapTransitioning: boolean = false; //used to prevent transitions happenning at the same time.
private emoteManager!: EmoteManager;
private preloading: boolean = true;
startPositionCalculator!: StartPositionCalculator;
private startPositionCalculator!: StartPositionCalculator;
private sharedVariablesManager!: SharedVariablesManager;
constructor(private room: Room, MapUrlFile: string, customKey?: string | undefined) {
super({
@ -718,6 +714,13 @@ export class GameScene extends DirtyScene {
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.connectionAnswerPromiseResolve(onConnect.room);
// Analyze tags to find if we are admin. If yes, show console.
@ -1053,20 +1056,24 @@ ${escapedMessage}
})
);
this.iframeSubscriptionList.push(
iframeListener.dataLayerChangeStream.subscribe(() => {
iframeListener.sendDataLayerEvent({ data: this.gameMap.getMap() });
})
);
iframeListener.registerAnswerer("getMapData", () => {
return {
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 {
mapUrl: this.MapUrlFile,
startLayerName: this.startPositionCalculator.startLayerName,
uuid: localUserStore.getLocalUser()?.uuid,
nickname: localUserStore.getName(),
nickname: this.playerName,
roomId: this.roomUrl,
tags: this.connection ? this.connection.getAllTags() : [],
variables: this.sharedVariablesManager.variables,
};
});
this.iframeSubscriptionList.push(
@ -1197,6 +1204,7 @@ ${escapedMessage}
this.chatVisibilityUnsubscribe();
this.biggestAvailableAreaStoreUnsubscribe();
iframeListener.unregisterAnswerer("getState");
this.sharedVariablesManager?.close();
mediaManager.hideGameOverlay();
@ -1236,12 +1244,12 @@ ${escapedMessage}
}
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) {
return undefined;
}
const obj = properties.find(
(property: ITiledMapLayerProperty) => property.name.toLowerCase() === name.toLowerCase()
(property: ITiledMapProperty) => property.name.toLowerCase() === name.toLowerCase()
);
if (obj === undefined) {
return undefined;
@ -1250,12 +1258,12 @@ ${escapedMessage}
}
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) {
return [];
}
return properties
.filter((property: ITiledMapLayerProperty) => property.name.toLowerCase() === name.toLowerCase())
.filter((property: ITiledMapProperty) => property.name.toLowerCase() === name.toLowerCase())
.map((property) => property.value);
}

View File

@ -0,0 +1,159 @@
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) => {
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);
}
this._variables.set(key, event.value);
// Dispatch to the room connection.
this.roomConnection.emitSetVariableEvent(key, event.value);
});
}
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 { ITiledMap, ITiledMapLayer, ITiledMapLayerProperty, ITiledMapTileLayer } from "../Map/ITiledMap";
import type { ITiledMap, ITiledMapLayer, ITiledMapProperty, ITiledMapTileLayer } from "../Map/ITiledMap";
import type { GameMap } from "./GameMap";
const defaultStartLayerName = "start";
@ -112,12 +112,12 @@ export class StartPositionCalculator {
}
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) {
return undefined;
}
const obj = properties.find(
(property: ITiledMapLayerProperty) => property.name.toLowerCase() === name.toLowerCase()
(property: ITiledMapProperty) => property.name.toLowerCase() === name.toLowerCase()
);
if (obj === undefined) {
return undefined;

View File

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

View File

@ -8,7 +8,7 @@ import {WAError} from "./WAError";
export const ErrorSceneName = "ErrorScene";
enum Textures {
icon = "icon",
mainFont = "main_font"
mainFont = "main_font",
}
export class ErrorScene extends Phaser.Scene {
@ -23,25 +23,21 @@ export class ErrorScene extends Phaser.Scene {
constructor() {
super({
key: ErrorSceneName
key: ErrorSceneName,
});
}
init({title, subTitle, message}: { title?: string, subTitle?: string, message?: string }) {
this.title = title ? title : '';
this.subTitle = subTitle ? subTitle : '';
this.message = message ? message : '';
init({ title, subTitle, message }: { title?: string; subTitle?: string; message?: string }) {
this.title = title ? title : "";
this.subTitle = subTitle ? subTitle : "";
this.message = message ? message : "";
}
preload() {
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
this.load.bitmapFont(Textures.mainFont, 'resources/fonts/arcade.png', 'resources/fonts/arcade.xml');
this.load.spritesheet(
'cat',
'resources/characters/pipoya/Cat 01-1.png',
{frameWidth: 32, frameHeight: 32}
);
this.load.bitmapFont(Textures.mainFont, "resources/fonts/arcade.png", "resources/fonts/arcade.xml");
this.load.spritesheet("cat", "resources/characters/pipoya/Cat 01-1.png", { frameWidth: 32, frameHeight: 32 });
}
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.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(
this.game.renderer.width / 2,
this.game.renderer.height / 2 + 48,
this.message,
{
fontFamily: 'Georgia, "Goudy Bookletter 1911", Times, serif',
fontSize: '10px'
});
fontSize: "10px",
}
);
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;
}
@ -69,38 +75,38 @@ export class ErrorScene extends Phaser.Scene {
public static showError(error: any, scene: ScenePlugin): void {
console.error(error);
if (typeof error === 'string' || error instanceof String) {
if (typeof error === "string" || error instanceof String) {
scene.start(ErrorSceneName, {
title: 'An error occurred',
subTitle: error
title: "An error occurred",
subTitle: error,
});
} else if (error instanceof WAError) {
scene.start(ErrorSceneName, {
title: error.title,
subTitle: error.subTitle,
message: error.details
message: error.details,
});
} else if (error.response) {
// Axios HTTP error
// client received an error response (5xx, 4xx)
scene.start(ErrorSceneName, {
title: 'HTTP ' + error.response.status + ' - ' + error.response.statusText,
subTitle: 'An error occurred while accessing URL:',
message: error.response.config.url
title: "HTTP " + error.response.status + " - " + error.response.statusText,
subTitle: "An error occurred while accessing URL:",
message: error.response.config.url,
});
} else if (error.request) {
// Axios HTTP error
// client never received a response, or request never left
scene.start(ErrorSceneName, {
title: 'Network error',
subTitle: error.message
title: "Network error",
subTitle: error.message,
});
} else if (error instanceof Error) {
// Error
scene.start(ErrorSceneName, {
title: 'An error occurred',
title: "An error occurred",
subTitle: error.name,
message: error.message
message: error.message,
});
} else {
throw error;
@ -114,7 +120,7 @@ export class ErrorScene extends Phaser.Scene {
scene.start(ErrorSceneName, {
title,
subTitle,
message
message,
});
}
}

View File

@ -5,7 +5,7 @@ import Sprite = Phaser.GameObjects.Sprite;
export const ReconnectingSceneName = "ReconnectingScene";
enum ReconnectingTextures {
icon = "icon",
mainFont = "main_font"
mainFont = "main_font",
}
export class ReconnectingScene extends Phaser.Scene {
@ -14,35 +14,40 @@ export class ReconnectingScene extends Phaser.Scene {
constructor() {
super({
key: ReconnectingSceneName
key: ReconnectingSceneName,
});
}
preload() {
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
this.load.bitmapFont(ReconnectingTextures.mainFont, 'resources/fonts/arcade.png', 'resources/fonts/arcade.xml');
this.load.spritesheet(
'cat',
'resources/characters/pipoya/Cat 01-1.png',
{frameWidth: 32, frameHeight: 32}
);
this.load.bitmapFont(ReconnectingTextures.mainFont, "resources/fonts/arcade.png", "resources/fonts/arcade.xml");
this.load.spritesheet("cat", "resources/characters/pipoya/Cat 01-1.png", { frameWidth: 32, frameHeight: 32 });
}
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.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({
key: 'right',
frames: this.anims.generateFrameNumbers('cat', { start: 6, end: 8 }),
key: "right",
frames: this.anims.generateFrameNumbers("cat", { start: 6, end: 8 }),
frameRate: 10,
repeat: -1
repeat: -1,
});
cat.play('right');
cat.play("right");
}
}

View File

@ -25,8 +25,8 @@ export class HtmlUtils {
}
public static escapeHtml(html: string): string {
const text = document.createTextNode(html.replace(/(\r\n|\r|\n)/g,'<br/>'));
const p = document.createElement('p');
const text = document.createTextNode(html.replace(/(\r\n|\r|\n)/g, "<br/>"));
const p = document.createElement("p");
p.appendChild(text);
return p.innerHTML;
}
@ -35,7 +35,7 @@ export class HtmlUtils {
const urlRegex = /(https?:\/\/[^\s]+)/g;
text = HtmlUtils.escapeHtml(text);
return text.replace(urlRegex, (url: string) => {
const link = document.createElement('a');
const link = document.createElement("a");
link.href = url;
link.target = "_blank";
const text = document.createTextNode(url);

View File

@ -1,7 +1,9 @@
import { registeredCallbacks } from "./Api/iframe/registeredCallbacks";
import {
IframeResponseEvent,
IframeResponseEventMap, isIframeAnswerEvent, isIframeErrorAnswerEvent,
IframeResponseEventMap,
isIframeAnswerEvent,
isIframeErrorAnswerEvent,
isIframeResponseEventWrapper,
TypedMessageEvent,
} from "./Api/Events/IframeEvent";
@ -11,12 +13,26 @@ import nav from "./Api/iframe/nav";
import controls from "./Api/iframe/controls";
import ui from "./Api/iframe/ui";
import sound from "./Api/iframe/sound";
import room from "./Api/iframe/room";
import player from "./Api/iframe/player";
import room, { setMapURL, setRoomId } from "./Api/iframe/room";
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 { Popup } from "./Api/iframe/Ui/Popup";
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 = {
ui,
@ -26,6 +42,11 @@ const wa = {
sound,
room,
player,
state,
onInit(): Promise<void> {
return initPromise;
},
// All methods below are deprecated and should not be used anymore.
// They are kept here for backward compatibility.
@ -164,34 +185,35 @@ declare global {
window.WA = wa;
window.addEventListener(
"message", <T extends keyof IframeResponseEventMap>(message: TypedMessageEvent<IframeResponseEvent<T>>) => {
"message",
<T extends keyof IframeResponseEventMap>(message: TypedMessageEvent<IframeResponseEvent<T>>) => {
if (message.source !== window.parent) {
return; // Skip message in this event listener
}
const payload = message.data;
console.debug(payload);
//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);
answerPromises.delete(queryId);
} else if (isIframeErrorAnswerEvent(payload)) {
if (isIframeErrorAnswerEvent(payload)) {
const queryId = payload.id;
const payloadError = payload.error;
const resolver = answerPromises.get(queryId);
if (resolver === undefined) {
throw new Error('In Iframe API, got an error answer for a question that we have no track of.');
throw new Error("In Iframe API, got an error answer for a question that we have no track of.");
}
resolver.reject(payloadError);
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)) {

View File

@ -1,4 +1,4 @@
import 'phaser';
import "phaser";
import GameConfig = Phaser.Types.Core.GameConfig;
import "../style/index.scss";
@ -9,23 +9,22 @@ import {SelectCharacterScene} from "./Phaser/Login/SelectCharacterScene";
import { SelectCompanionScene } from "./Phaser/Login/SelectCompanionScene";
import { EnableCameraScene } from "./Phaser/Login/EnableCameraScene";
import { CustomizeScene } from "./Phaser/Login/CustomizeScene";
import WebFontLoaderPlugin from 'phaser3-rex-plugins/plugins/webfontloader-plugin.js';
import OutlinePipelinePlugin from 'phaser3-rex-plugins/plugins/outlinepipeline-plugin.js';
import WebFontLoaderPlugin from "phaser3-rex-plugins/plugins/webfontloader-plugin.js";
import OutlinePipelinePlugin from "phaser3-rex-plugins/plugins/outlinepipeline-plugin.js";
import { EntryScene } from "./Phaser/Login/EntryScene";
import { coWebsiteManager } from "./WebRtc/CoWebsiteManager";
import { MenuScene } from "./Phaser/Menu/MenuScene";
import { localUserStore } from "./Connexion/LocalUserStore";
import { ErrorScene } from "./Phaser/Reconnecting/ErrorScene";
import { iframeListener } from "./Api/IframeListener";
import { SelectCharacterMobileScene } from './Phaser/Login/SelectCharacterMobileScene';
import { SelectCharacterMobileScene } from "./Phaser/Login/SelectCharacterMobileScene";
import { HdpiManager } from "./Phaser/Services/HdpiManager";
import { waScaleManager } from "./Phaser/Services/WaScaleManager";
import { Game } from "./Phaser/Game/Game";
import App from './Components/App.svelte';
import App from "./Components/App.svelte";
import { HtmlUtils } from "./WebRtc/HtmlUtils";
import WebGLRenderer = Phaser.Renderer.WebGL.WebGLRenderer;
const { width, height } = coWebsiteManager.getGameSize();
const valueGameQuality = localUserStore.getGameQualityValue();
@ -53,22 +52,22 @@ const fps : Phaser.Types.Core.FPSConfig = {
/**
* 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
const params = new URLSearchParams(document.location.search.substring(1));
const phaserMode = params.get("phaserMode");
let mode: number;
switch (phaserMode) {
case 'auto':
case "auto":
case null:
mode = Phaser.AUTO;
break;
case 'canvas':
case "canvas":
mode = Phaser.CANVAS;
break;
case 'webgl':
case "webgl":
mode = Phaser.WEBGL;
break;
default:
@ -87,9 +86,10 @@ const config: GameConfig = {
height: gameSize.height,
zoom: realSize.width / gameSize.width,
autoRound: true,
resizeInterval: 999999999999
resizeInterval: 999999999999,
},
scene: [EntryScene,
scene: [
EntryScene,
LoginScene,
isMobile() ? SelectCharacterMobileScene : SelectCharacterScene,
SelectCompanionScene,
@ -102,37 +102,39 @@ const config: GameConfig = {
//resolution: window.devicePixelRatio / 2,
fps: fps,
dom: {
createContainer: true
createContainer: true,
},
render: {
pixelArt: true,
roundPixels: true,
antialias: false
antialias: false,
},
plugins: {
global: [{
key: 'rexWebFontLoader',
global: [
{
key: "rexWebFontLoader",
plugin: WebFontLoaderPlugin,
start: true
}]
start: true,
},
],
},
physics: {
default: "arcade",
arcade: {
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
powerPreference: "low-power",
callbacks: {
postBoot: game => {
postBoot: (game) => {
// Install rexOutlinePipeline only if the renderer is WebGL.
const renderer = game.renderer;
if (renderer instanceof WebGLRenderer) {
game.plugins.install('rexOutlinePipeline', OutlinePipelinePlugin, true);
}
}
game.plugins.install("rexOutlinePipeline", OutlinePipelinePlugin, true);
}
},
},
};
//const game = new Phaser.Game(config);
@ -140,7 +142,7 @@ const game = new Game(config);
waScaleManager.setGame(game);
window.addEventListener('resize', function (event) {
window.addEventListener("resize", function (event) {
coWebsiteManager.resetStyle();
waScaleManager.applyNewSize();
@ -153,21 +155,22 @@ coWebsiteManager.onResize.subscribe(() => {
iframeListener.init();
const app = new App({
target: HtmlUtils.getElementByIdOrFail('svelte-overlay'),
target: HtmlUtils.getElementByIdOrFail("svelte-overlay"),
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 => {
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 => {
.catch((error) => {
console.error("Error registering the Service Worker: ", error);
});
});

View File

@ -1,23 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<script>
var script = document.createElement('script');
// Don't do this at home kids! The "document.referrer" part is actually inserting a XSS security.
// We are OK in this precise case because the HTML page is hosted on the "maps" domain that contains only static files.
script.setAttribute('src', document.referrer + 'iframe_api.js');
document.head.appendChild(script);
window.addEventListener('load', () => {
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>
</head>
<body>
<p>Log in the console the information of the current room</p>
</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);
})

View File

@ -1,11 +1,4 @@
{ "compressionlevel":-1,
"editorsettings":
{
"export":
{
"target":"."
}
},
"height":10,
"infinite":false,
"layers":[
@ -51,29 +44,6 @@
"x":0,
"y":0
},
{
"data":[0, 0, 0, 0, 0, 0, 128, 128, 128, 128, 0, 0, 0, 0, 0, 0, 128, 128, 128, 128, 0, 0, 0, 0, 0, 0, 128, 128, 128, 128, 0, 0, 0, 0, 0, 0, 128, 128, 128, 128, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
"height":10,
"id":4,
"name":"metadata",
"opacity":1,
"properties":[
{
"name":"openWebsite",
"type":"string",
"value":"getCurrentRoom.html"
},
{
"name":"openWebsiteAllowApi",
"type":"bool",
"value":true
}],
"type":"tilelayer",
"visible":true,
"width":10,
"x":0,
"y":0
},
{
"draworder":"topdown",
"id":5,
@ -88,7 +58,7 @@
{
"fontfamily":"Sans Serif",
"pixelsize":9,
"text":"Test : \nWalk on the grass and open the console.\n\nResult : \nYou should see a console.log() of the following attributes : \n\t- id : ID of the current room\n\t- map : data of the JSON file of the map\n\t- mapUrl : url of the JSON file of the map\n\t- startLayer : Name of the layer where the current user started (HereYouAppered)\n\n\n",
"text":"Test : \nOpen the console.\n\nResult : \nYou should see a console.log() of the following attributes : \n\t- id : ID of the current room\n\t- mapUrl : url of the JSON file of the map\n\t- Player name\n - Player ID\n - Player tags\n\nAnd also:\n\t- map : data of the JSON file of the map\n\n",
"wrap":true
},
"type":"",
@ -106,8 +76,14 @@
"nextlayerid":11,
"nextobjectid":2,
"orientation":"orthogonal",
"properties":[
{
"name":"script",
"type":"string",
"value":"getCurrentRoom.js"
}],
"renderorder":"right-down",
"tiledversion":"1.4.3",
"tiledversion":"2021.03.23",
"tileheight":32,
"tilesets":[
{
@ -274,6 +250,6 @@
}],
"tilewidth":32,
"type":"map",
"version":1.4,
"version":1.5,
"width":10
}

View File

@ -1,22 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<script>
var script = document.createElement('script');
// Don't do this at home kids! The "document.referrer" part is actually inserting a XSS security.
// We are OK in this precise case because the HTML page is hosted on the "maps" domain that contains only static files.
script.setAttribute('src', document.referrer + 'iframe_api.js');
document.head.appendChild(script);
window.addEventListener('load', () => {
WA.player.getCurrentUser().then((user) => {
console.log('id : ', user.id);
console.log('nickName : ', user.nickName);
console.log('tags : ', user.tags);
})
})
</script>
</head>
<body>
<p>Log in the console the information of the current player</p>
</body>
</html>

View File

@ -1,296 +0,0 @@
{ "compressionlevel":-1,
"editorsettings":
{
"export":
{
"target":"."
}
},
"height":10,
"infinite":false,
"layers":[
{
"data":[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 52, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
"height":10,
"id":1,
"name":"start",
"opacity":1,
"type":"tilelayer",
"visible":true,
"width":10,
"x":0,
"y":0
},
{
"data":[33, 34, 34, 34, 34, 34, 34, 34, 34, 35, 41, 42, 42, 42, 42, 42, 42, 42, 42, 43, 41, 42, 42, 42, 42, 42, 42, 42, 42, 43, 41, 42, 42, 42, 42, 42, 42, 42, 42, 43, 41, 42, 42, 42, 42, 42, 42, 42, 42, 43, 41, 42, 42, 42, 42, 42, 42, 42, 42, 43, 41, 42, 42, 42, 42, 42, 42, 42, 42, 43, 41, 42, 42, 42, 42, 42, 42, 42, 42, 43, 41, 42, 42, 42, 42, 42, 42, 42, 42, 43, 49, 50, 50, 50, 50, 50, 50, 50, 50, 51],
"height":10,
"id":2,
"name":"bottom",
"opacity":1,
"type":"tilelayer",
"visible":true,
"width":10,
"x":0,
"y":0
},
{
"data":[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 101, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
"height":10,
"id":9,
"name":"exit",
"opacity":1,
"properties":[
{
"name":"exitUrl",
"type":"string",
"value":"getCurrentRoom.json#HereYouAppered"
}],
"type":"tilelayer",
"visible":true,
"width":10,
"x":0,
"y":0
},
{
"data":[0, 0, 0, 0, 0, 0, 128, 128, 128, 128, 0, 0, 0, 0, 0, 0, 128, 128, 128, 128, 0, 0, 0, 0, 0, 0, 128, 128, 128, 128, 0, 0, 0, 0, 0, 0, 128, 128, 128, 128, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
"height":10,
"id":4,
"name":"metadata",
"opacity":1,
"properties":[
{
"name":"openWebsite",
"type":"string",
"value":"getCurrentUser.html"
},
{
"name":"openWebsiteAllowApi",
"type":"bool",
"value":true
}],
"type":"tilelayer",
"visible":true,
"width":10,
"x":0,
"y":0
},
{
"draworder":"topdown",
"id":5,
"name":"floorLayer",
"objects":[
{
"height":151.839293303871,
"id":1,
"name":"",
"rotation":0,
"text":
{
"fontfamily":"Sans Serif",
"pixelsize":9,
"text":"Test : \nWalk on the grass, open the console.\n\nResut : \nYou should see a console.log() of the following attributes :\n\t- id : ID of the current user\n\t- nickName : Name of the current user\n\t- tags : List of tags of the current user\n\nFinally : \nWalk on the red tile and continue the test in an another room.",
"wrap":true
},
"type":"",
"visible":true,
"width":305.097705765524,
"x":14.750638909983,
"y":159.621625296353
}],
"opacity":1,
"type":"objectgroup",
"visible":true,
"x":0,
"y":0
}],
"nextlayerid":10,
"nextobjectid":2,
"orientation":"orthogonal",
"renderorder":"right-down",
"tiledversion":"1.4.3",
"tileheight":32,
"tilesets":[
{
"columns":8,
"firstgid":1,
"image":"tileset_dungeon.png",
"imageheight":256,
"imagewidth":256,
"margin":0,
"name":"TDungeon",
"spacing":0,
"tilecount":64,
"tileheight":32,
"tiles":[
{
"id":0,
"properties":[
{
"name":"collides",
"type":"bool",
"value":true
}]
},
{
"id":1,
"properties":[
{
"name":"collides",
"type":"bool",
"value":true
}]
},
{
"id":2,
"properties":[
{
"name":"collides",
"type":"bool",
"value":true
}]
},
{
"id":3,
"properties":[
{
"name":"collides",
"type":"bool",
"value":true
}]
},
{
"id":4,
"properties":[
{
"name":"collides",
"type":"bool",
"value":true
}]
},
{
"id":8,
"properties":[
{
"name":"collides",
"type":"bool",
"value":true
}]
},
{
"id":9,
"properties":[
{
"name":"collides",
"type":"bool",
"value":true
}]
},
{
"id":10,
"properties":[
{
"name":"collides",
"type":"bool",
"value":true
}]
},
{
"id":11,
"properties":[
{
"name":"collides",
"type":"bool",
"value":true
}]
},
{
"id":12,
"properties":[
{
"name":"collides",
"type":"bool",
"value":true
}]
},
{
"id":16,
"properties":[
{
"name":"collides",
"type":"bool",
"value":true
}]
},
{
"id":17,
"properties":[
{
"name":"collides",
"type":"bool",
"value":true
}]
},
{
"id":18,
"properties":[
{
"name":"collides",
"type":"bool",
"value":true
}]
},
{
"id":19,
"properties":[
{
"name":"collides",
"type":"bool",
"value":true
}]
},
{
"id":20,
"properties":[
{
"name":"collides",
"type":"bool",
"value":true
}]
}],
"tilewidth":32
},
{
"columns":8,
"firstgid":65,
"image":"floortileset.png",
"imageheight":288,
"imagewidth":256,
"margin":0,
"name":"Floor",
"spacing":0,
"tilecount":72,
"tileheight":32,
"tiles":[
{
"animation":[
{
"duration":100,
"tileid":9
},
{
"duration":100,
"tileid":64
},
{
"duration":100,
"tileid":55
}],
"id":0
}],
"tilewidth":32
}],
"tilewidth":32,
"type":"map",
"version":1.4,
"width":10
}

View File

@ -0,0 +1,33 @@
WA.onInit().then(() => {
console.log('Trying to read variable "doorOpened" whose default property is true. This should display "true".');
console.log('doorOpened', WA.state.loadVariable('doorOpened'));
console.log('Trying to set variable "not_exists". This should display an error in the console, followed by a log saying the error was caught.')
WA.state.saveVariable('not_exists', 'foo').catch((e) => {
console.log('Successfully caught error: ', e);
});
console.log('Trying to set variable "myvar". This should work.');
WA.state.saveVariable('myvar', {'foo': 'bar'});
console.log('Trying to read variable "myvar". This should display a {"foo": "bar"} object.');
console.log(WA.state.loadVariable('myvar'));
console.log('Trying to set variable "myvar" using proxy. This should work.');
WA.state.myvar = {'baz': 42};
console.log('Trying to read variable "myvar" using proxy. This should display a {"baz": 42} object.');
console.log(WA.state.myvar);
console.log('Trying to set variable "config". This should not work because we are not logged as admin.');
WA.state.saveVariable('config', {'foo': 'bar'}).catch(e => {
console.log('Successfully caught error because variable "config" is not writable: ', e);
});
console.log('Trying to read variable "readableByAdmin" that can only be read by "admin". We are not admin so we should not get the default value.');
if (WA.state.readableByAdmin === true) {
console.error('Failed test: readableByAdmin can be read.');
} else {
console.log('Success test: readableByAdmin was not read.');
}
});

View File

@ -0,0 +1,48 @@
<!doctype html>
<html lang="en">
<head>
<script>
var script = document.createElement('script');
// Don't do this at home kids! The "document.referrer" part is actually inserting a XSS security.
// We are OK in this precise case because the HTML page is hosted on the "maps" domain that contains only static files.
script.setAttribute('src', document.referrer + 'iframe_api.js');
document.head.appendChild(script);
window.addEventListener('load', () => {
console.log('On load');
WA.onInit().then(() => {
console.log('After WA init');
const textField = document.getElementById('textField');
textField.value = WA.state.textField;
textField.addEventListener('change', function (evt) {
console.log('saving variable')
WA.state.textField = this.value;
});
WA.state.onVariableChange('textField').subscribe((value) => {
console.log('variable changed received')
textField.value = value;
});
document.getElementById('btn').addEventListener('click', () => {
console.log(WA.state.loadVariable('textField'));
document.getElementById('placeholder').innerText = WA.state.loadVariable('textField');
});
document.getElementById('setUndefined').addEventListener('click', () => {
WA.state.textField = undefined;
document.getElementById('textField').value = '';
});
});
})
</script>
</head>
<body>
<input type="text" id="textField" />
<button id="setUndefined">Delete variable</button>
<button id="btn">Display textField variable value</button>
<div id="placeholder"></div>
</body>
</html>

View File

@ -0,0 +1,131 @@
{ "compressionlevel":-1,
"height":10,
"infinite":false,
"layers":[
{
"data":[1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
"height":10,
"id":1,
"name":"floor",
"opacity":1,
"properties":[
{
"name":"openWebsite",
"type":"string",
"value":"shared_variables.html"
},
{
"name":"openWebsiteAllowApi",
"type":"bool",
"value":true
}],
"type":"tilelayer",
"visible":true,
"width":10,
"x":0,
"y":0
},
{
"data":[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 12, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
"height":10,
"id":2,
"name":"start",
"opacity":1,
"type":"tilelayer",
"visible":true,
"width":10,
"x":0,
"y":0
},
{
"draworder":"topdown",
"id":3,
"name":"floorLayer",
"objects":[
{
"height":67,
"id":3,
"name":"",
"rotation":0,
"text":
{
"fontfamily":"Sans Serif",
"pixelsize":11,
"text":"Test:\nChange the form\nConnect with another user\n\nResult:\nThe form should open in the same state for the other user\nAlso, a change on one user is directly propagated to the other user",
"wrap":true
},
"type":"",
"visible":true,
"width":252.4375,
"x":2.78125,
"y":2.5
},
{
"height":0,
"id":5,
"name":"textField",
"point":true,
"properties":[
{
"name":"default",
"type":"string",
"value":"default value"
},
{
"name":"jsonSchema",
"type":"string",
"value":"{}"
},
{
"name":"persist",
"type":"bool",
"value":true
},
{
"name":"readableBy",
"type":"string",
"value":""
},
{
"name":"writableBy",
"type":"string",
"value":""
}],
"rotation":0,
"type":"variable",
"visible":true,
"width":0,
"x":57.5,
"y":111
}],
"opacity":1,
"type":"objectgroup",
"visible":true,
"x":0,
"y":0
}],
"nextlayerid":8,
"nextobjectid":10,
"orientation":"orthogonal",
"renderorder":"right-down",
"tiledversion":"2021.03.23",
"tileheight":32,
"tilesets":[
{
"columns":11,
"firstgid":1,
"image":"..\/tileset1.png",
"imageheight":352,
"imagewidth":352,
"margin":0,
"name":"tileset1",
"spacing":0,
"tilecount":121,
"tileheight":32,
"tilewidth":32
}],
"tilewidth":32,
"type":"map",
"version":1.5,
"width":10
}

View File

@ -0,0 +1,205 @@
{ "compressionlevel":-1,
"height":10,
"infinite":false,
"layers":[
{
"data":[1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
"height":10,
"id":1,
"name":"floor",
"opacity":1,
"type":"tilelayer",
"visible":true,
"width":10,
"x":0,
"y":0
},
{
"data":[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 12, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
"height":10,
"id":2,
"name":"start",
"opacity":1,
"type":"tilelayer",
"visible":true,
"width":10,
"x":0,
"y":0
},
{
"draworder":"topdown",
"id":3,
"name":"floorLayer",
"objects":[
{
"height":67,
"id":3,
"name":"",
"rotation":0,
"text":
{
"fontfamily":"Sans Serif",
"pixelsize":11,
"text":"Test:\nOpen your console\n\nResult:\nYou should see a list of tests performed and results associated.",
"wrap":true
},
"type":"",
"visible":true,
"width":252.4375,
"x":2.78125,
"y":2.5
},
{
"height":0,
"id":5,
"name":"config",
"point":true,
"properties":[
{
"name":"default",
"type":"string",
"value":"{}"
},
{
"name":"jsonSchema",
"type":"string",
"value":"{}"
},
{
"name":"persist",
"type":"bool",
"value":true
},
{
"name":"readableBy",
"type":"string",
"value":""
},
{
"name":"writableBy",
"type":"string",
"value":"admin"
}],
"rotation":0,
"type":"variable",
"visible":true,
"width":0,
"x":57.5,
"y":111
},
{
"height":0,
"id":6,
"name":"doorOpened",
"point":true,
"properties":[
{
"name":"default",
"type":"bool",
"value":true
}],
"rotation":0,
"type":"variable",
"visible":true,
"width":0,
"x":131.38069962269,
"y":106.004988169086
},
{
"height":0,
"id":9,
"name":"myvar",
"point":true,
"properties":[
{
"name":"default",
"type":"string",
"value":"{}"
},
{
"name":"jsonSchema",
"type":"string",
"value":"{}"
},
{
"name":"persist",
"type":"bool",
"value":true
},
{
"name":"readableBy",
"type":"string",
"value":""
},
{
"name":"writableBy",
"type":"string",
"value":""
}],
"rotation":0,
"type":"variable",
"visible":true,
"width":0,
"x":88.8149900876127,
"y":147.75212636695
},
{
"height":0,
"id":10,
"name":"readableByAdmin",
"point":true,
"properties":[
{
"name":"default",
"type":"bool",
"value":true
},
{
"name":"readableBy",
"type":"string",
"value":"admin"
}],
"rotation":0,
"type":"variable",
"visible":true,
"width":0,
"x":182.132122529897,
"y":157.984268082113
}],
"opacity":1,
"type":"objectgroup",
"visible":true,
"x":0,
"y":0
}],
"nextlayerid":8,
"nextobjectid":11,
"orientation":"orthogonal",
"properties":[
{
"name":"script",
"type":"string",
"value":"script.js"
}],
"renderorder":"right-down",
"tiledversion":"2021.03.23",
"tileheight":32,
"tilesets":[
{
"columns":11,
"firstgid":1,
"image":"..\/tileset1.png",
"imageheight":352,
"imagewidth":352,
"margin":0,
"name":"tileset1",
"spacing":0,
"tilecount":121,
"tileheight":32,
"tilewidth":32
}],
"tilewidth":32,
"type":"map",
"version":1.5,
"width":10
}

View File

@ -127,15 +127,7 @@
<input type="radio" name="test-getCurrentUser"> Success <input type="radio" name="test-getCurrentUser"> Failure <input type="radio" name="test-getCurrentUser" checked> Pending
</td>
<td>
<a href="#" class="testLink" data-testmap="Metadata/getCurrentUser.json" target="_blank">Testing return current user attributes by Scripting API</a>
</td>
</tr>
<tr>
<td>
<input type="radio" name="test-getCurrentRoom"> Success <input type="radio" name="test-getCurrentRoom"> Failure <input type="radio" name="test-getCurrentRoom" checked> Pending
</td>
<td>
<a href="#" class="testLink" data-testmap="Metadata/getCurrentRoom.json" target="_blank">Testing return current room attributes by Scripting API (Need to test from current user)</a>
<a href="#" class="testLink" data-testmap="Metadata/getCurrentRoom.json" target="_blank">Testing return current player attributes in Scripting API + WA.onInit</a>
</td>
</tr>
<tr>
@ -194,6 +186,22 @@
<a href="#" class="testLink" data-testmap="Metadata/setTiles.json" target="_blank">Test set tiles</a>
</td>
</tr>
<tr>
<td>
<input type="radio" name="test-variables"> Success <input type="radio" name="test-variables"> Failure <input type="radio" name="test-variables" checked> Pending
</td>
<td>
<a href="#" class="testLink" data-testmap="Variables/variables.json" target="_blank">Testing scripting variables locally</a>
</td>
</tr>
<tr>
<td>
<input type="radio" name="test-shared-variables"> Success <input type="radio" name="test-shared-variables"> Failure <input type="radio" name="test-shared-variables" checked> Pending
</td>
<td>
<a href="#" class="testLink" data-testmap="Variables/shared_variables.json" target="_blank">Testing shared scripting variables</a>
</td>
</tr>
</table>
<script>

View File

@ -94,6 +94,7 @@ message ClientToServerMessage {
ReportPlayerMessage reportPlayerMessage = 11;
QueryJitsiJwtMessage queryJitsiJwtMessage = 12;
EmotePromptMessage emotePromptMessage = 13;
VariableMessage variableMessage = 14;
}
}
@ -107,6 +108,20 @@ message ItemEventMessage {
string parametersJson = 4;
}
message VariableMessage {
string name = 1;
string value = 2;
}
/**
* A variable, along the tag describing who it is targeted at
*/
message VariableWithTagMessage {
string name = 1;
string value = 2;
string readableBy = 3;
}
message PlayGlobalMessage {
string id = 1;
string type = 2;
@ -133,6 +148,8 @@ message SubMessage {
UserLeftMessage userLeftMessage = 5;
ItemEventMessage itemEventMessage = 6;
EmoteEventMessage emoteEventMessage = 7;
VariableMessage variableMessage = 8;
ErrorMessage errorMessage = 9;
}
}
@ -180,6 +197,7 @@ message RoomJoinedMessage {
repeated ItemStateMessage item = 3;
int32 currentUserId = 4;
repeated string tag = 5;
repeated VariableMessage variable = 6;
}
message WebRtcStartMessage {
@ -318,6 +336,10 @@ message ZoneMessage {
int32 y = 3;
}
message RoomMessage {
string roomId = 1;
}
message PusherToBackMessage {
oneof message {
JoinRoomMessage joinRoomMessage = 1;
@ -334,6 +356,7 @@ message PusherToBackMessage {
SendUserMessage sendUserMessage = 12;
BanUserMessage banUserMessage = 13;
EmotePromptMessage emotePromptMessage = 14;
VariableMessage variableMessage = 15;
}
}
@ -352,9 +375,22 @@ message SubToPusherMessage {
SendUserMessage sendUserMessage = 7;
BanUserMessage banUserMessage = 8;
EmoteEventMessage emoteEventMessage = 9;
ErrorMessage errorMessage = 10;
}
}
message BatchToPusherRoomMessage {
repeated SubToPusherRoomMessage payload = 2;
}
message SubToPusherRoomMessage {
oneof message {
VariableWithTagMessage variableMessage = 1;
ErrorMessage errorMessage = 2;
}
}
/*message BatchToAdminPusherMessage {
repeated SubToAdminPusherMessage payload = 2;
}*/
@ -424,9 +460,13 @@ message EmptyMessage {
}
/**
* Service handled by the "back". Pusher servers connect to this service.
*/
service RoomManager {
rpc joinRoom(stream PusherToBackMessage) returns (stream ServerToClientMessage);
rpc listenZone(ZoneMessage) returns (stream BatchToPusherMessage);
rpc joinRoom(stream PusherToBackMessage) returns (stream ServerToClientMessage); // Holds a connection between one given client and the back
rpc listenZone(ZoneMessage) returns (stream BatchToPusherMessage); // Connection used to send to a pusher messages related to a given zone of a given room
rpc listenRoom(RoomMessage) returns (stream BatchToPusherRoomMessage); // Connection used to send to a pusher messages related to a given room
rpc adminRoom(stream AdminPusherToBackMessage) returns (stream ServerToAdminClientMessage);
rpc sendAdminMessage(AdminMessage) returns (EmptyMessage);
rpc sendGlobalAdminMessage(AdminGlobalMessage) returns (EmptyMessage);

View File

@ -17,6 +17,7 @@ import {
ServerToClientMessage,
CompanionMessage,
EmotePromptMessage,
VariableMessage,
} from "../Messages/generated/messages_pb";
import { UserMovesMessage } from "../Messages/generated/messages_pb";
import { TemplatedApp } from "uWebSockets.js";
@ -357,6 +358,8 @@ export class IoSocketController {
socketManager.handleSilentMessage(client, message.getSilentmessage() as SilentMessage);
} else if (message.hasItemeventmessage()) {
socketManager.handleItemEvent(client, message.getItemeventmessage() as ItemEventMessage);
} else if (message.hasVariablemessage()) {
socketManager.handleVariableEvent(client, message.getVariablemessage() as VariableMessage);
} else if (message.hasWebrtcsignaltoservermessage()) {
socketManager.emitVideo(
client,

View File

@ -2,7 +2,29 @@ import { ExSocketInterface } from "_Model/Websocket/ExSocketInterface";
import { PositionDispatcher } from "./PositionDispatcher";
import { ViewportInterface } from "_Model/Websocket/ViewportMessage";
import { arrayIntersect } from "../Services/ArrayHelper";
import { ZoneEventListener } from "_Model/Zone";
import { GroupDescriptor, UserDescriptor, ZoneEventListener } from "_Model/Zone";
import { apiClientRepository } from "../Services/ApiClientRepository";
import {
BatchToPusherMessage,
BatchToPusherRoomMessage,
EmoteEventMessage,
ErrorMessage,
GroupLeftZoneMessage,
GroupUpdateZoneMessage,
RoomMessage,
SubMessage,
UserJoinedZoneMessage,
UserLeftZoneMessage,
UserMovedMessage,
VariableMessage,
VariableWithTagMessage,
ZoneMessage,
} from "../Messages/generated/messages_pb";
import Debug from "debug";
import { ClientReadableStream } from "grpc";
import { ExAdminSocketInterface } from "_Model/Websocket/ExAdminSocketInterface";
const debug = Debug("room");
export enum GameRoomPolicyTypes {
ANONYMOUS_POLICY = 1,
@ -15,6 +37,10 @@ export class PusherRoom {
public tags: string[];
public policyType: GameRoomPolicyTypes;
private versionNumber: number = 1;
private backConnection!: ClientReadableStream<BatchToPusherRoomMessage>;
private isClosing: boolean = false;
private listeners: Set<ExSocketInterface> = new Set<ExSocketInterface>();
//public readonly variables = new Map<string, string>();
constructor(public readonly roomUrl: string, private socketListener: ZoneEventListener) {
this.tags = [];
@ -28,8 +54,13 @@ export class PusherRoom {
this.positionNotifier.setViewport(socket, viewport);
}
public join(socket: ExSocketInterface) {
this.listeners.add(socket);
}
public leave(socket: ExSocketInterface) {
this.positionNotifier.removeViewport(socket);
this.listeners.delete(socket);
}
public canAccess(userTags: string[]): boolean {
@ -48,4 +79,75 @@ export class PusherRoom {
return false;
}
}
/**
* Creates a connection to the back server to track global messages relative to this room (like variable changes).
*/
public async init(): Promise<void> {
debug("Opening connection to room %s on back server", this.roomUrl);
const apiClient = await apiClientRepository.getClient(this.roomUrl);
const roomMessage = new RoomMessage();
roomMessage.setRoomid(this.roomUrl);
this.backConnection = apiClient.listenRoom(roomMessage);
this.backConnection.on("data", (batch: BatchToPusherRoomMessage) => {
for (const message of batch.getPayloadList()) {
if (message.hasVariablemessage()) {
const variableMessage = message.getVariablemessage() as VariableWithTagMessage;
const readableBy = variableMessage.getReadableby();
// We need to store all variables to dispatch variables later to the listeners
//this.variables.set(variableMessage.getName(), variableMessage.getValue(), readableBy);
// Let's dispatch this variable to all the listeners
for (const listener of this.listeners) {
const subMessage = new SubMessage();
if (!readableBy || listener.tags.includes(readableBy)) {
subMessage.setVariablemessage(variableMessage);
}
listener.emitInBatch(subMessage);
}
} else if (message.hasErrormessage()) {
const errorMessage = message.getErrormessage() as ErrorMessage;
// Let's dispatch this error to all the listeners
for (const listener of this.listeners) {
const subMessage = new SubMessage();
subMessage.setErrormessage(errorMessage);
listener.emitInBatch(subMessage);
}
} else {
throw new Error("Unexpected message");
}
}
});
this.backConnection.on("error", (e) => {
if (!this.isClosing) {
debug("Error on back connection");
this.close();
// Let's close all connections linked to that room
for (const listener of this.listeners) {
listener.disconnecting = true;
listener.end(1011, "Connection error between pusher and back server");
}
}
});
this.backConnection.on("close", () => {
if (!this.isClosing) {
debug("Close on back connection");
this.close();
// Let's close all connections linked to that room
for (const listener of this.listeners) {
listener.disconnecting = true;
listener.end(1011, "Connection closed between pusher and back server");
}
}
});
}
public close(): void {
debug("Closing connection to room %s on back server", this.roomUrl);
this.isClosing = true;
this.backConnection.cancel();
}
}

View File

@ -15,6 +15,7 @@ import {
ZoneMessage,
EmoteEventMessage,
CompanionMessage,
ErrorMessage,
} from "../Messages/generated/messages_pb";
import { ClientReadableStream } from "grpc";
import { PositionDispatcher } from "_Model/PositionDispatcher";
@ -30,6 +31,7 @@ export interface ZoneEventListener {
onGroupMoves(group: GroupDescriptor, listener: ExSocketInterface): void;
onGroupLeaves(groupId: number, listener: ExSocketInterface): void;
onEmote(emoteMessage: EmoteEventMessage, listener: ExSocketInterface): void;
onError(errorMessage: ErrorMessage, listener: ExSocketInterface): void;
}
/*export type EntersCallback = (thing: Movable, listener: User) => void;
@ -217,6 +219,9 @@ export class Zone {
} else if (message.hasEmoteeventmessage()) {
const emoteEventMessage = message.getEmoteeventmessage() as EmoteEventMessage;
this.notifyEmote(emoteEventMessage);
} else if (message.hasErrormessage()) {
const errorMessage = message.getErrormessage() as ErrorMessage;
this.notifyError(errorMessage);
} else {
throw new Error("Unexpected message");
}
@ -303,6 +308,12 @@ export class Zone {
}
}
private notifyError(errorMessage: ErrorMessage) {
for (const listener of this.listeners) {
this.socketListener.onError(errorMessage, listener);
}
}
/**
* Notify listeners of this zone that this group left
*/

View File

@ -30,6 +30,8 @@ import {
BanMessage,
RefreshRoomMessage,
EmotePromptMessage,
VariableMessage,
ErrorMessage,
} from "../Messages/generated/messages_pb";
import { ProtobufUtils } from "../Model/Websocket/ProtobufUtils";
import { ADMIN_API_URL, JITSI_ISS, SECRET_JITSI_KEY } from "../Enum/EnvironmentVariable";
@ -226,6 +228,9 @@ export class SocketManager implements ZoneEventListener {
const pusherToBackMessage = new PusherToBackMessage();
pusherToBackMessage.setJoinroommessage(joinRoomMessage);
streamToPusher.write(pusherToBackMessage);
const pusherRoom = await this.getOrCreateRoom(client.roomId);
pusherRoom.join(client);
} catch (e) {
console.error('An error occurred on "join_room" event');
console.error(e);
@ -277,6 +282,13 @@ export class SocketManager implements ZoneEventListener {
emitInBatch(listener, subMessage);
}
onError(errorMessage: ErrorMessage, listener: ExSocketInterface): void {
const subMessage = new SubMessage();
subMessage.setErrormessage(errorMessage);
emitInBatch(listener, subMessage);
}
// Useless now, will be useful again if we allow editing details in game
handleSetPlayerDetails(client: ExSocketInterface, playerDetailsMessage: SetPlayerDetailsMessage) {
const pusherToBackMessage = new PusherToBackMessage();
@ -299,6 +311,13 @@ export class SocketManager implements ZoneEventListener {
client.backConnection.write(pusherToBackMessage);
}
handleVariableEvent(client: ExSocketInterface, variableMessage: VariableMessage) {
const pusherToBackMessage = new PusherToBackMessage();
pusherToBackMessage.setVariablemessage(variableMessage);
client.backConnection.write(pusherToBackMessage);
}
async handleReportMessage(client: ExSocketInterface, reportPlayerMessage: ReportPlayerMessage) {
try {
await adminApi.reportPlayer(
@ -339,6 +358,7 @@ export class SocketManager implements ZoneEventListener {
room.leave(socket);
if (room.isEmpty()) {
room.close();
this.rooms.delete(socket.roomId);
debug("Room %s is empty. Deleting.", socket.roomId);
}
@ -368,7 +388,7 @@ export class SocketManager implements ZoneEventListener {
if (ADMIN_API_URL) {
await this.updateRoomWithAdminData(room);
}
await room.init();
this.rooms.set(roomUrl, room);
}
return room;

View File

@ -3,7 +3,7 @@
"experimentalDecorators": true,
/* Basic Options */
// "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,
"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. */