Merge branch 'develop' of github.com:thecodingmachine/workadventure into audioPlayerImprovements
@ -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=
|
||||||
|
3
.github/workflows/continuous_integration.yml
vendored
@ -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
|
||||||
|
1
.github/workflows/push-to-npm.yml
vendored
@ -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.
|
||||||
|
29
CHANGELOG.md
@ -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
|
||||||
|
@ -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",
|
||||||
|
@ -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,
|
||||||
|
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -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[][] = [];
|
||||||
|
|
||||||
|
@ -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());
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
24
back/src/Services/AdminApi.ts
Normal 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();
|
11
back/src/Services/AdminApi/CharacterTexture.ts
Normal 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>;
|
21
back/src/Services/AdminApi/MapDetailsData.ts
Normal 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>;
|
8
back/src/Services/AdminApi/RoomRedirect.ts
Normal 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>;
|
1
back/src/Services/LocalUrlError.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export class LocalUrlError extends Error {}
|
69
back/src/Services/MapFetcher.ts
Normal 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();
|
@ -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);
|
||||||
|
}
|
||||||
|
23
back/src/Services/RedisClient.ts
Normal 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 };
|
43
back/src/Services/Repository/RedisVariablesRepository.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
14
back/src/Services/Repository/VariablesRepository.ts
Normal 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 };
|
10
back/src/Services/Repository/VariablesRepositoryInterface.ts
Normal 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>;
|
||||||
|
}
|
14
back/src/Services/Repository/VoidVariablesRepository.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
@ -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;
|
||||||
}
|
}
|
||||||
|
218
back/src/Services/VariablesManager.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
@ -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);
|
||||||
});
|
});
|
||||||
|
});
|
||||||
})
|
|
||||||
|
32
back/tests/MapFetcherTest.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
@ -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. */
|
||||||
|
@ -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"
|
||||||
|
@ -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;
|
||||||
|
|
||||||
|
@ -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)::
|
||||||
|
@ -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:
|
||||||
|
@ -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:
|
||||||
|
@ -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);
|
||||||
```
|
```
|
||||||
|
@ -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)
|
||||||
|
@ -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
@ -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
@ -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();
|
||||||
|
```
|
@ -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() {};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
@ -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
@ -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>
|
@ -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:
|
||||||
|
|
||||||
|
40
docs/maps/website-in-map.md
Normal 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>
|
@ -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",
|
||||||
|
1
front/dist/index.tmpl.html
vendored
@ -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">
|
||||||
|
4
front/dist/resources/html/gameMenu.html
vendored
@ -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>
|
||||||
|
18
front/dist/resources/html/warningContainer.html
vendored
@ -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
@ -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
|
||||||
|
});
|
@ -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
|
||||||
});
|
});
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1.6 KiB |
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 1.9 KiB |
Before Width: | Height: | Size: 880 B After Width: | Height: | Size: 754 B |
Before Width: | Height: | Size: 933 B After Width: | Height: | Size: 922 B |
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.3 KiB |
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.6 KiB |
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.7 KiB |
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1.8 KiB |
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 1.8 KiB |
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 2.0 KiB |
Before Width: | Height: | Size: 978 B After Width: | Height: | Size: 1.0 KiB |
Before Width: | Height: | Size: 985 B After Width: | Height: | Size: 1.2 KiB |
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.2 KiB |
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.3 KiB |
BIN
front/dist/static/images/favicons/apple-icon.png
vendored
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 2.3 KiB |
BIN
front/dist/static/images/favicons/icon-512x512.png
vendored
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 3.4 KiB |
57
front/dist/static/images/favicons/manifest.json
vendored
@ -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
@ -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",
|
||||||
|
@ -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}`;
|
|
||||||
}
|
|
||||||
}
|
|
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
@ -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();
|
||||||
|
48
front/src/Api/Events/EmbeddedWebsiteEvent.ts
Normal 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>;
|
@ -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();
|
||||||
/**
|
/**
|
||||||
|
@ -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";
|
||||||
|
12
front/src/Api/Events/LoadTilesetEvent.ts
Normal 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>;
|
@ -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>;
|
20
front/src/Api/Events/SetVariableEvent.ts
Normal 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();
|
26
front/src/Api/Events/ui/TriggerActionMessageEvent.ts
Normal 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>;
|
24
front/src/Api/Events/ui/TriggerMessageEventHandler.ts
Normal 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);
|
@ -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();
|
||||||
|
@ -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"];
|
||||||
}
|
}
|
||||||
|
90
front/src/Api/iframe/Room/EmbeddedWebsite.ts
Normal 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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
56
front/src/Api/iframe/Ui/ActionMessage.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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();
|
||||||
|
90
front/src/Api/iframe/state.ts
Normal 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;
|
@ -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();
|
||||||
|
38
front/src/Api/iframe/website.ts
Normal 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();
|
@ -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>
|
||||||
|
119
front/src/Components/AudioManager/AudioManager.svelte
Normal 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>
|
@ -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}>×</p>
|
<p class="close-icon" on:click={closeChat}>×</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>
|
||||||
|
|
||||||
|
@ -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, {
|
||||||
|
@ -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>
|
||||||
|
@ -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>
|
||||||
|
@ -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>
|
||||||
|
|
||||||
|
|
||||||
|
@ -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>
|
57
front/src/Components/LayoutManager/LayoutManager.svelte
Normal 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>
|
96
front/src/Components/TypeMessage/BanMessage.svelte
Normal 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>
|
59
front/src/Components/TypeMessage/TextMessage.svelte
Normal 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>
|
@ -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>
|
||||||
|
@ -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>
|