Merge branch 'develop' of github.com:thecodingmachine/workadventure into audioPlayerImprovements

This commit is contained in:
Lurkars 2021-08-11 19:55:36 +02:00
commit e8104f63ac
196 changed files with 7353 additions and 12315 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,69 @@
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)) {
//TODO fixme
//throw new Error("Invalid map format for map " + mapUrl);
console.error("Invalid map format for map " + mapUrl);
}
return res.data;
}
/**
* Returns true if the domain name is localhost of *.localhost
* Returns true if the domain name resolves to an IP address that is "private" (like 10.x.x.x or 192.168.x.x)
*
* @private
*/
async isLocalUrl(url: string): Promise<boolean> {
const urlObj = new URL(url);
if (urlObj.hostname === "localhost" || urlObj.hostname.endsWith(".localhost")) {
return true;
}
let addresses = [];
if (!ipaddr.isValid(urlObj.hostname)) {
const resolver = new Resolver();
addresses = await promisify(resolver.resolve).bind(resolver)(urlObj.hostname);
} else {
addresses = [urlObj.hostname];
}
for (const address of addresses) {
const addr = ipaddr.parse(address);
if (addr.range() !== "unicast") {
return true;
}
}
return false;
}
}
export const mapFetcher = new MapFetcher();

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,59 +1,62 @@
import "jasmine"; import "jasmine";
import {ConnectCallback, DisconnectCallback, GameRoom} from "../src/Model/GameRoom"; import { ConnectCallback, DisconnectCallback, GameRoom } from "../src/Model/GameRoom";
import {Point} from "../src/Model/Websocket/MessageUserPosition"; import { Point } from "../src/Model/Websocket/MessageUserPosition";
import {Group} from "../src/Model/Group"; import { Group } from "../src/Model/Group";
import {User, UserSocket} from "_Model/User"; import { User, UserSocket } from "_Model/User";
import {JoinRoomMessage, PositionMessage} from "../src/Messages/generated/messages_pb"; import { JoinRoomMessage, PositionMessage } from "../src/Messages/generated/messages_pb";
import Direction = PositionMessage.Direction; import Direction = PositionMessage.Direction;
import {EmoteCallback} from "_Model/Zone"; import { EmoteCallback } from "_Model/Zone";
function createMockUser(userId: number): User { function createMockUser(userId: number): User {
return { return {
userId userId,
} as unknown as User; } as unknown as User;
} }
function createMockUserSocket(): UserSocket { function createMockUserSocket(): UserSocket {
return { return {} as unknown as UserSocket;
} as unknown as UserSocket;
} }
function createJoinRoomMessage(uuid: string, x: number, y: number): JoinRoomMessage function createJoinRoomMessage(uuid: string, x: number, y: number): JoinRoomMessage {
{
const positionMessage = new PositionMessage(); const positionMessage = new PositionMessage();
positionMessage.setX(x); positionMessage.setX(x);
positionMessage.setY(y); positionMessage.setY(y);
positionMessage.setDirection(Direction.DOWN); positionMessage.setDirection(Direction.DOWN);
positionMessage.setMoving(false); positionMessage.setMoving(false);
const joinRoomMessage = new JoinRoomMessage(); const joinRoomMessage = new JoinRoomMessage();
joinRoomMessage.setUseruuid('1'); joinRoomMessage.setUseruuid("1");
joinRoomMessage.setIpaddress('10.0.0.2'); joinRoomMessage.setIpaddress("10.0.0.2");
joinRoomMessage.setName('foo'); joinRoomMessage.setName("foo");
joinRoomMessage.setRoomid('_/global/test.json'); joinRoomMessage.setRoomid("_/global/test.json");
joinRoomMessage.setPositionmessage(positionMessage); joinRoomMessage.setPositionmessage(positionMessage);
return joinRoomMessage; return joinRoomMessage;
} }
const emote: EmoteCallback = (emoteEventMessage, listener): void => {} const emote: EmoteCallback = (emoteEventMessage, listener): void => {};
describe("GameRoom", () => { describe("GameRoom", () => {
it("should connect user1 and user2", () => { it("should connect user1 and user2", async () => {
let connectCalledNumber: number = 0; let connectCalledNumber: number = 0;
const connect: ConnectCallback = (user: User, group: Group): void => { const connect: ConnectCallback = (user: User, group: Group): void => {
connectCalledNumber++; connectCalledNumber++;
} };
const disconnect: DisconnectCallback = (user: User, group: Group): void => { const disconnect: DisconnectCallback = (user: User, group: Group): void => {};
} const world = await GameRoom.create(
"https://play.workadventu.re/_/global/localhost/test.json",
connect,
disconnect,
160,
160,
() => {},
() => {},
() => {},
emote
);
const user1 = world.join(createMockUserSocket(), createJoinRoomMessage("1", 100, 100));
const world = new GameRoom('_/global/test.json', connect, disconnect, 160, 160, () => {}, () => {}, () => {}, emote); const user2 = world.join(createMockUserSocket(), createJoinRoomMessage("2", 500, 100));
const user1 = world.join(createMockUserSocket(), createJoinRoomMessage('1', 100, 100));
const user2 = world.join(createMockUserSocket(), createJoinRoomMessage('2', 500, 100));
world.updatePosition(user2, new Point(261, 100)); world.updatePosition(user2, new Point(261, 100));
@ -67,26 +70,34 @@ describe("GameRoom", () => {
expect(connectCalledNumber).toBe(2); expect(connectCalledNumber).toBe(2);
}); });
it("should connect 3 users", () => { it("should connect 3 users", async () => {
let connectCalled: boolean = false; let connectCalled: boolean = false;
const connect: ConnectCallback = (user: User, group: Group): void => { const connect: ConnectCallback = (user: User, group: Group): void => {
connectCalled = true; connectCalled = true;
} };
const disconnect: DisconnectCallback = (user: User, group: Group): void => { const disconnect: DisconnectCallback = (user: User, group: Group): void => {};
} const world = await GameRoom.create(
"https://play.workadventu.re/_/global/localhost/test.json",
connect,
disconnect,
160,
160,
() => {},
() => {},
() => {},
emote
);
const world = new GameRoom('_/global/test.json', connect, disconnect, 160, 160, () => {}, () => {}, () => {}, emote); const user1 = world.join(createMockUserSocket(), createJoinRoomMessage("1", 100, 100));
const user1 = world.join(createMockUserSocket(), createJoinRoomMessage('1', 100, 100)); const user2 = world.join(createMockUserSocket(), createJoinRoomMessage("2", 200, 100));
const user2 = world.join(createMockUserSocket(), createJoinRoomMessage('2', 200, 100));
expect(connectCalled).toBe(true); expect(connectCalled).toBe(true);
connectCalled = false; connectCalled = false;
// baz joins at the outer limit of the group // baz joins at the outer limit of the group
const user3 = world.join(createMockUserSocket(), createJoinRoomMessage('2', 311, 100)); const user3 = world.join(createMockUserSocket(), createJoinRoomMessage("2", 311, 100));
expect(connectCalled).toBe(false); expect(connectCalled).toBe(false);
@ -95,31 +106,40 @@ describe("GameRoom", () => {
expect(connectCalled).toBe(true); expect(connectCalled).toBe(true);
}); });
it("should disconnect user1 and user2", () => { it("should disconnect user1 and user2", async () => {
let connectCalled: boolean = false; let connectCalled: boolean = false;
let disconnectCallNumber: number = 0; let disconnectCallNumber: number = 0;
const connect: ConnectCallback = (user: User, group: Group): void => { const connect: ConnectCallback = (user: User, group: Group): void => {
connectCalled = true; connectCalled = true;
} };
const disconnect: DisconnectCallback = (user: User, group: Group): void => { const disconnect: DisconnectCallback = (user: User, group: Group): void => {
disconnectCallNumber++; disconnectCallNumber++;
} };
const world = new GameRoom('_/global/test.json', connect, disconnect, 160, 160, () => {}, () => {}, () => {}, emote); const world = await GameRoom.create(
"https://play.workadventu.re/_/global/localhost/test.json",
connect,
disconnect,
160,
160,
() => {},
() => {},
() => {},
emote
);
const user1 = world.join(createMockUserSocket(), createJoinRoomMessage('1', 100, 100)); const user1 = world.join(createMockUserSocket(), createJoinRoomMessage("1", 100, 100));
const user2 = world.join(createMockUserSocket(), createJoinRoomMessage('2', 259, 100)); const user2 = world.join(createMockUserSocket(), createJoinRoomMessage("2", 259, 100));
expect(connectCalled).toBe(true); expect(connectCalled).toBe(true);
expect(disconnectCallNumber).toBe(0); expect(disconnectCallNumber).toBe(0);
world.updatePosition(user2, new Point(100+160+160+1, 100)); world.updatePosition(user2, new Point(100 + 160 + 160 + 1, 100));
expect(disconnectCallNumber).toBe(2); expect(disconnectCallNumber).toBe(2);
world.updatePosition(user2, new Point(262, 100)); world.updatePosition(user2, new Point(262, 100));
expect(disconnectCallNumber).toBe(2); expect(disconnectCallNumber).toBe(2);
}); });
});
})

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -79,6 +79,58 @@ Example :
WA.room.setProperty('wikiLayer', 'openWebsite', 'https://www.wikipedia.org/'); WA.room.setProperty('wikiLayer', 'openWebsite', 'https://www.wikipedia.org/');
``` ```
### Get the room id
```
WA.room.id: string;
```
The ID of the current room is available from the `WA.room.id` property.
{.alert.alert-info}
You need to wait for the end of the initialization before accessing `WA.room.id`
```typescript
WA.onInit().then(() => {
console.log('Room id: ', WA.room.id);
// Will output something like: 'https://play.workadventu.re/@/myorg/myworld/myroom', or 'https://play.workadventu.re/_/global/mymap.org/map.json"
})
```
### Get the map URL
```
WA.room.mapURL: string;
```
The URL of the map is available from the `WA.room.mapURL` property.
{.alert.alert-info}
You need to wait for the end of the initialization before accessing `WA.room.mapURL`
```typescript
WA.onInit().then(() => {
console.log('Map URL: ', WA.room.mapURL);
// Will output something like: 'https://mymap.org/map.json"
})
```
### Getting map data
```
WA.room.getTiledMap(): Promise<ITiledMap>
```
Returns a promise that resolves to the JSON map file.
```javascript
const map = await WA.room.getTiledMap();
console.log("Map generated with Tiled version ", map.tiledversion);
```
Check the [Tiled documentation to learn more about the format of the JSON map](https://doc.mapeditor.org/en/stable/reference/json-map-format/).
### Changing tiles ### Changing tiles
``` ```
WA.room.setTiles(tiles: TileDescriptor[]): void WA.room.setTiles(tiles: TileDescriptor[]): void
@ -111,3 +163,115 @@ WA.room.setTiles([
{x: 9, y: 4, tile: 'blue', layer: 'setTiles'} {x: 9, y: 4, tile: 'blue', layer: 'setTiles'}
]); ]);
``` ```
### Loading a tileset
```
WA.room.loadTileset(url: string): Promise<number>
```
Load a tileset in JSON format from an url and return the id of the first tile of the loaded tileset.
You can create a tileset file in Tile Editor.
```javascript
WA.room.loadTileset("Assets/Tileset.json").then((firstId) => {
WA.room.setTiles([{x: 4, y: 4, tile: firstId, layer: 'bottom'}]);
})
```
## Embedding websites in a map
You can use the scripting API to embed websites in a map, or to edit websites that are already embedded (using the ["website" objects](website-in-map.md)).
### Getting an instance of a website already embedded in the map
```
WA.room.website.get(objectName: string): Promise<EmbeddedWebsite>
```
You can get an instance of an embedded website by using the `WA.room.website.get()` method.
It returns a promise of an `EmbeddedWebsite` instance.
```javascript
// Get an existing website object where 'my_website' is the name of the object (on any layer object of the map)
const website = await WA.room.website.get('my_website');
website.url = 'https://example.com';
website.visible = true;
```
### Adding a new website in a map
```
WA.room.website.create(website: CreateEmbeddedWebsiteEvent): EmbeddedWebsite
interface CreateEmbeddedWebsiteEvent {
name: string; // A unique name for this iframe
url: string; // The URL the iframe points to.
position: {
x: number, // In pixels, relative to the map coordinates
y: number, // In pixels, relative to the map coordinates
width: number, // In pixels, sensitive to zoom level
height: number, // In pixels, sensitive to zoom level
},
visible?: boolean, // Whether to display the iframe or not
allowApi?: boolean, // Whether the scripting API should be available to the iframe
allow?: string, // The list of feature policies allowed
}
```
You can create an instance of an embedded website by using the `WA.room.website.create()` method.
It returns an `EmbeddedWebsite` instance.
```javascript
// Create a new website object
const website = WA.room.website.create({
name: "my_website",
url: "https://example.com",
position: {
x: 64,
y: 128,
width: 320,
height: 240,
},
visible: true,
allowApi: true,
allow: "fullscreen",
});
```
### Deleting a website from a map
```
WA.room.website.delete(name: string): Promise<void>
```
Use `WA.room.website.delete` to completely remove an embedded website from your map.
### The EmbeddedWebsite class
Instances of the `EmbeddedWebsite` class represent the website displayed on the map.
```typescript
class EmbeddedWebsite {
readonly name: string;
url: string;
visible: boolean;
allow: string;
allowApi: boolean;
x: number; // In pixels, relative to the map coordinates
y: number; // In pixels, relative to the map coordinates
width: number; // In pixels, sensitive to zoom level
height: number; // In pixels, sensitive to zoom level
}
```
When you modify a property of an `EmbeddedWebsite` instance, the iframe is automatically modified in the map.
{.alert.alert-warning}
The websites you add/edit/delete via the scripting API are only shown locally. If you want them
to be displayed for every player, you can use [variables](api-start.md) to share a common state
between all users.

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

@ -86,4 +86,51 @@ WA.ui.registerMenuCommand("test", () => {
<div class="col"> <div class="col">
<img src="https://workadventu.re/img/docs/menu-command.png" class="figure-img img-fluid rounded" alt="" /> <img src="https://workadventu.re/img/docs/menu-command.png" class="figure-img img-fluid rounded" alt="" />
</div> </div>
### Awaiting User Confirmation (with space bar)
```
WA.ui.displayActionMessage({
message: string,
callback: () => void,
type?: "message"|"warning",
}): ActionMessage
```
Displays a message at the bottom of the screen (that will disappear when space bar is pressed).
<div class="col">
<img src="https://workadventu.re/img/docs/trigger_message.png" class="figure-img img-fluid rounded" alt="" />
</div>
Example:
```javascript
const triggerMessage = WA.ui.displayActionMessage({
message: "press 'space' to confirm",
callback: () => {
WA.chat.sendChatMessage("confirmed", "trigger message logic")
}
});
setTimeout(() => {
// later
triggerMessage.remove();
}, 1000)
```
Please note that `displayActionMessage` returns an object of the `ActionMessage` class.
The `ActionMessage` class contains a single method: `remove(): Promise<void>`. This will obviously remove the message when called.
```javascript
class ActionMessage {
/**
* Hides the message
*/
remove() {};
}
```

View File

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

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

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

View File

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

View File

@ -0,0 +1,40 @@
{.section-title.accent.text-primary}
# Putting a website inside a map
You can inject a website directly into your map, at a given position.
To do this in Tiled:
- Select an object layer
- Create a rectangular object, at the position where you want your website to appear
- Add a `url` property to your object pointing to the URL you want to open
<div>
<figure class="figure">
<img src="https://workadventu.re/img/docs/website_url_property.png" class="figure-img img-fluid rounded" alt="" style="width: 70%" />
<figcaption class="figure-caption">A "website" object</figcaption>
</figure>
</div>
The `url` can be absolute, or relative to your map.
{.alert.alert-info}
Internally, WorkAdventure will create an "iFrame" to load the website.
Some websites forbid being opened by iframes using the [`X-Frame-Options](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Frame-Options)
HTTP header.
{.alert.alert-warning}
Please note that the website always appears **on top** of the tiles (even if you put the object layer that
contains the "website" object under the tiles).
## Allowing the scripting API in your iframe
If you are planning to use the WorkAdventure scripting API inside your iframe, you need
to explicitly allow it, by setting an additional `allowApi` property to `true`.
<div>
<figure class="figure">
<img src="https://workadventu.re/img/docs/website_allowapi_property.png" class="figure-img img-fluid rounded" alt="" style="width: 70%" />
<figcaption class="figure-caption">A "website" object that can communicate using the Iframe API</figcaption>
</figure>
</div>

View File

@ -26,7 +26,6 @@
"rules": { "rules": {
"no-unused-vars": "off", "no-unused-vars": "off",
"@typescript-eslint/no-explicit-any": "error", "@typescript-eslint/no-explicit-any": "error",
// TODO: remove those ignored rules and write a stronger code! // TODO: remove those ignored rules and write a stronger code!
"@typescript-eslint/no-floating-promises": "off", "@typescript-eslint/no-floating-promises": "off",
"@typescript-eslint/no-unsafe-call": "off", "@typescript-eslint/no-unsafe-call": "off",

View File

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

View File

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

View File

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

30
front/dist/service-worker-dev.js vendored Normal file
View File

@ -0,0 +1,30 @@
let CACHE_NAME = 'workavdenture-cache-dev';
let urlsToCache = [
'/'
];
self.addEventListener('install', function(event) {
// Perform install steps
event.waitUntil(
caches.open(CACHE_NAME)
.then((cache) => {
return cache.addAll(urlsToCache);
})
);
});
self.addEventListener('fetch', (event) => {
//never cache data will be stored in dev mode
});
self.addEventListener('wait', function(event) {
//TODO wait
});
self.addEventListener('update', function(event) {
//TODO update
});
self.addEventListener('beforeinstallprompt', (e) => {
//TODO change prompt
});

View File

@ -1,4 +1,4 @@
let CACHE_NAME = 'workavdenture-cache-v1'; let CACHE_NAME = 'workavdenture-cache-v1.4.14';
let urlsToCache = [ let urlsToCache = [
'/' '/'
]; ];
@ -8,7 +8,6 @@ self.addEventListener('install', function(event) {
event.waitUntil( event.waitUntil(
caches.open(CACHE_NAME) caches.open(CACHE_NAME)
.then(function(cache) { .then(function(cache) {
console.log('Opened cache');
return cache.addAll(urlsToCache); return cache.addAll(urlsToCache);
}) })
); );
@ -48,6 +47,14 @@ self.addEventListener('fetch', function(event) {
); );
}); });
self.addEventListener('activate', function(event) { self.addEventListener('wait', function(event) {
//TODO activate service worker //TODO wait
});
self.addEventListener('update', function(event) {
//TODO update
});
self.addEventListener('beforeinstallprompt', (e) => {
//TODO change prompt
}); });

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 880 B

After

Width:  |  Height:  |  Size: 754 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 933 B

After

Width:  |  Height:  |  Size: 922 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 978 B

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 985 B

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

View File

@ -48,41 +48,10 @@
"type": "image\/png" "type": "image\/png"
}, },
{ {
"src": "/static/images/favicons/android-icon-36x36.png", "src": "/static/images/favicons/apple-icon.png",
"sizes": "36x36", "sizes": "192x192",
"type": "image\/png", "type": "image\/png",
"density": "0.75" "purpose": "any"
},
{
"src": "/static/images/favicons/android-icon-48x48.png",
"sizes": "48x48",
"type": "image\/png",
"density": "1.0"
},
{
"src": "/static/images/favicons/android-icon-72x72.png",
"sizes": "72x72",
"type": "image\/png",
"density": "1.5"
},
{
"src": "/static/images/favicons/favicon-16x16.png",
"sizes": "16x16",
"type": "image\/png",
"density": "1"
},
{
"src": "/static/images/favicons/favicon-32x32.png",
"sizes": "32x32",
"type": "image\/png",
"density": "1.5"
},
{
"src": "/static/images/favicons/favicon-96x96.png",
"sizes": "96x96",
"type": "image\/png",
"density": "2.0"
}, },
{ {
@ -122,6 +91,25 @@
"density": "4.0", "density": "4.0",
"purpose": "any maskable" "purpose": "any maskable"
}, },
{
"src": "/static/images/favicons/favicon-16x16.png",
"sizes": "16x16",
"type": "image\/png",
"density": "1"
},
{
"src": "/static/images/favicons/favicon-32x32.png",
"sizes": "32x32",
"type": "image\/png",
"density": "1.5"
},
{
"src": "/static/images/favicons/favicon-96x96.png",
"sizes": "96x96",
"type": "image\/png",
"density": "2.0"
},
{ {
"src": "/static/images/favicons/icon-512x512.png", "src": "/static/images/favicons/icon-512x512.png",
"sizes": "512x512", "sizes": "512x512",
@ -132,6 +120,7 @@
"background_color": "#000000", "background_color": "#000000",
"display_override": ["window-control-overlay", "minimal-ui"], "display_override": ["window-control-overlay", "minimal-ui"],
"display": "standalone", "display": "standalone",
"orientation": "portrait-primary",
"scope": "/", "scope": "/",
"lang": "en", "lang": "en",
"theme_color": "#000000", "theme_color": "#000000",

8393
front/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -10,6 +10,7 @@
"@types/mini-css-extract-plugin": "^1.4.3", "@types/mini-css-extract-plugin": "^1.4.3",
"@types/node": "^15.3.0", "@types/node": "^15.3.0",
"@types/quill": "^1.3.7", "@types/quill": "^1.3.7",
"@types/uuidv4": "^5.0.0",
"@types/webpack-dev-server": "^3.11.4", "@types/webpack-dev-server": "^3.11.4",
"@typescript-eslint/eslint-plugin": "^4.23.0", "@typescript-eslint/eslint-plugin": "^4.23.0",
"@typescript-eslint/parser": "^4.23.0", "@typescript-eslint/parser": "^4.23.0",
@ -50,10 +51,12 @@
"phaser3-rex-plugins": "^1.1.42", "phaser3-rex-plugins": "^1.1.42",
"queue-typescript": "^1.0.1", "queue-typescript": "^1.0.1",
"quill": "1.3.6", "quill": "1.3.6",
"quill-delta-to-html": "^0.12.0",
"rxjs": "^6.6.3", "rxjs": "^6.6.3",
"simple-peer": "^9.11.0", "simple-peer": "^9.11.0",
"socket.io-client": "^2.3.0", "socket.io-client": "^2.3.0",
"standardized-audio-context": "^25.2.4" "standardized-audio-context": "^25.2.4",
"uuidv4": "^6.2.10"
}, },
"scripts": { "scripts": {
"start": "run-p templater serve svelte-check-watch", "start": "run-p templater serve svelte-check-watch",

View File

@ -1,92 +0,0 @@
import {HtmlUtils} from "./../WebRtc/HtmlUtils";
import {PUSHER_URL, UPLOADER_URL} from "../Enum/EnvironmentVariable";
import type {RoomConnection} from "../Connexion/RoomConnection";
import type {PlayGlobalMessageInterface} from "../Connexion/ConnexionModels";
import {soundPlayingStore} from "../Stores/SoundPlayingStore";
import {soundManager} from "../Phaser/Game/SoundManager";
import {AdminMessageEventTypes} from "../Connexion/AdminMessagesService";
export class GlobalMessageManager {
constructor(private Connection: RoomConnection) {
this.initialise();
}
initialise(){
//receive signal to show message
this.Connection.receivePlayGlobalMessage((message: PlayGlobalMessageInterface) => {
this.playMessage(message);
});
//receive signal to close message
this.Connection.receiveStopGlobalMessage((messageId: string) => {
this.stopMessage(messageId);
});
//receive signal to close message
this.Connection.receiveTeleportMessage((map: string) => {
console.log('map to teleport user', map);
//TODO teleport user on map
});
}
private playMessage(message : PlayGlobalMessageInterface){
const previousMessage = document.getElementById(this.getHtmlMessageId(message.id));
if(previousMessage){
previousMessage.remove();
}
if(AdminMessageEventTypes.audio === message.type){
this.playAudioMessage(message.id, message.message);
}
if(AdminMessageEventTypes.admin === message.type){
this.playTextMessage(message.id, message.message);
}
}
private playAudioMessage(messageId : string, urlMessage: string) {
soundPlayingStore.playSound(UPLOADER_URL + urlMessage);
}
private playTextMessage(messageId : string, htmlMessage: string){
//add button to clear message
const buttonText = document.createElement('p');
buttonText.id = 'button-clear-message';
buttonText.innerText = 'Clear';
const buttonMainConsole = document.createElement('div');
buttonMainConsole.classList.add('clear');
buttonMainConsole.appendChild(buttonText);
buttonMainConsole.addEventListener('click', () => {
messageContainer.style.top = '-80%';
setTimeout(() => {
messageContainer.remove();
buttonMainConsole.remove();
});
});
//create content message
const messageCotent = document.createElement('div');
messageCotent.innerHTML = htmlMessage;
messageCotent.className = "content-message";
//add message container
const messageContainer = document.createElement('div');
messageContainer.id = this.getHtmlMessageId(messageId);
messageContainer.className = "message-container";
messageContainer.appendChild(messageCotent);
messageContainer.appendChild(buttonMainConsole);
const mainSectionDiv = HtmlUtils.getElementByIdOrFail<HTMLDivElement>('main-container');
mainSectionDiv.appendChild(messageContainer);
}
private stopMessage(messageId: string){
HtmlUtils.removeElementByIdOrFail<HTMLDivElement>(this.getHtmlMessageId(messageId));
}
private getHtmlMessageId(messageId: string) : string{
return `message-${messageId}`;
}
}

View File

@ -1,95 +0,0 @@
import type {TypeMessageInterface} from "./UserMessageManager";
import {HtmlUtils} from "../WebRtc/HtmlUtils";
let modalTimeOut : NodeJS.Timeout;
export class TypeMessageExt implements TypeMessageInterface{
private nbSecond = 0;
private maxNbSecond = 10;
private titleMessage = 'IMPORTANT !';
showMessage(message: string, canDeleteMessage: boolean = true): void {
//delete previous modal
try{
if(modalTimeOut){
clearTimeout(modalTimeOut);
}
const modal = HtmlUtils.getElementByIdOrFail('report-message-user');
modal.remove();
}catch (err){
console.error(err);
}
//create new modal
const div : HTMLDivElement = document.createElement('div');
div.classList.add('modal-report-user');
div.id = 'report-message-user';
div.style.backgroundColor = '#000000e0';
const img : HTMLImageElement = document.createElement('img');
img.src = 'resources/logos/report.svg';
div.appendChild(img);
const title : HTMLParagraphElement = document.createElement('p');
title.id = 'title-report-user';
title.innerText = `${this.titleMessage} (${this.maxNbSecond})`;
div.appendChild(title);
const p : HTMLParagraphElement = document.createElement('p');
p.id = 'body-report-user'
p.innerText = message;
div.appendChild(p);
const mainSectionDiv = HtmlUtils.getElementByIdOrFail<HTMLDivElement>('main-container');
mainSectionDiv.appendChild(div);
const reportMessageAudio = HtmlUtils.getElementByIdOrFail<HTMLAudioElement>('report-message');
// FIXME: this will fail on iOS
// We should move the sound playing into the GameScene and listen to the event of a report using a store
try {
reportMessageAudio.play();
} catch (e) {
console.error(e);
}
this.nbSecond = this.maxNbSecond;
setTimeout((c) => {
this.forMessage(title, canDeleteMessage);
}, 1000);
}
forMessage(title: HTMLParagraphElement, canDeleteMessage: boolean = true){
this.nbSecond -= 1;
title.innerText = `${this.titleMessage} (${this.nbSecond})`;
if(this.nbSecond > 0){
modalTimeOut = setTimeout(() => {
this.forMessage(title, canDeleteMessage);
}, 1000);
}else {
title.innerText = this.titleMessage;
if (!canDeleteMessage) {
return;
}
const imgCancel: HTMLImageElement = document.createElement('img');
imgCancel.id = 'cancel-report-user';
imgCancel.src = 'resources/logos/close.svg';
const div = HtmlUtils.getElementByIdOrFail<HTMLDivElement>('report-message-user');
div.appendChild(imgCancel);
imgCancel.addEventListener('click', () => {
div.remove();
});
}
}
}
export class Message extends TypeMessageExt {}
export class Ban extends TypeMessageExt {}
export class Banned extends TypeMessageExt {
showMessage(message: string){
super.showMessage(message, false);
}
}

View File

@ -1,43 +1,34 @@
import * as TypeMessages from "./TypeMessage"; import { AdminMessageEventTypes, adminMessagesService } from "../Connexion/AdminMessagesService";
import {Banned} from "./TypeMessage"; import { textMessageContentStore, textMessageVisibleStore } from "../Stores/TypeMessageStore/TextMessageStore";
import {adminMessagesService} from "../Connexion/AdminMessagesService"; import { soundPlayingStore } from "../Stores/SoundPlayingStore";
import { UPLOADER_URL } from "../Enum/EnvironmentVariable";
export interface TypeMessageInterface { import { banMessageContentStore, banMessageVisibleStore } from "../Stores/TypeMessageStore/BanMessageStore";
showMessage(message: string): void;
}
class UserMessageManager { class UserMessageManager {
typeMessages: Map<string, TypeMessageInterface> = new Map<string, TypeMessageInterface>();
receiveBannedMessageListener!: Function; receiveBannedMessageListener!: Function;
constructor() { constructor() {
const valueTypeMessageTab = Object.values(TypeMessages);
Object.keys(TypeMessages).forEach((value: string, index: number) => {
const typeMessageInstance: TypeMessageInterface = (new valueTypeMessageTab[index]() as TypeMessageInterface);
this.typeMessages.set(value.toLowerCase(), typeMessageInstance);
});
adminMessagesService.messageStream.subscribe((event) => { adminMessagesService.messageStream.subscribe((event) => {
const typeMessage = this.showMessage(event.type, event.text); textMessageVisibleStore.set(false);
if(typeMessage instanceof Banned) { banMessageVisibleStore.set(false);
if (event.type === AdminMessageEventTypes.admin) {
textMessageContentStore.set(event.text);
textMessageVisibleStore.set(true);
} else if (event.type === AdminMessageEventTypes.audio) {
soundPlayingStore.playSound(UPLOADER_URL + event.text);
} else if (event.type === AdminMessageEventTypes.ban) {
banMessageContentStore.set(event.text);
banMessageVisibleStore.set(true);
} else if (event.type === AdminMessageEventTypes.banned) {
banMessageContentStore.set(event.text);
banMessageVisibleStore.set(true);
this.receiveBannedMessageListener(); this.receiveBannedMessageListener();
} }
}) });
} }
showMessage(type: string, message: string) { setReceiveBanListener(callback: Function) {
const classTypeMessage = this.typeMessages.get(type.toLowerCase());
if (!classTypeMessage) {
console.error('Message unknown');
return;
}
classTypeMessage.showMessage(message);
return classTypeMessage;
}
setReceiveBanListener(callback: Function){
this.receiveBannedMessageListener = callback; this.receiveBannedMessageListener = callback;
} }
} }
export const userMessageManager = new UserMessageManager() export const userMessageManager = new UserMessageManager();

View File

@ -0,0 +1,48 @@
import * as tg from "generic-type-guard";
export const isRectangle = new tg.IsInterface()
.withProperties({
x: tg.isNumber,
y: tg.isNumber,
width: tg.isNumber,
height: tg.isNumber,
})
.get();
export const isEmbeddedWebsiteEvent = new tg.IsInterface()
.withProperties({
name: tg.isString,
})
.withOptionalProperties({
url: tg.isString,
visible: tg.isBoolean,
allowApi: tg.isBoolean,
allow: tg.isString,
x: tg.isNumber,
y: tg.isNumber,
width: tg.isNumber,
height: tg.isNumber,
})
.get();
export const isCreateEmbeddedWebsiteEvent = new tg.IsInterface()
.withProperties({
name: tg.isString,
url: tg.isString,
position: isRectangle,
})
.withOptionalProperties({
visible: tg.isBoolean,
allowApi: tg.isBoolean,
allow: tg.isString,
})
.get();
/**
* A message sent from the iFrame to the game to modify an embedded website
*/
export type ModifyEmbeddedWebsiteEvent = tg.GuardedType<typeof isEmbeddedWebsiteEvent>;
export type CreateEmbeddedWebsiteEvent = tg.GuardedType<typeof isCreateEmbeddedWebsiteEvent>;
// TODO: make a variation that is all optional (except for the name)
export type Rectangle = tg.GuardedType<typeof isRectangle>;

View File

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

View File

@ -1,4 +1,4 @@
import type { GameStateEvent } from "./GameStateEvent"; import * as tg from "generic-type-guard";
import type { ButtonClickedEvent } from "./ButtonClickedEvent"; import type { ButtonClickedEvent } from "./ButtonClickedEvent";
import type { ChatEvent } from "./ChatEvent"; import type { ChatEvent } from "./ChatEvent";
import type { ClosePopupEvent } from "./ClosePopupEvent"; import type { ClosePopupEvent } from "./ClosePopupEvent";
@ -9,7 +9,7 @@ import type { OpenCoWebSiteEvent } from "./OpenCoWebSiteEvent";
import type { OpenPopupEvent } from "./OpenPopupEvent"; import type { OpenPopupEvent } from "./OpenPopupEvent";
import type { OpenTabEvent } from "./OpenTabEvent"; import type { OpenTabEvent } from "./OpenTabEvent";
import type { UserInputChatEvent } from "./UserInputChatEvent"; import type { UserInputChatEvent } from "./UserInputChatEvent";
import type { DataLayerEvent } from "./DataLayerEvent"; import type { MapDataEvent } from "./MapDataEvent";
import type { LayerEvent } from "./LayerEvent"; import type { LayerEvent } from "./LayerEvent";
import type { SetPropertyEvent } from "./setPropertyEvent"; import type { SetPropertyEvent } from "./setPropertyEvent";
import type { LoadSoundEvent } from "./LoadSoundEvent"; import type { LoadSoundEvent } from "./LoadSoundEvent";
@ -18,6 +18,21 @@ import type { MenuItemClickedEvent } from "./ui/MenuItemClickedEvent";
import type { MenuItemRegisterEvent } from "./ui/MenuItemRegisterEvent"; import type { MenuItemRegisterEvent } from "./ui/MenuItemRegisterEvent";
import type { HasPlayerMovedEvent } from "./HasPlayerMovedEvent"; import type { HasPlayerMovedEvent } from "./HasPlayerMovedEvent";
import type { SetTilesEvent } from "./SetTilesEvent"; import type { SetTilesEvent } from "./SetTilesEvent";
import type { SetVariableEvent } from "./SetVariableEvent";
import { isGameStateEvent } from "./GameStateEvent";
import { isMapDataEvent } from "./MapDataEvent";
import { isSetVariableEvent } from "./SetVariableEvent";
import type { EmbeddedWebsite } from "../iframe/Room/EmbeddedWebsite";
import { isCreateEmbeddedWebsiteEvent } from "./EmbeddedWebsiteEvent";
import type { LoadTilesetEvent } from "./LoadTilesetEvent";
import { isLoadTilesetEvent } from "./LoadTilesetEvent";
import type {
MessageReferenceEvent,
removeActionMessage,
triggerActionMessage,
TriggerActionMessageEvent,
} from "./ui/TriggerActionMessageEvent";
import { isMessageReferenceEvent, isTriggerActionMessageEvent } from "./ui/TriggerActionMessageEvent";
export interface TypedMessageEvent<T> extends MessageEvent { export interface TypedMessageEvent<T> extends MessageEvent {
data: T; data: T;
@ -43,13 +58,14 @@ export type IframeEventMap = {
showLayer: LayerEvent; showLayer: LayerEvent;
hideLayer: LayerEvent; hideLayer: LayerEvent;
setProperty: SetPropertyEvent; setProperty: SetPropertyEvent;
getDataLayer: undefined;
loadSound: LoadSoundEvent; loadSound: LoadSoundEvent;
playSound: PlaySoundEvent; playSound: PlaySoundEvent;
stopSound: null; stopSound: null;
getState: undefined; getState: undefined;
loadTileset: LoadTilesetEvent;
registerMenuCommand: MenuItemRegisterEvent; registerMenuCommand: MenuItemRegisterEvent;
setTiles: SetTilesEvent; setTiles: SetTilesEvent;
modifyEmbeddedWebsite: Partial<EmbeddedWebsite>; // Note: name should be compulsory in fact
}; };
export interface IframeEvent<T extends keyof IframeEventMap> { export interface IframeEvent<T extends keyof IframeEventMap> {
type: T; type: T;
@ -66,8 +82,9 @@ export interface IframeResponseEventMap {
leaveEvent: EnterLeaveEvent; leaveEvent: EnterLeaveEvent;
buttonClickedEvent: ButtonClickedEvent; buttonClickedEvent: ButtonClickedEvent;
hasPlayerMoved: HasPlayerMovedEvent; hasPlayerMoved: HasPlayerMovedEvent;
dataLayer: DataLayerEvent;
menuItemClicked: MenuItemClickedEvent; menuItemClicked: MenuItemClickedEvent;
setVariable: SetVariableEvent;
messageTriggered: MessageReferenceEvent;
} }
export interface IframeResponseEvent<T extends keyof IframeResponseEventMap> { export interface IframeResponseEvent<T extends keyof IframeResponseEventMap> {
type: T; type: T;
@ -79,20 +96,63 @@ export const isIframeResponseEventWrapper = (event: {
type?: string; type?: string;
}): event is IframeResponseEvent<keyof IframeResponseEventMap> => typeof event.type === "string"; }): event is IframeResponseEvent<keyof IframeResponseEventMap> => typeof event.type === "string";
/** /**
* List event types sent from an iFrame to WorkAdventure that expect a unique answer from WorkAdventure along the type for the answer from WorkAdventure to the iFrame * List event types sent from an iFrame to WorkAdventure that expect a unique answer from WorkAdventure along the type for the answer from WorkAdventure to the iFrame.
* Types are defined using Type guards that will actually bused to enforce and check types.
*/ */
export type IframeQueryMap = { export const iframeQueryMapTypeGuards = {
getState: { getState: {
query: undefined, query: tg.isUndefined,
answer: GameStateEvent answer: isGameStateEvent,
}, },
} getMapData: {
query: tg.isUndefined,
answer: isMapDataEvent,
},
setVariable: {
query: isSetVariableEvent,
answer: tg.isUndefined,
},
loadTileset: {
query: isLoadTilesetEvent,
answer: tg.isNumber,
},
triggerActionMessage: {
query: isTriggerActionMessageEvent,
answer: tg.isUndefined,
},
removeActionMessage: {
query: isMessageReferenceEvent,
answer: tg.isUndefined,
},
getEmbeddedWebsite: {
query: tg.isString,
answer: isCreateEmbeddedWebsiteEvent,
},
deleteEmbeddedWebsite: {
query: tg.isString,
answer: tg.isUndefined,
},
createEmbeddedWebsite: {
query: isCreateEmbeddedWebsiteEvent,
answer: tg.isUndefined,
},
};
type GuardedType<T> = T extends (x: unknown) => x is infer T ? T : never;
type IframeQueryMapTypeGuardsType = typeof iframeQueryMapTypeGuards;
type UnknownToVoid<T> = undefined extends T ? void : T;
export type IframeQueryMap = {
[key in keyof IframeQueryMapTypeGuardsType]: {
query: GuardedType<IframeQueryMapTypeGuardsType[key]["query"]>;
answer: UnknownToVoid<GuardedType<IframeQueryMapTypeGuardsType[key]["answer"]>>;
};
};
export interface IframeQuery<T extends keyof IframeQueryMap> { export interface IframeQuery<T extends keyof IframeQueryMap> {
type: T; type: T;
data: IframeQueryMap[T]['query']; data: IframeQueryMap[T]["query"];
} }
export interface IframeQueryWrapper<T extends keyof IframeQueryMap> { export interface IframeQueryWrapper<T extends keyof IframeQueryMap> {
@ -100,19 +160,41 @@ export interface IframeQueryWrapper<T extends keyof IframeQueryMap> {
query: IframeQuery<T>; query: IframeQuery<T>;
} }
// eslint-disable-next-line @typescript-eslint/no-explicit-any export const isIframeQueryKey = (type: string): type is keyof IframeQueryMap => {
export const isIframeQuery = (event: any): event is IframeQuery<keyof IframeQueryMap> => typeof event.type === 'string'; return type in iframeQueryMapTypeGuards;
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
export const isIframeQueryWrapper = (event: any): event is IframeQueryWrapper<keyof IframeQueryMap> => typeof event.id === 'number' && isIframeQuery(event.query); export const isIframeQuery = (event: any): event is IframeQuery<keyof IframeQueryMap> => {
const type = event.type;
if (typeof type !== "string") {
return false;
}
if (!isIframeQueryKey(type)) {
return false;
}
const result = iframeQueryMapTypeGuards[type].query(event.data);
if (!result) {
console.warn('Received a query with type "' + type + '" but the payload is invalid.');
}
return result;
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const isIframeQueryWrapper = (event: any): event is IframeQueryWrapper<keyof IframeQueryMap> =>
typeof event.id === "number" && isIframeQuery(event.query);
export interface IframeAnswerEvent<T extends keyof IframeQueryMap> { export interface IframeAnswerEvent<T extends keyof IframeQueryMap> {
id: number; id: number;
type: T; type: T;
data: IframeQueryMap[T]['answer']; data: IframeQueryMap[T]["answer"];
} }
export const isIframeAnswerEvent = (event: { type?: string, id?: number }): event is IframeAnswerEvent<keyof IframeQueryMap> => typeof event.type === 'string' && typeof event.id === 'number'; export const isIframeAnswerEvent = (event: {
type?: string;
id?: number;
}): event is IframeAnswerEvent<keyof IframeQueryMap> => typeof event.type === "string" && typeof event.id === "number";
export interface IframeErrorAnswerEvent { export interface IframeErrorAnswerEvent {
id: number; id: number;
@ -120,4 +202,9 @@ export interface IframeErrorAnswerEvent {
error: string; error: string;
} }
export const isIframeErrorAnswerEvent = (event: { type?: string, id?: number, error?: string }): event is IframeErrorAnswerEvent => typeof event.type === 'string' && typeof event.id === 'number' && typeof event.error === 'string'; export const isIframeErrorAnswerEvent = (event: {
type?: string;
id?: number;
error?: string;
}): event is IframeErrorAnswerEvent =>
typeof event.type === "string" && typeof event.id === "number" && typeof event.error === "string";

View File

@ -0,0 +1,12 @@
import * as tg from "generic-type-guard";
export const isLoadTilesetEvent = new tg.IsInterface()
.withProperties({
url: tg.isString,
})
.get();
/**
* A message sent from the iFrame to the game to add a message in the chat.
*/
export type LoadTilesetEvent = tg.GuardedType<typeof isLoadTilesetEvent>;

View File

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

View File

@ -0,0 +1,20 @@
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

@ -0,0 +1,26 @@
import * as tg from "generic-type-guard";
export const triggerActionMessage = "triggerActionMessage";
export const removeActionMessage = "removeActionMessage";
export const isActionMessageType = tg.isSingletonStringUnion("message", "warning");
export type ActionMessageType = tg.GuardedType<typeof isActionMessageType>;
export const isTriggerActionMessageEvent = new tg.IsInterface()
.withProperties({
message: tg.isString,
uuid: tg.isString,
type: isActionMessageType,
})
.get();
export type TriggerActionMessageEvent = tg.GuardedType<typeof isTriggerActionMessageEvent>;
export const isMessageReferenceEvent = new tg.IsInterface()
.withProperties({
uuid: tg.isString,
})
.get();
export type MessageReferenceEvent = tg.GuardedType<typeof isMessageReferenceEvent>;

View File

@ -0,0 +1,24 @@
import {
isMessageReferenceEvent,
isTriggerActionMessageEvent,
removeActionMessage,
triggerActionMessage,
} from "./TriggerActionMessageEvent";
import * as tg from "generic-type-guard";
const isTriggerMessageEventObject = new tg.IsInterface()
.withProperties({
type: tg.isSingletonString(triggerActionMessage),
data: isTriggerActionMessageEvent,
})
.get();
const isTriggerMessageRemoveEventObject = new tg.IsInterface()
.withProperties({
type: tg.isSingletonString(removeActionMessage),
data: isMessageReferenceEvent,
})
.get();
export const isTriggerMessageHandlerEvent = tg.isUnion(isTriggerMessageEventObject, isTriggerMessageRemoveEventObject);

View File

@ -1,4 +1,5 @@
import { Subject } from "rxjs"; import { Subject } from "rxjs";
import type * as tg from "generic-type-guard";
import { ChatEvent, isChatEvent } from "./Events/ChatEvent"; import { ChatEvent, isChatEvent } from "./Events/ChatEvent";
import { HtmlUtils } from "../WebRtc/HtmlUtils"; import { HtmlUtils } from "../WebRtc/HtmlUtils";
import type { EnterLeaveEvent } from "./Events/EnterLeaveEvent"; import type { EnterLeaveEvent } from "./Events/EnterLeaveEvent";
@ -12,7 +13,8 @@ import { isOpenCoWebsite, OpenCoWebSiteEvent } from "./Events/OpenCoWebSiteEvent
import { import {
IframeErrorAnswerEvent, IframeErrorAnswerEvent,
IframeEvent, IframeEvent,
IframeEventMap, IframeQueryMap, IframeEventMap,
IframeQueryMap,
IframeResponseEvent, IframeResponseEvent,
IframeResponseEventMap, IframeResponseEventMap,
isIframeEventWrapper, isIframeEventWrapper,
@ -25,21 +27,27 @@ import { isStopSoundEvent, StopSoundEvent } from "./Events/StopSoundEvent";
import { isLoadSoundEvent, LoadSoundEvent } from "./Events/LoadSoundEvent"; import { isLoadSoundEvent, LoadSoundEvent } from "./Events/LoadSoundEvent";
import { isSetPropertyEvent, SetPropertyEvent } from "./Events/setPropertyEvent"; import { isSetPropertyEvent, SetPropertyEvent } from "./Events/setPropertyEvent";
import { isLayerEvent, LayerEvent } from "./Events/LayerEvent"; import { isLayerEvent, LayerEvent } from "./Events/LayerEvent";
import { isMenuItemRegisterEvent } from "./Events/ui/MenuItemRegisterEvent";
import type { DataLayerEvent } from "./Events/DataLayerEvent";
import type { GameStateEvent } from "./Events/GameStateEvent";
import type { HasPlayerMovedEvent } from "./Events/HasPlayerMovedEvent"; import type { HasPlayerMovedEvent } from "./Events/HasPlayerMovedEvent";
import { isLoadPageEvent } from "./Events/LoadPageEvent"; import { isLoadPageEvent } from "./Events/LoadPageEvent";
import { handleMenuItemRegistrationEvent, isMenuItemRegisterIframeEvent } from "./Events/ui/MenuItemRegisterEvent"; import { handleMenuItemRegistrationEvent, isMenuItemRegisterIframeEvent } from "./Events/ui/MenuItemRegisterEvent";
import { SetTilesEvent, isSetTilesEvent } from "./Events/SetTilesEvent"; import { SetTilesEvent, isSetTilesEvent } from "./Events/SetTilesEvent";
import type { SetVariableEvent } from "./Events/SetVariableEvent";
import { ModifyEmbeddedWebsiteEvent, isEmbeddedWebsiteEvent } from "./Events/EmbeddedWebsiteEvent";
import { EmbeddedWebsite } from "./iframe/Room/EmbeddedWebsite";
type AnswererCallback<T extends keyof IframeQueryMap> = (query: IframeQueryMap[T]['query']) => IframeQueryMap[T]['answer']|Promise<IframeQueryMap[T]['answer']>; type AnswererCallback<T extends keyof IframeQueryMap> = (
query: IframeQueryMap[T]["query"],
source: MessageEventSource | null
) => IframeQueryMap[T]["answer"] | PromiseLike<IframeQueryMap[T]["answer"]>;
/** /**
* Listens to messages from iframes and turn those messages into easy to use observables. * Listens to messages from iframes and turn those messages into easy to use observables.
* Also allows to send messages to those iframes. * Also allows to send messages to those iframes.
*/ */
class IframeListener { class IframeListener {
private readonly _readyStream: Subject<HTMLIFrameElement> = new Subject();
public readonly readyStream = this._readyStream.asObservable();
private readonly _chatStream: Subject<ChatEvent> = new Subject(); private readonly _chatStream: Subject<ChatEvent> = new Subject();
public readonly chatStream = this._chatStream.asObservable(); public readonly chatStream = this._chatStream.asObservable();
@ -85,9 +93,6 @@ class IframeListener {
private readonly _setPropertyStream: Subject<SetPropertyEvent> = new Subject(); private readonly _setPropertyStream: Subject<SetPropertyEvent> = new Subject();
public readonly setPropertyStream = this._setPropertyStream.asObservable(); public readonly setPropertyStream = this._setPropertyStream.asObservable();
private readonly _dataLayerChangeStream: Subject<void> = new Subject();
public readonly dataLayerChangeStream = this._dataLayerChangeStream.asObservable();
private readonly _registerMenuCommandStream: Subject<string> = new Subject(); private readonly _registerMenuCommandStream: Subject<string> = new Subject();
public readonly registerMenuCommandStream = this._registerMenuCommandStream.asObservable(); public readonly registerMenuCommandStream = this._registerMenuCommandStream.asObservable();
@ -106,19 +111,23 @@ class IframeListener {
private readonly _setTilesStream: Subject<SetTilesEvent> = new Subject(); private readonly _setTilesStream: Subject<SetTilesEvent> = new Subject();
public readonly setTilesStream = this._setTilesStream.asObservable(); public readonly setTilesStream = this._setTilesStream.asObservable();
private readonly _modifyEmbeddedWebsiteStream: Subject<ModifyEmbeddedWebsiteEvent> = new Subject();
public readonly modifyEmbeddedWebsiteStream = this._modifyEmbeddedWebsiteStream.asObservable();
private readonly iframes = new Set<HTMLIFrameElement>(); private readonly iframes = new Set<HTMLIFrameElement>();
private readonly iframeCloseCallbacks = new Map<HTMLIFrameElement, (() => void)[]>(); private readonly iframeCloseCallbacks = new Map<HTMLIFrameElement, (() => void)[]>();
private readonly scripts = new Map<string, HTMLIFrameElement>(); private readonly scripts = new Map<string, HTMLIFrameElement>();
private sendPlayerMove: boolean = false; private sendPlayerMove: boolean = false;
// Note: we are forced to type this in unknown and later cast with "as" because of https://github.com/microsoft/TypeScript/issues/31904
private answerers: { private answerers: {
[key in keyof IframeQueryMap]?: AnswererCallback<key> [str in keyof IframeQueryMap]?: unknown;
} = {}; } = {};
init() { init() {
window.addEventListener( window.addEventListener(
"message", "message",
(message: TypedMessageEvent<IframeEvent<keyof IframeEventMap>>) => { (message: MessageEvent<unknown>) => {
// Do we trust the sender of this message? // Do we trust the sender of this message?
// Let's only accept messages from the iframe that are allowed. // Let's only accept messages from the iframe that are allowed.
// Note: maybe we could restrict on the domain too for additional security (in case the iframe goes to another domain). // Note: maybe we could restrict on the domain too for additional security (in case the iframe goes to another domain).
@ -152,109 +161,123 @@ class IframeListener {
const queryId = payload.id; const queryId = payload.id;
const query = payload.query; const query = payload.query;
const answerer = this.answerers[query.type]; const answerer = this.answerers[query.type] as AnswererCallback<keyof IframeQueryMap> | undefined;
if (answerer === undefined) { if (answerer === undefined) {
const errorMsg = 'The iFrame sent a message of type "'+query.type+'" but there is no service configured to answer these messages.'; const errorMsg =
'The iFrame sent a message of type "' +
query.type +
'" but there is no service configured to answer these messages.';
console.error(errorMsg); console.error(errorMsg);
iframe.contentWindow?.postMessage({ iframe.contentWindow?.postMessage(
id: queryId, {
type: query.type, id: queryId,
error: errorMsg type: query.type,
} as IframeErrorAnswerEvent, '*'); error: errorMsg,
} as IframeErrorAnswerEvent,
"*"
);
return; return;
} }
Promise.resolve(answerer(query.data)).then((value) => { const errorHandler = (reason: unknown) => {
iframe?.contentWindow?.postMessage({ console.error("An error occurred while responding to an iFrame query.", reason);
id: queryId, let reasonMsg: string = "";
type: query.type,
data: value
}, '*');
}).catch(reason => {
console.error('An error occurred while responding to an iFrame query.', reason);
let reasonMsg: string;
if (reason instanceof Error) { if (reason instanceof Error) {
reasonMsg = reason.message; reasonMsg = reason.message;
} else { } else if (typeof reason === "object") {
reasonMsg = reason.toString(); reasonMsg = reason ? reason.toString() : "";
} else if (typeof reason === "string") {
reasonMsg = reason;
} }
iframe?.contentWindow?.postMessage({ iframe?.contentWindow?.postMessage(
id: queryId, {
type: query.type, id: queryId,
error: reasonMsg type: query.type,
} as IframeErrorAnswerEvent, '*'); error: reasonMsg,
}); } as IframeErrorAnswerEvent,
"*"
);
};
try {
Promise.resolve(answerer(query.data, message.source))
.then((value) => {
iframe?.contentWindow?.postMessage(
{
id: queryId,
type: query.type,
data: value,
},
"*"
);
})
.catch(errorHandler);
} catch (reason) {
errorHandler(reason);
}
} else if (isIframeEventWrapper(payload)) { } else if (isIframeEventWrapper(payload)) {
if (payload.type === "showLayer" && isLayerEvent(payload.data)) { if (payload.type === "showLayer" && isLayerEvent(payload.data)) {
this._showLayerStream.next(payload.data); this._showLayerStream.next(payload.data);
} else if (payload.type === "hideLayer" && isLayerEvent(payload.data)) { } else if (payload.type === "hideLayer" && isLayerEvent(payload.data)) {
this._hideLayerStream.next(payload.data); this._hideLayerStream.next(payload.data);
} else if (payload.type === "setProperty" && isSetPropertyEvent(payload.data)) { } else if (payload.type === "setProperty" && isSetPropertyEvent(payload.data)) {
this._setPropertyStream.next(payload.data); this._setPropertyStream.next(payload.data);
} else if (payload.type === "chat" && isChatEvent(payload.data)) { } else if (payload.type === "chat" && isChatEvent(payload.data)) {
this._chatStream.next(payload.data); this._chatStream.next(payload.data);
} else if (payload.type === "openPopup" && isOpenPopupEvent(payload.data)) { } else if (payload.type === "openPopup" && isOpenPopupEvent(payload.data)) {
this._openPopupStream.next(payload.data); this._openPopupStream.next(payload.data);
} else if (payload.type === "closePopup" && isClosePopupEvent(payload.data)) { } else if (payload.type === "closePopup" && isClosePopupEvent(payload.data)) {
this._closePopupStream.next(payload.data); this._closePopupStream.next(payload.data);
} else if (payload.type === "openTab" && isOpenTabEvent(payload.data)) { } else if (payload.type === "openTab" && isOpenTabEvent(payload.data)) {
scriptUtils.openTab(payload.data.url); scriptUtils.openTab(payload.data.url);
} else if (payload.type === "goToPage" && isGoToPageEvent(payload.data)) { } else if (payload.type === "goToPage" && isGoToPageEvent(payload.data)) {
scriptUtils.goToPage(payload.data.url); scriptUtils.goToPage(payload.data.url);
} else if (payload.type === "loadPage" && isLoadPageEvent(payload.data)) { } else if (payload.type === "loadPage" && isLoadPageEvent(payload.data)) {
this._loadPageStream.next(payload.data.url); this._loadPageStream.next(payload.data.url);
} else if (payload.type === "playSound" && isPlaySoundEvent(payload.data)) { } else if (payload.type === "playSound" && isPlaySoundEvent(payload.data)) {
this._playSoundStream.next(payload.data); this._playSoundStream.next(payload.data);
} else if (payload.type === "stopSound" && isStopSoundEvent(payload.data)) { } else if (payload.type === "stopSound" && isStopSoundEvent(payload.data)) {
this._stopSoundStream.next(payload.data); this._stopSoundStream.next(payload.data);
} else if (payload.type === "loadSound" && isLoadSoundEvent(payload.data)) { } else if (payload.type === "loadSound" && isLoadSoundEvent(payload.data)) {
this._loadSoundStream.next(payload.data); this._loadSoundStream.next(payload.data);
} else if (payload.type === "openCoWebSite" && isOpenCoWebsite(payload.data)) { } else if (payload.type === "openCoWebSite" && isOpenCoWebsite(payload.data)) {
scriptUtils.openCoWebsite( scriptUtils.openCoWebsite(
payload.data.url, payload.data.url,
foundSrc, foundSrc,
payload.data.allowApi, payload.data.allowApi,
payload.data.allowPolicy payload.data.allowPolicy
); );
} else if (payload.type === "closeCoWebSite") { } else if (payload.type === "closeCoWebSite") {
scriptUtils.closeCoWebSite(); scriptUtils.closeCoWebSite();
} else if (payload.type === "disablePlayerControls") { } else if (payload.type === "disablePlayerControls") {
this._disablePlayerControlStream.next(); this._disablePlayerControlStream.next();
} else if (payload.type === "restorePlayerControls") { } else if (payload.type === "restorePlayerControls") {
this._enablePlayerControlStream.next(); this._enablePlayerControlStream.next();
} else if (payload.type === "displayBubble") { } else if (payload.type === "displayBubble") {
this._displayBubbleStream.next(); this._displayBubbleStream.next();
} else if (payload.type === "removeBubble") { } else if (payload.type === "removeBubble") {
this._removeBubbleStream.next(); this._removeBubbleStream.next();
} else if (payload.type == "onPlayerMove") { } else if (payload.type == "onPlayerMove") {
this.sendPlayerMove = true; this.sendPlayerMove = true;
} else if (payload.type == "getDataLayer") { } else if (isMenuItemRegisterIframeEvent(payload)) {
this._dataLayerChangeStream.next(); const data = payload.data.menutItem;
} else if (isMenuItemRegisterIframeEvent(payload)) { // @ts-ignore
const data = payload.data.menutItem; this.iframeCloseCallbacks.get(iframe).push(() => {
// @ts-ignore this._unregisterMenuCommandStream.next(data);
this.iframeCloseCallbacks.get(iframe).push(() => { });
this._unregisterMenuCommandStream.next(data); handleMenuItemRegistrationEvent(payload.data);
}); } else if (payload.type == "setTiles" && isSetTilesEvent(payload.data)) {
handleMenuItemRegistrationEvent(payload.data); this._setTilesStream.next(payload.data);
} else if (payload.type == "setTiles" && isSetTilesEvent(payload.data)) { } else if (payload.type == "modifyEmbeddedWebsite" && isEmbeddedWebsiteEvent(payload.data)) {
this._setTilesStream.next(payload.data); this._modifyEmbeddedWebsiteStream.next(payload.data);
}
} }
}
}, },
false false
); );
} }
sendDataLayerEvent(dataLayerEvent: DataLayerEvent) {
this.postMessage({
type: "dataLayer",
data: dataLayerEvent,
});
}
/** /**
* Allows the passed iFrame to send/receive messages via the API. * Allows the passed iFrame to send/receive messages via the API.
*/ */
@ -394,6 +417,22 @@ class IframeListener {
}); });
} }
setVariable(setVariableEvent: SetVariableEvent) {
this.postMessage({
type: "setVariable",
data: setVariableEvent,
});
}
sendActionMessageTriggered(uuid: string): void {
this.postMessage({
type: "messageTriggered",
data: {
uuid,
},
});
}
/** /**
* Sends the message... to all allowed iframes. * Sends the message... to all allowed iframes.
*/ */
@ -411,13 +450,31 @@ class IframeListener {
* @param key The "type" of the query we are answering * @param key The "type" of the query we are answering
* @param callback * @param callback
*/ */
public registerAnswerer<T extends keyof IframeQueryMap>(key: T, callback: (query: IframeQueryMap[T]['query']) => IframeQueryMap[T]['answer']|Promise<IframeQueryMap[T]['answer']> ): void { public registerAnswerer<T extends keyof IframeQueryMap>(key: T, callback: AnswererCallback<T>): void {
this.answerers[key] = callback; this.answerers[key] = callback;
} }
public unregisterAnswerer(key: keyof IframeQueryMap): void { public unregisterAnswerer(key: keyof IframeQueryMap): void {
delete this.answerers[key]; delete this.answerers[key];
} }
dispatchVariableToOtherIframes(key: string, value: unknown, source: MessageEventSource | null) {
// Let's dispatch the message to the other iframes
for (const iframe of this.iframes) {
if (iframe.contentWindow !== source) {
iframe.contentWindow?.postMessage(
{
type: "setVariable",
data: {
key,
value,
},
},
"*"
);
}
}
}
} }
export const iframeListener = new IframeListener(); export const iframeListener = new IframeListener();

View File

@ -1,51 +1,66 @@
import type * as tg from "generic-type-guard"; import type * as tg from "generic-type-guard";
import type { import type {
IframeEvent, IframeEvent,
IframeEventMap, IframeQuery, IframeEventMap,
IframeQuery,
IframeQueryMap, IframeQueryMap,
IframeResponseEventMap IframeResponseEventMap,
} from '../Events/IframeEvent'; } from "../Events/IframeEvent";
import type {IframeQueryWrapper} from "../Events/IframeEvent"; import type { IframeQueryWrapper } from "../Events/IframeEvent";
export function sendToWorkadventure(content: IframeEvent<keyof IframeEventMap>) { export function sendToWorkadventure(content: IframeEvent<keyof IframeEventMap>) {
window.parent.postMessage(content, "*") window.parent.postMessage(content, "*");
} }
let queryNumber = 0; let queryNumber = 0;
export const answerPromises = new Map<number, { export const answerPromises = new Map<
resolve: (value: (IframeQueryMap[keyof IframeQueryMap]['answer'] | PromiseLike<IframeQueryMap[keyof IframeQueryMap]['answer']>)) => void, number,
// eslint-disable-next-line @typescript-eslint/no-explicit-any {
reject: (reason?: any) => void resolve: (
}>(); value:
| IframeQueryMap[keyof IframeQueryMap]["answer"]
| PromiseLike<IframeQueryMap[keyof IframeQueryMap]["answer"]>
) => void;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
reject: (reason?: any) => void;
}
>();
export function queryWorkadventure<T extends keyof IframeQueryMap>(content: IframeQuery<T>): Promise<IframeQueryMap[T]['answer']> { export function queryWorkadventure<T extends keyof IframeQueryMap>(
return new Promise<IframeQueryMap[T]['answer']>((resolve, reject) => { content: IframeQuery<T>
window.parent.postMessage({ ): Promise<IframeQueryMap[T]["answer"]> {
id: queryNumber, return new Promise<IframeQueryMap[T]["answer"]>((resolve, reject) => {
query: content window.parent.postMessage(
} as IframeQueryWrapper<T>, "*"); {
id: queryNumber,
query: content,
} as IframeQueryWrapper<T>,
"*"
);
answerPromises.set(queryNumber, { answerPromises.set(queryNumber, {
resolve, resolve,
reject reject,
}); });
queryNumber++; queryNumber++;
}); });
} }
type GuardedType<Guard extends tg.TypeGuard<unknown>> = Guard extends tg.TypeGuard<infer T> ? T : never type GuardedType<Guard extends tg.TypeGuard<unknown>> = Guard extends tg.TypeGuard<infer T> ? T : never;
export interface IframeCallback<Key extends keyof IframeResponseEventMap, T = IframeResponseEventMap[Key], Guard = tg.TypeGuard<T>> { export interface IframeCallback<
Key extends keyof IframeResponseEventMap,
typeChecker: Guard, T = IframeResponseEventMap[Key],
callback: (payloadData: T) => void Guard = tg.TypeGuard<T>
> {
typeChecker: Guard;
callback: (payloadData: T) => void;
} }
export interface IframeCallbackContribution<Key extends keyof IframeResponseEventMap> extends IframeCallback<Key> { export interface IframeCallbackContribution<Key extends keyof IframeResponseEventMap> extends IframeCallback<Key> {
type: Key;
type: Key
} }
/** /**
@ -54,9 +69,10 @@ export interface IframeCallbackContribution<Key extends keyof IframeResponseEven
* *
*/ */
export abstract class IframeApiContribution<T extends { export abstract class IframeApiContribution<
callbacks: Array<IframeCallbackContribution<keyof IframeResponseEventMap>>, T extends {
}> { callbacks: Array<IframeCallbackContribution<keyof IframeResponseEventMap>>;
}
abstract callbacks: T["callbacks"] > {
abstract callbacks: T["callbacks"];
} }

View File

@ -0,0 +1,90 @@
import { sendToWorkadventure } from "../IframeApiContribution";
import type {
CreateEmbeddedWebsiteEvent,
ModifyEmbeddedWebsiteEvent,
Rectangle,
} from "../../Events/EmbeddedWebsiteEvent";
export class EmbeddedWebsite {
public readonly name: string;
private _url: string;
private _visible: boolean;
private _allow: string;
private _allowApi: boolean;
private _position: Rectangle;
constructor(private config: CreateEmbeddedWebsiteEvent) {
this.name = config.name;
this._url = config.url;
this._visible = config.visible ?? true;
this._allow = config.allow ?? "";
this._allowApi = config.allowApi ?? false;
this._position = config.position;
}
public set url(url: string) {
this._url = url;
sendToWorkadventure({
type: "modifyEmbeddedWebsite",
data: {
name: this.name,
url: this._url,
},
});
}
public set visible(visible: boolean) {
this._visible = visible;
sendToWorkadventure({
type: "modifyEmbeddedWebsite",
data: {
name: this.name,
visible: this._visible,
},
});
}
public set x(x: number) {
this._position.x = x;
sendToWorkadventure({
type: "modifyEmbeddedWebsite",
data: {
name: this.name,
x: this._position.x,
},
});
}
public set y(y: number) {
this._position.y = y;
sendToWorkadventure({
type: "modifyEmbeddedWebsite",
data: {
name: this.name,
y: this._position.y,
},
});
}
public set width(width: number) {
this._position.width = width;
sendToWorkadventure({
type: "modifyEmbeddedWebsite",
data: {
name: this.name,
width: this._position.width,
},
});
}
public set height(height: number) {
this._position.height = height;
sendToWorkadventure({
type: "modifyEmbeddedWebsite",
data: {
name: this.name,
height: this._position.height,
},
});
}
}

View File

@ -0,0 +1,56 @@
import {
ActionMessageType,
MessageReferenceEvent,
removeActionMessage,
triggerActionMessage,
TriggerActionMessageEvent,
} from "../../Events/ui/TriggerActionMessageEvent";
import { queryWorkadventure } from "../IframeApiContribution";
import type { ActionMessageOptions } from "../ui";
function uuidv4() {
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
const r = (Math.random() * 16) | 0,
v = c === "x" ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
}
export class ActionMessage {
public readonly uuid: string;
private readonly type: ActionMessageType;
private readonly message: string;
private readonly callback: () => void;
constructor(actionMessageOptions: ActionMessageOptions, private onRemove: () => void) {
this.uuid = uuidv4();
this.message = actionMessageOptions.message;
this.type = actionMessageOptions.type ?? "message";
this.callback = actionMessageOptions.callback;
this.create();
}
private async create() {
await queryWorkadventure({
type: triggerActionMessage,
data: {
message: this.message,
type: this.type,
uuid: this.uuid,
} as TriggerActionMessageEvent,
});
}
async remove() {
await queryWorkadventure({
type: removeActionMessage,
data: {
uuid: this.uuid,
} as MessageReferenceEvent,
});
this.onRemove();
}
triggerCallback() {
this.callback();
}
}

View File

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

View File

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

View File

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

View File

@ -1,10 +1,11 @@
import { isButtonClickedEvent } from "../Events/ButtonClickedEvent"; import { isButtonClickedEvent } from "../Events/ButtonClickedEvent";
import { isMenuItemClickedEvent } from "../Events/ui/MenuItemClickedEvent"; import { isMenuItemClickedEvent } from "../Events/ui/MenuItemClickedEvent";
import type { MenuItemRegisterEvent } from "../Events/ui/MenuItemRegisterEvent";
import { IframeApiContribution, sendToWorkadventure } from "./IframeApiContribution"; import { IframeApiContribution, sendToWorkadventure } from "./IframeApiContribution";
import { apiCallback } from "./registeredCallbacks"; import { apiCallback } from "./registeredCallbacks";
import type { ButtonClickedCallback, ButtonDescriptor } from "./Ui/ButtonDescriptor"; import type { ButtonClickedCallback, ButtonDescriptor } from "./Ui/ButtonDescriptor";
import { Popup } from "./Ui/Popup"; import { Popup } from "./Ui/Popup";
import { ActionMessage } from "./Ui/ActionMessage";
import { isMessageReferenceEvent } from "../Events/ui/TriggerActionMessageEvent";
let popupId = 0; let popupId = 0;
const popups: Map<number, Popup> = new Map<number, Popup>(); const popups: Map<number, Popup> = new Map<number, Popup>();
@ -14,6 +15,7 @@ const popupCallbacks: Map<number, Map<number, ButtonClickedCallback>> = new Map<
>(); >();
const menuCallbacks: Map<string, (command: string) => void> = new Map(); const menuCallbacks: Map<string, (command: string) => void> = new Map();
const actionMessages = new Map<string, ActionMessage>();
interface ZonedPopupOptions { interface ZonedPopupOptions {
zone: string; zone: string;
@ -23,6 +25,12 @@ interface ZonedPopupOptions {
popupOptions: Array<ButtonDescriptor>; popupOptions: Array<ButtonDescriptor>;
} }
export interface ActionMessageOptions {
message: string;
type?: "message" | "warning";
callback: () => void;
}
export class WorkAdventureUiCommands extends IframeApiContribution<WorkAdventureUiCommands> { export class WorkAdventureUiCommands extends IframeApiContribution<WorkAdventureUiCommands> {
callbacks = [ callbacks = [
apiCallback({ apiCallback({
@ -49,6 +57,16 @@ export class WorkAdventureUiCommands extends IframeApiContribution<WorkAdventure
} }
}, },
}), }),
apiCallback({
type: "messageTriggered",
typeChecker: isMessageReferenceEvent,
callback: (event) => {
const actionMessage = actionMessages.get(event.uuid);
if (actionMessage) {
actionMessage.triggerCallback();
}
},
}),
]; ];
openPopup(targetObject: string, message: string, buttons: ButtonDescriptor[]): Popup { openPopup(targetObject: string, message: string, buttons: ButtonDescriptor[]): Popup {
@ -103,6 +121,14 @@ export class WorkAdventureUiCommands extends IframeApiContribution<WorkAdventure
removeBubble(): void { removeBubble(): void {
sendToWorkadventure({ type: "removeBubble", data: null }); sendToWorkadventure({ type: "removeBubble", data: null });
} }
displayActionMessage(actionMessageOptions: ActionMessageOptions): ActionMessage {
const actionMessage = new ActionMessage(actionMessageOptions, () => {
actionMessages.delete(actionMessage.uuid);
});
actionMessages.set(actionMessage.uuid, actionMessage);
return actionMessage;
}
} }
export default new WorkAdventureUiCommands(); export default new WorkAdventureUiCommands();

View File

@ -0,0 +1,38 @@
import type { LoadSoundEvent } from "../Events/LoadSoundEvent";
import type { PlaySoundEvent } from "../Events/PlaySoundEvent";
import type { StopSoundEvent } from "../Events/StopSoundEvent";
import { IframeApiContribution, queryWorkadventure, sendToWorkadventure } from "./IframeApiContribution";
import { Sound } from "./Sound/Sound";
import { EmbeddedWebsite } from "./Room/EmbeddedWebsite";
import type { CreateEmbeddedWebsiteEvent } from "../Events/EmbeddedWebsiteEvent";
export class WorkadventureRoomWebsiteCommands extends IframeApiContribution<WorkadventureRoomWebsiteCommands> {
callbacks = [];
async get(objectName: string): Promise<EmbeddedWebsite> {
const websiteEvent = await queryWorkadventure({
type: "getEmbeddedWebsite",
data: objectName,
});
return new EmbeddedWebsite(websiteEvent);
}
create(createEmbeddedWebsiteEvent: CreateEmbeddedWebsiteEvent): EmbeddedWebsite {
queryWorkadventure({
type: "createEmbeddedWebsite",
data: createEmbeddedWebsiteEvent,
}).catch((e) => {
console.error(e);
});
return new EmbeddedWebsite(createEmbeddedWebsiteEvent);
}
async delete(objectName: string): Promise<void> {
return await queryWorkadventure({
type: "deleteEmbeddedWebsite",
data: objectName,
});
}
}
export default new WorkadventureRoomWebsiteCommands();

View File

@ -27,6 +27,16 @@
import {gameOverlayVisibilityStore} from "../Stores/GameOverlayStoreVisibility"; import {gameOverlayVisibilityStore} from "../Stores/GameOverlayStoreVisibility";
import {consoleGlobalMessageManagerVisibleStore} from "../Stores/ConsoleGlobalMessageManagerStore"; import {consoleGlobalMessageManagerVisibleStore} from "../Stores/ConsoleGlobalMessageManagerStore";
import ConsoleGlobalMessageManager from "./ConsoleGlobalMessageManager/ConsoleGlobalMessageManager.svelte"; import ConsoleGlobalMessageManager from "./ConsoleGlobalMessageManager/ConsoleGlobalMessageManager.svelte";
import AdminMessage from "./TypeMessage/BanMessage.svelte";
import TextMessage from "./TypeMessage/TextMessage.svelte";
import {banMessageVisibleStore} from "../Stores/TypeMessageStore/BanMessageStore";
import {textMessageVisibleStore} from "../Stores/TypeMessageStore/TextMessageStore";
import {warningContainerStore} from "../Stores/MenuStore";
import WarningContainer from "./WarningContainer/WarningContainer.svelte";
import {layoutManagerVisibilityStore} from "../Stores/LayoutManagerStore";
import LayoutManager from "./LayoutManager/LayoutManager.svelte";
import {audioManagerVisibilityStore} from "../Stores/AudioManagerStore";
import AudioManager from "./AudioManager/AudioManager.svelte"
export let game: Game; export let game: Game;
@ -58,11 +68,31 @@
<EnableCameraScene game={game}></EnableCameraScene> <EnableCameraScene game={game}></EnableCameraScene>
</div> </div>
{/if} {/if}
{#if $banMessageVisibleStore}
<div>
<AdminMessage></AdminMessage>
</div>
{/if}
{#if $textMessageVisibleStore}
<div>
<TextMessage></TextMessage>
</div>
{/if}
{#if $soundPlayingStore} {#if $soundPlayingStore}
<div> <div>
<AudioPlaying url={$soundPlayingStore} /> <AudioPlaying url={$soundPlayingStore} />
</div> </div>
{/if} {/if}
{#if $audioManagerVisibilityStore}
<div>
<AudioManager></AudioManager>
</div>
{/if}
{#if $layoutManagerVisibilityStore}
<div>
<LayoutManager></LayoutManager>
</div>
{/if}
{#if $gameOverlayVisibilityStore} {#if $gameOverlayVisibilityStore}
<div> <div>
<VideoOverlay></VideoOverlay> <VideoOverlay></VideoOverlay>
@ -91,4 +121,7 @@
{#if $chatVisibilityStore} {#if $chatVisibilityStore}
<Chat></Chat> <Chat></Chat>
{/if} {/if}
{#if $warningContainerStore}
<WarningContainer></WarningContainer>
{/if}
</div> </div>

View File

@ -0,0 +1,119 @@
<script lang="ts">
import audioImg from "../images/audio.svg";
import audioMuteImg from "../images/audio-mute.svg";
import { localUserStore } from "../../Connexion/LocalUserStore";
import type { audioManagerVolume } from "../../Stores/AudioManagerStore";
import {
audioManagerFileStore,
audioManagerVolumeStore,
} from "../../Stores/AudioManagerStore";
import {get} from "svelte/store";
import type { Unsubscriber } from "svelte/store";
import {onDestroy, onMount} from "svelte";
let HTMLAudioPlayer: HTMLAudioElement;
let unsubscriberFileStore: Unsubscriber | null = null;
let unsubscriberVolumeStore: Unsubscriber | null = null;
let volume: number = 1;
let decreaseWhileTalking: boolean = true;
onMount(() => {
unsubscriberFileStore = audioManagerFileStore.subscribe(() =>{
HTMLAudioPlayer.pause();
HTMLAudioPlayer.loop = get(audioManagerVolumeStore).loop;
HTMLAudioPlayer.volume = get(audioManagerVolumeStore).volume;
HTMLAudioPlayer.muted = get(audioManagerVolumeStore).muted;
HTMLAudioPlayer.play();
});
unsubscriberVolumeStore = audioManagerVolumeStore.subscribe((audioManager: audioManagerVolume) => {
const reduceVolume = audioManager.talking && audioManager.decreaseWhileTalking;
if (reduceVolume && !audioManager.volumeReduced) {
audioManager.volume *= 0.5;
} else if (!reduceVolume && audioManager.volumeReduced) {
audioManager.volume *= 2.0;
}
audioManager.volumeReduced = reduceVolume;
HTMLAudioPlayer.volume = audioManager.volume;
HTMLAudioPlayer.muted = audioManager.muted;
HTMLAudioPlayer.loop = audioManager.loop;
})
})
onDestroy(() => {
if (unsubscriberFileStore) {
unsubscriberFileStore();
}
if (unsubscriberVolumeStore) {
unsubscriberVolumeStore();
}
})
function onMute() {
audioManagerVolumeStore.setMuted(!get(audioManagerVolumeStore).muted);
localUserStore.setAudioPlayerMuted(get(audioManagerVolumeStore).muted);
}
function setVolume() {
audioManagerVolumeStore.setVolume(volume)
localUserStore.setAudioPlayerVolume(get(audioManagerVolumeStore).volume);
}
function setDecrease() {
audioManagerVolumeStore.setDecreaseWhileTalking(decreaseWhileTalking);
}
</script>
<div class="main-audio-manager nes-container is-rounded">
<div class="audio-manager-player-volume">
<img src={$audioManagerVolumeStore.muted ? audioMuteImg : audioImg} alt="player volume" on:click={onMute}>
<input type="range" min="0" max="1" step="0.025" bind:value={volume} on:change={setVolume}>
</div>
<div class="audio-manager-reduce-conversation">
<label>
reduce in conversations
<input type="checkbox" bind:checked={decreaseWhileTalking} on:change={setDecrease}>
</label>
<section class="audio-manager-file">
<audio class="audio-manager-audioplayer" bind:this={HTMLAudioPlayer}>
<source src={$audioManagerFileStore}>
</audio>
</section>
</div>
</div>
<style lang="scss">
div.main-audio-manager.nes-container.is-rounded {
position: relative;
top: 0.5rem;
max-height: clamp(150px, 10vh, 15vh); //replace @media for small screen
width: clamp(200px, 15vw, 15vw);
padding: 3px 3px;
margin-left: auto;
margin-right: auto;
background-color: rgb(0,0,0,0.5);
display: grid;
grid-template-rows: 50% 50%;
color: whitesmoke;
text-align: center;
pointer-events: auto;
div.audio-manager-player-volume {
display: grid;
grid-template-columns: 50px 1fr;
img {
height: 100%;
width: calc(100% - 10px);
margin-right: 10px;
}
}
section.audio-manager-file {
display: none;
}
}
</style>

View File

@ -3,9 +3,12 @@
import { chatMessagesStore, chatVisibilityStore } from "../../Stores/ChatStore"; import { chatMessagesStore, chatVisibilityStore } from "../../Stores/ChatStore";
import ChatMessageForm from './ChatMessageForm.svelte'; import ChatMessageForm from './ChatMessageForm.svelte';
import ChatElement from './ChatElement.svelte'; import ChatElement from './ChatElement.svelte';
import { afterUpdate, beforeUpdate } from "svelte"; import {afterUpdate, beforeUpdate} from "svelte";
import {HtmlUtils} from "../../WebRtc/HtmlUtils";
let listDom: HTMLElement; let listDom: HTMLElement;
let chatWindowElement: HTMLElement;
let handleFormBlur: { blur():void };
let autoscroll: boolean; let autoscroll: boolean;
beforeUpdate(() => { beforeUpdate(() => {
@ -16,6 +19,12 @@
if (autoscroll) listDom.scrollTo(0, listDom.scrollHeight); if (autoscroll) listDom.scrollTo(0, listDom.scrollHeight);
}); });
function onClick(event: MouseEvent) {
if (HtmlUtils.isClickedOutside(event, chatWindowElement)) {
handleFormBlur.blur();
}
}
function closeChat() { function closeChat() {
chatVisibilityStore.set(false); chatVisibilityStore.set(false);
} }
@ -26,10 +35,10 @@
} }
</script> </script>
<svelte:window on:keydown={onKeyDown}/> <svelte:window on:keydown={onKeyDown} on:click={onClick}/>
<aside class="chatWindow" transition:fly="{{ x: -1000, duration: 500 }}"> <aside class="chatWindow" transition:fly="{{ x: -1000, duration: 500 }}" bind:this={chatWindowElement}>
<p class="close-icon" on:click={closeChat}>&times</p> <p class="close-icon" on:click={closeChat}>&times</p>
<section class="messagesList" bind:this={listDom}> <section class="messagesList" bind:this={listDom}>
<ul> <ul>
@ -40,7 +49,7 @@
</ul> </ul>
</section> </section>
<section class="messageForm"> <section class="messageForm">
<ChatMessageForm></ChatMessageForm> <ChatMessageForm bind:handleForm={handleFormBlur}></ChatMessageForm>
</section> </section>
</aside> </aside>

View File

@ -7,13 +7,14 @@
export let message: ChatMessage; export let message: ChatMessage;
export let line: number; export let line: number;
const chatStyleLink = "color: white; text-decoration: underline;";
$: author = message.author as PlayerInterface; $: author = message.author as PlayerInterface;
$: targets = message.targets || []; $: targets = message.targets || [];
$: texts = message.text || []; $: texts = message.text || [];
function urlifyText(text: string): string { function urlifyText(text: string): string {
return HtmlUtils.urlify(text) return HtmlUtils.urlify(text, chatStyleLink);
} }
function renderDate(date: Date) { function renderDate(date: Date) {
return date.toLocaleTimeString(navigator.language, { return date.toLocaleTimeString(navigator.language, {

View File

@ -1,6 +1,12 @@
<script lang="ts"> <script lang="ts">
import {chatMessagesStore, chatInputFocusStore} from "../../Stores/ChatStore"; import {chatMessagesStore, chatInputFocusStore} from "../../Stores/ChatStore";
export const handleForm = {
blur() {
inputElement.blur();
}
}
let inputElement: HTMLElement;
let newMessageText = ''; let newMessageText = '';
function onFocus() { function onFocus() {
@ -18,7 +24,7 @@
</script> </script>
<form on:submit|preventDefault={saveMessage}> <form on:submit|preventDefault={saveMessage}>
<input type="text" bind:value={newMessageText} placeholder="Enter your message..." on:focus={onFocus} on:blur={onBlur} > <input type="text" bind:value={newMessageText} placeholder="Enter your message..." on:focus={onFocus} on:blur={onBlur} bind:this={inputElement}>
<button type="submit"> <button type="submit">
<img src="/static/images/send.png" alt="Send" width="20"> <img src="/static/images/send.png" alt="Send" width="20">
</button> </button>

View File

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

View File

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

View File

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

View File

@ -0,0 +1,57 @@
<script lang="ts">
import { layoutManagerActionStore } from "../../Stores/LayoutManagerStore";
function onClick(callback: () => void) {
callback();
}
</script>
<div class="layout-manager-list">
{#each $layoutManagerActionStore as action}
<div class="nes-container is-rounded {action.type}" on:click={() => onClick(action.callback)}>
<p>{action.message}</p>
</div>
{/each}
</div>
<style lang="scss">
div.layout-manager-list {
pointer-events: auto;
position: absolute;
left: 0;
right: 0;
bottom: 40px;
margin: 0 auto;
padding: 0;
width: clamp(200px, 20vw, 20vw);
display: flex;
flex-direction: column;
animation: moveMessage .5s;
animation-iteration-count: infinite;
animation-timing-function: ease-in-out;
}
div.nes-container.is-rounded {
padding: 8px 4px;
text-align: center;
font-family: Lato;
color: whitesmoke;
background-color: rgb(0,0,0,0.5);
&.warning {
background-color: #ff9800eb;
color: #000;
}
}
@keyframes moveMessage {
0% {bottom: 40px;}
50% {bottom: 30px;}
100% {bottom: 40px;}
}
</style>

View File

@ -0,0 +1,96 @@
<script lang="ts">
import { fly } from "svelte/transition";
import {banMessageVisibleStore, banMessageContentStore} from "../../Stores/TypeMessageStore/BanMessageStore";
import {onMount} from "svelte";
const text = $banMessageContentStore;
const NAME_BUTTON = 'Ok';
let nbSeconds = 10;
let nameButton = '';
onMount(() => {
timeToRead()
})
function timeToRead() {
nbSeconds -= 1;
nameButton = nbSeconds.toString();
if ( nbSeconds > 0 ) {
setTimeout( () => {
timeToRead();
}, 1000);
} else {
nameButton = NAME_BUTTON;
}
}
function closeBanMessage() {
banMessageVisibleStore.set(false);
}
</script>
<div class="main-ban-message nes-container is-rounded" transition:fly="{{ y: -1000, duration: 500 }}">
<h2 class="title-ban-message"><img src="resources/logos/report.svg" alt="***"/> Important message <img src="resources/logos/report.svg" alt="***"/></h2>
<div class="content-ban-message">
<p>{text}</p>
</div>
<div class="footer-ban-message">
<button type="button" class="nes-btn {nameButton === NAME_BUTTON ? 'is-primary' : 'is-error'}" disabled="{!(nameButton === NAME_BUTTON)}" on:click|preventDefault={closeBanMessage}>{nameButton}</button>
</div>
<audio id="report-message" autoplay>
<source src="/resources/objects/report-message.mp3" type="audio/mp3">
</audio>
</div>
<style lang="scss">
div.main-ban-message {
display: flex;
flex-direction: column;
position: relative;
top: 15vh;
height: 70vh;
width: 60vw;
margin-left: auto;
margin-right: auto;
padding-bottom: 0;
pointer-events: auto;
background-color: #333333;
color: whitesmoke;
h2.title-ban-message {
flex: 1 1 auto;
max-height: 50px;
margin-bottom: 20px;
text-align: center;
img {
height: 50px;
}
}
div.content-ban-message {
flex: 1 1 auto;
max-height: calc(100% - 50px);
overflow: auto;
p {
white-space: pre-wrap;
}
}
div.footer-ban-message {
height: 50px;
margin-top: 10px;
text-align: center;
button {
width: 88px;
height: 44px;
}
}
}
</style>

View File

@ -0,0 +1,59 @@
<script lang="ts">
import { fly } from "svelte/transition";
import {textMessageContentStore, textMessageVisibleStore} from "../../Stores/TypeMessageStore/TextMessageStore";
import { QuillDeltaToHtmlConverter } from "quill-delta-to-html";
const content = JSON.parse($textMessageContentStore);
const converter = new QuillDeltaToHtmlConverter(content.ops, {inlineStyles: true});
const NAME_BUTTON = 'Ok';
function closeTextMessage() {
textMessageVisibleStore.set(false);
}
function onKeyDown(e:KeyboardEvent) {
if (e.key === 'Escape') {
closeTextMessage();
}
}
</script>
<svelte:window on:keydown={onKeyDown}/>
<div class="main-text-message nes-container is-rounded" transition:fly="{{ x: -1000, duration: 500 }}">
<div class="content-text-message">
{@html converter.convert()}
</div>
<div class="footer-text-message">
<button type="button" class="nes-btn is-primary" on:click|preventDefault={closeTextMessage}>{NAME_BUTTON}</button>
</div>
</div>
<style lang="scss">
div.main-text-message {
display: flex;
flex-direction: column;
max-height: 25vh;
width: 80vw;
margin-right: auto;
margin-left: auto;
padding-bottom: 0;
pointer-events: auto;
background-color: #333333;
div.content-text-message {
flex: 1 1 auto;
max-height: calc(100% - 50px);
color: whitesmoke;
overflow: auto;
}
div.footer-text-message {
height: 50px;
text-align: center;
}
}
</style>

View File

@ -7,6 +7,8 @@
import {videoFocusStore} from "../../Stores/VideoFocusStore"; import {videoFocusStore} from "../../Stores/VideoFocusStore";
import {showReportScreenStore} from "../../Stores/ShowReportScreenStore"; import {showReportScreenStore} from "../../Stores/ShowReportScreenStore";
import {getColorByString, srcObject} from "./utils"; import {getColorByString, srcObject} from "./utils";
import {obtainedMediaConstraintIsMobileStore} from "../../Stores/MediaStore";
import {onDestroy} from "svelte";
export let peer: VideoPeer; export let peer: VideoPeer;
let streamStore = peer.streamStore; let streamStore = peer.streamStore;
@ -18,6 +20,12 @@
showReportScreenStore.set({ userId:peer.userId, userName: peer.userName }); showReportScreenStore.set({ userId:peer.userId, userName: peer.userName });
} }
let isMobile : boolean|null;
const unsubscribe = obtainedMediaConstraintIsMobileStore.subscribe(value => {
isMobile = value;
});
onDestroy(unsubscribe);
</script> </script>
<div class="video-container"> <div class="video-container">
@ -31,13 +39,13 @@
<i style="background-color: {getColorByString(name)};">{name}</i> <i style="background-color: {getColorByString(name)};">{name}</i>
{/if} {/if}
{#if $constraintStore && $constraintStore.audio === false} {#if $constraintStore && $constraintStore.audio === false}
<img src={microphoneCloseImg} alt="Muted"> <img src={microphoneCloseImg} class="active" alt="Muted">
{/if} {/if}
<button class="report" on:click={() => openReport(peer)}> <button class="report" on:click={() => openReport(peer)}>
<img alt="Report this user" src={reportImg}> <img alt="Report this user" src={reportImg}>
<span>Report/Block</span> <span>Report/Block</span>
</button> </button>
<video use:srcObject={$streamStore} autoplay playsinline on:click={() => videoFocusStore.toggleFocus(peer)}></video> <video class:mobile="{isMobile === true}" use:srcObject={$streamStore} autoplay playsinline on:click={() => videoFocusStore.toggleFocus(peer)}></video>
<img src={blockSignImg} class="block-logo" alt="Block" /> <img src={blockSignImg} class="block-logo" alt="Block" />
{#if $constraintStore && $constraintStore.audio !== false} {#if $constraintStore && $constraintStore.audio !== false}
<SoundMeterWidget stream={$streamStore}></SoundMeterWidget> <SoundMeterWidget stream={$streamStore}></SoundMeterWidget>

View File

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

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