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

This commit is contained in:
GRL 2021-08-03 17:19:47 +02:00
commit fe585297fc
167 changed files with 5730 additions and 12289 deletions

View File

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

View File

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

View File

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

View File

@ -1,4 +1,13 @@
## Version 1.4.x-dev ## Version develop
### Updates
- New scripting API features :
- Use `WA.room.loadTileset(url: string) : Promise<number>` to load a tileset from a JSON file.
- Rewrote the way authentification works: the auth jwt token can now contains an email instead of an uuid
- Added an OpenId login flow than can be plugged to any OIDC provider.
- You can send a message to all rooms of your world from the console global message (user with tag admin only).
## Version 1.4.11
### Updates ### Updates
@ -8,20 +17,35 @@
- 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.room.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
- Added a 'Enable notifications' button in the menu.
- The exchange format between Pusher and Admin servers has changed. If you have your own implementation of an admin server, these endpoints signatures have changed:
- `/api/map`: now accepts a complete room URL instead of organization/world/room slugs
- `/api/ban`: new endpoint to report users
- as a side effect, the "routing" is now completely stored on the admin side, so by implementing your own admin server, you can develop completely custom routing
## Version 1.4.3 - 1.4.4 - 1.4.5 ## Version 1.4.3 - 1.4.4 - 1.4.5

View File

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

View File

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

View File

@ -5,41 +5,64 @@ 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 { extractDataFromPrivateRoomId, extractRoomSlugPublicRoomId, isRoomAnonymous } from "./RoomIdentifier"; import {
import { arrayIntersect } from "../Services/ArrayHelper"; BatchToPusherMessage,
import { EmoteEventMessage, JoinRoomMessage } from "../Messages/generated/messages_pb"; 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 roomId: string;
public readonly roomSlug: string;
public readonly worldSlug: string = "";
public readonly organizationSlug: 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>();
roomId: string,
private constructor(
public readonly roomUrl: string,
private mapUrl: string,
private readonly connectCallback: ConnectCallback,
private readonly disconnectCallback: DisconnectCallback,
private readonly minDistance: number,
private readonly groupRadius: number,
onEnters: EntersCallback,
onMoves: MovesCallback,
onLeaves: LeavesCallback,
onEmote: EmoteCallback
) {
// A zone is 10 sprites wide.
this.positionNotifier = new PositionNotifier(320, 320, onEnters, onMoves, onLeaves, onEmote);
}
public static async create(
roomUrl: string,
connectCallback: ConnectCallback, connectCallback: ConnectCallback,
disconnectCallback: DisconnectCallback, disconnectCallback: DisconnectCallback,
minDistance: number, minDistance: number,
@ -48,28 +71,23 @@ export class GameRoom {
onMoves: MovesCallback, onMoves: MovesCallback,
onLeaves: LeavesCallback, onLeaves: LeavesCallback,
onEmote: EmoteCallback onEmote: EmoteCallback
) { ): Promise<GameRoom> {
this.roomId = roomId; const mapDetails = await GameRoom.getMapDetails(roomUrl);
if (isRoomAnonymous(roomId)) { const gameRoom = new GameRoom(
this.roomSlug = extractRoomSlugPublicRoomId(this.roomId); roomUrl,
} else { mapDetails.mapUrl,
const { organizationSlug, worldSlug, roomSlug } = extractDataFromPrivateRoomId(this.roomId); connectCallback,
this.roomSlug = roomSlug; disconnectCallback,
this.organizationSlug = organizationSlug; minDistance,
this.worldSlug = worldSlug; groupRadius,
} onEnters,
onMoves,
onLeaves,
onEmote
);
this.users = new Map<number, User>(); return gameRoom;
this.usersByUuid = new Map<string, User>();
this.admins = new Set<Admin>();
this.groups = new Set<Group>();
this.connectCallback = connectCallback;
this.disconnectCallback = disconnectCallback;
this.minDistance = minDistance;
this.groupRadius = groupRadius;
// A zone is 10 sprites wide.
this.positionNotifier = new PositionNotifier(320, 320, onEnters, onMoves, onLeaves, onEmote);
} }
public getGroups(): Group[] { public getGroups(): Group[] {
@ -86,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();
@ -177,7 +204,7 @@ export class GameRoom {
} else { } else {
const closestUser: User = closestItem; const closestUser: User = closestItem;
const group: Group = new Group( const group: Group = new Group(
this.roomId, this.roomUrl,
[user, closestUser], [user, closestUser],
this.connectCallback, this.connectCallback,
this.disconnectCallback, this.disconnectCallback,
@ -303,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);
} }
@ -332,4 +390,98 @@ export class GameRoom {
public emitEmoteEvent(user: User, emoteEventMessage: EmoteEventMessage) { public emitEmoteEvent(user: User, emoteEventMessage: EmoteEventMessage) {
this.positionNotifier.emitEmoteEvent(user, emoteEventMessage); this.positionNotifier.emitEmoteEvent(user, emoteEventMessage);
} }
public addRoomListener(socket: RoomSocket) {
this.roomListeners.add(socket);
}
public removeRoomListener(socket: RoomSocket) {
this.roomListeners.delete(socket);
}
/**
* Connects to the admin server to fetch map details.
* If there is no admin server, the map details are generated by analysing the map URL (that must be in the form: /_/instance/map_url)
*/
private static async getMapDetails(roomUrl: string): Promise<MapDetailsData> {
if (!ADMIN_API_URL) {
const roomUrlObj = new URL(roomUrl);
const match = /\/_\/[^/]+\/(.+)/.exec(roomUrlObj.pathname);
if (!match) {
console.error("Unexpected room URL", roomUrl);
throw new Error('Unexpected room URL "' + roomUrl + '"');
}
const mapUrl = roomUrlObj.protocol + "//" + match[1];
return {
mapUrl,
policy_type: 1,
textures: [],
tags: [],
};
}
const result = await adminApi.fetchMapDetails(roomUrl);
if (!isMapDetailsData(result)) {
console.error("Unexpected room details received from server", result);
throw new Error("Unexpected room details received from server");
}
return result;
}
private mapPromise: Promise<ITiledMap> | undefined;
/**
* Returns a promise to the map file.
* @throws LocalUrlError if the map we are trying to load is hosted on a local network
* @throws Error
*/
private getMap(): Promise<ITiledMap> {
if (!this.mapPromise) {
this.mapPromise = mapFetcher.fetchMap(this.mapUrl);
}
return this.mapPromise;
}
private variableManagerPromise: Promise<VariablesManager> | undefined;
private getVariableManager(): Promise<VariablesManager> {
if (!this.variableManagerPromise) {
this.variableManagerPromise = this.getMap()
.then((map) => {
const variablesManager = new VariablesManager(this.roomUrl, map);
return variablesManager.init();
})
.catch((e) => {
if (e instanceof LocalUrlError) {
// If we are trying to load a local URL, we are probably in test mode.
// In this case, let's bypass the server-side checks completely.
// Note: we run this message inside a setTimeout so that the room listeners can have time to connect.
setTimeout(() => {
for (const roomListener of this.roomListeners) {
emitErrorOnRoomSocket(
roomListener,
"You are loading a local map. If you use the scripting API in this map, please be aware that server-side checks and variable persistence is disabled."
);
}
}, 1000);
const variablesManager = new VariablesManager(this.roomUrl, null);
return variablesManager.init();
} else {
throw e;
}
});
}
return this.variableManagerPromise;
}
public async getVariablesForTags(tags: string[]): Promise<Map<string, string>> {
const variablesManager = await this.getVariableManager();
return variablesManager.getVariablesForTags(tags);
}
} }

View File

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

View File

@ -1,30 +0,0 @@
//helper functions to parse room IDs
export const isRoomAnonymous = (roomID: string): boolean => {
if (roomID.startsWith("_/")) {
return true;
} else if (roomID.startsWith("@/")) {
return false;
} else {
throw new Error("Incorrect room ID: " + roomID);
}
};
export const extractRoomSlugPublicRoomId = (roomId: string): string => {
const idParts = roomId.split("/");
if (idParts.length < 3) throw new Error("Incorrect roomId: " + roomId);
return idParts.slice(2).join("/");
};
export interface extractDataFromPrivateRoomIdResponse {
organizationSlug: string;
worldSlug: string;
roomSlug: string;
}
export const extractDataFromPrivateRoomId = (roomId: string): extractDataFromPrivateRoomIdResponse => {
const idParts = roomId.split("/");
if (idParts.length < 4) throw new Error("Incorrect roomId: " + roomId);
const organizationSlug = idParts[1];
const worldSlug = idParts[2];
const roomSlug = idParts[3];
return { organizationSlug, worldSlug, roomSlug };
};

View File

@ -5,6 +5,8 @@ import {
AdminPusherToBackMessage, AdminPusherToBackMessage,
AdminRoomMessage, AdminRoomMessage,
BanMessage, BanMessage,
BatchToPusherMessage,
BatchToPusherRoomMessage,
EmotePromptMessage, EmotePromptMessage,
EmptyMessage, EmptyMessage,
ItemEventMessage, ItemEventMessage,
@ -13,17 +15,18 @@ import {
PusherToBackMessage, PusherToBackMessage,
QueryJitsiJwtMessage, QueryJitsiJwtMessage,
RefreshRoomPromptMessage, RefreshRoomPromptMessage,
RoomMessage,
ServerToAdminClientMessage, ServerToAdminClientMessage,
ServerToClientMessage,
SilentMessage, SilentMessage,
UserMovesMessage, UserMovesMessage,
VariableMessage,
WebRtcSignalToServerMessage, WebRtcSignalToServerMessage,
WorldFullWarningToRoomMessage, WorldFullWarningToRoomMessage,
ZoneMessage, ZoneMessage,
} from "./Messages/generated/messages_pb"; } from "./Messages/generated/messages_pb";
import { sendUnaryData, ServerDuplexStream, ServerUnaryCall, ServerWritableStream } from "grpc"; import { sendUnaryData, ServerDuplexStream, ServerUnaryCall, ServerWritableStream } from "grpc";
import { socketManager } from "./Services/SocketManager"; import { socketManager } from "./Services/SocketManager";
import { emitError } from "./Services/MessageHelpers"; import { emitError, emitErrorOnRoomSocket, emitErrorOnZoneSocket } from "./Services/MessageHelpers";
import { User, UserSocket } from "./Model/User"; import { User, UserSocket } from "./Model/User";
import { GameRoom } from "./Model/GameRoom"; import { GameRoom } from "./Model/GameRoom";
import Debug from "debug"; import Debug from "debug";
@ -32,7 +35,8 @@ import { Admin } from "./Model/Admin";
const debug = Debug("roommanager"); const debug = Debug("roommanager");
export type AdminSocket = ServerDuplexStream<AdminPusherToBackMessage, ServerToAdminClientMessage>; export type AdminSocket = ServerDuplexStream<AdminPusherToBackMessage, ServerToAdminClientMessage>;
export type ZoneSocket = ServerWritableStream<ZoneMessage, ServerToClientMessage>; export type ZoneSocket = ServerWritableStream<ZoneMessage, BatchToPusherMessage>;
export type RoomSocket = ServerWritableStream<RoomMessage, BatchToPusherRoomMessage>;
const roomManager: IRoomManagerServer = { const roomManager: IRoomManagerServer = {
joinRoom: (call: UserSocket): void => { joinRoom: (call: UserSocket): void => {
@ -42,6 +46,7 @@ const roomManager: IRoomManagerServer = {
let user: User | null = null; let user: User | null = null;
call.on("data", (message: PusherToBackMessage) => { call.on("data", (message: PusherToBackMessage) => {
(async () => {
try { try {
if (room === null || user === null) { if (room === null || user === null) {
if (message.hasJoinroommessage()) { if (message.hasJoinroommessage()) {
@ -52,10 +57,11 @@ const roomManager: IRoomManagerServer = {
room = gameRoom; room = gameRoom;
user = myUser; user = myUser;
} else { } else {
//Connexion may have been closed before the init was finished, so we have to manually disconnect the user. //Connection may have been closed before the init was finished, so we have to manually disconnect the user.
socketManager.leaveRoom(gameRoom, myUser); socketManager.leaveRoom(gameRoom, myUser);
} }
}); })
.catch((e) => emitError(call, 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");
} }
@ -71,7 +77,17 @@ const roomManager: IRoomManagerServer = {
} else if (message.hasSilentmessage()) { } else if (message.hasSilentmessage()) {
socketManager.handleSilentMessage(room, user, message.getSilentmessage() as SilentMessage); socketManager.handleSilentMessage(room, user, message.getSilentmessage() as SilentMessage);
} else if (message.hasItemeventmessage()) { } else if (message.hasItemeventmessage()) {
socketManager.handleItemEvent(room, user, message.getItemeventmessage() as ItemEventMessage); 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()) { } else if (message.hasWebrtcsignaltoservermessage()) {
socketManager.emitVideo( socketManager.emitVideo(
room, room,
@ -85,7 +101,10 @@ const roomManager: IRoomManagerServer = {
message.getWebrtcscreensharingsignaltoservermessage() as WebRtcSignalToServerMessage message.getWebrtcscreensharingsignaltoservermessage() as WebRtcSignalToServerMessage
); );
} else if (message.hasPlayglobalmessage()) { } else if (message.hasPlayglobalmessage()) {
socketManager.emitPlayGlobalMessage(room, message.getPlayglobalmessage() as PlayGlobalMessage); socketManager.emitPlayGlobalMessage(
room,
message.getPlayglobalmessage() as PlayGlobalMessage
);
} else if (message.hasQueryjitsijwtmessage()) { } else if (message.hasQueryjitsijwtmessage()) {
socketManager.handleQueryJitsiJwtMessage( socketManager.handleQueryJitsiJwtMessage(
user, user,
@ -112,9 +131,11 @@ const roomManager: IRoomManagerServer = {
} }
} }
} catch (e) { } catch (e) {
console.error(e);
emitError(call, e); emitError(call, e);
call.end(); call.end();
} }
})().catch((e) => console.error(e));
}); });
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
.handleJoinAdminRoom(admin, roomId)
.then((gameRoom: GameRoom) => {
room = gameRoom; room = gameRoom;
}); })
.catch((e) => console.error(e));
} else { } else {
throw new Error("The first message sent MUST be of type JoinRoomMessage"); throw new Error("The first message sent MUST be of type JoinRoomMessage");
} }
@ -192,11 +250,9 @@ const roomManager: IRoomManagerServer = {
}); });
}, },
sendAdminMessage(call: ServerUnaryCall<AdminMessage>, callback: sendUnaryData<EmptyMessage>): void { sendAdminMessage(call: ServerUnaryCall<AdminMessage>, callback: sendUnaryData<EmptyMessage>): void {
socketManager.sendAdminMessage( socketManager
call.request.getRoomid(), .sendAdminMessage(call.request.getRoomid(), call.request.getRecipientuuid(), call.request.getMessage())
call.request.getRecipientuuid(), .catch((e) => console.error(e));
call.request.getMessage()
);
callback(null, new EmptyMessage()); callback(null, new EmptyMessage());
}, },
@ -207,26 +263,33 @@ const roomManager: IRoomManagerServer = {
}, },
ban(call: ServerUnaryCall<BanMessage>, callback: sendUnaryData<EmptyMessage>): void { ban(call: ServerUnaryCall<BanMessage>, callback: sendUnaryData<EmptyMessage>): void {
// FIXME Work in progress // FIXME Work in progress
socketManager.banUser(call.request.getRoomid(), call.request.getRecipientuuid(), call.request.getMessage()); socketManager
.banUser(call.request.getRoomid(), call.request.getRecipientuuid(), call.request.getMessage())
.catch((e) => console.error(e));
callback(null, new EmptyMessage()); callback(null, new EmptyMessage());
}, },
sendAdminMessageToRoom(call: ServerUnaryCall<AdminRoomMessage>, callback: sendUnaryData<EmptyMessage>): void { sendAdminMessageToRoom(call: ServerUnaryCall<AdminRoomMessage>, callback: sendUnaryData<EmptyMessage>): void {
socketManager.sendAdminRoomMessage(call.request.getRoomid(), call.request.getMessage()); // FIXME: we could improve return message by returning a Success|ErrorMessage message
socketManager
.sendAdminRoomMessage(call.request.getRoomid(), call.request.getMessage(), call.request.getType())
.catch((e) => console.error(e));
callback(null, new EmptyMessage()); callback(null, new EmptyMessage());
}, },
sendWorldFullWarningToRoom( sendWorldFullWarningToRoom(
call: ServerUnaryCall<WorldFullWarningToRoomMessage>, call: ServerUnaryCall<WorldFullWarningToRoomMessage>,
callback: sendUnaryData<EmptyMessage> callback: sendUnaryData<EmptyMessage>
): void { ): void {
socketManager.dispatchWorlFullWarning(call.request.getRoomid()); // FIXME: we could improve return message by returning a Success|ErrorMessage message
socketManager.dispatchWorldFullWarning(call.request.getRoomid()).catch((e) => console.error(e));
callback(null, new EmptyMessage()); callback(null, new EmptyMessage());
}, },
sendRefreshRoomPrompt( sendRefreshRoomPrompt(
call: ServerUnaryCall<RefreshRoomPromptMessage>, call: ServerUnaryCall<RefreshRoomPromptMessage>,
callback: sendUnaryData<EmptyMessage> callback: sendUnaryData<EmptyMessage>
): void { ): void {
socketManager.dispatchRoomRefresh(call.request.getRoomid()); // FIXME: we could improve return message by returning a Success|ErrorMessage message
socketManager.dispatchRoomRefresh(call.request.getRoomid()).catch((e) => console.error(e));
callback(null, new EmptyMessage()); callback(null, new EmptyMessage());
}, },
}; };

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -30,6 +30,9 @@ import {
BanUserMessage, BanUserMessage,
RefreshRoomMessage, RefreshRoomMessage,
EmotePromptMessage, EmotePromptMessage,
VariableMessage,
BatchToPusherRoomMessage,
SubToPusherRoomMessage,
} from "../Messages/generated/messages_pb"; } from "../Messages/generated/messages_pb";
import { User, UserSocket } from "../Model/User"; import { User, UserSocket } from "../Model/User";
import { ProtobufUtils } from "../Model/Websocket/ProtobufUtils"; import { ProtobufUtils } from "../Model/Websocket/ProtobufUtils";
@ -48,7 +51,7 @@ import Jwt from "jsonwebtoken";
import { JITSI_URL } from "../Enum/EnvironmentVariable"; import { JITSI_URL } from "../Enum/EnvironmentVariable";
import { clientEventsEmitter } from "./ClientEventsEmitter"; import { clientEventsEmitter } from "./ClientEventsEmitter";
import { gaugeManager } from "./GaugeManager"; import { gaugeManager } from "./GaugeManager";
import { ZoneSocket } from "../RoomManager"; import { RoomSocket, ZoneSocket } from "../RoomManager";
import { Zone } from "_Model/Zone"; import { Zone } from "_Model/Zone";
import Debug from "debug"; import Debug from "debug";
import { Admin } from "_Model/Admin"; import { Admin } from "_Model/Admin";
@ -65,7 +68,9 @@ function emitZoneMessage(subMessage: SubToPusherMessage, socket: ZoneSocket): vo
} }
export class SocketManager { export class SocketManager {
private rooms: Map<string, GameRoom> = new Map<string, GameRoom>(); //private rooms = new Map<string, GameRoom>();
// List of rooms in process of loading.
private roomsPromises = new Map<string, PromiseLike<GameRoom>>();
constructor() { constructor() {
clientEventsEmitter.registerToClientJoin((clientUUid: string, roomId: string) => { clientEventsEmitter.registerToClientJoin((clientUUid: string, roomId: string) => {
@ -101,6 +106,16 @@ export class SocketManager {
roomJoinedMessage.addItem(itemStateMessage); roomJoinedMessage.addItem(itemStateMessage);
} }
const variables = await room.getVariablesForTags(user.tags);
for (const [name, value] of variables.entries()) {
const variableMessage = new VariableMessage();
variableMessage.setName(name);
variableMessage.setValue(value);
roomJoinedMessage.addVariable(variableMessage);
}
roomJoinedMessage.setCurrentuserid(user.id); roomJoinedMessage.setCurrentuserid(user.id);
const serverToClientMessage = new ServerToClientMessage(); const serverToClientMessage = new ServerToClientMessage();
@ -114,7 +129,6 @@ 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();
@ -134,10 +148,6 @@ export class SocketManager {
// update position in the world // update position in the world
room.updatePosition(user, ProtobufUtils.toPointInterface(position)); room.updatePosition(user, ProtobufUtils.toPointInterface(position));
//room.setViewport(client, client.viewport); //room.setViewport(client, client.viewport);
} catch (e) {
console.error('An error occurred on "user_position" event');
console.error(e);
}
} }
// Useless now, will be useful again if we allow editing details in game // Useless now, will be useful again if we allow editing details in game
@ -156,18 +166,12 @@ export class SocketManager {
}*/ }*/
handleSilentMessage(room: GameRoom, user: User, silentMessage: SilentMessage) { 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);
@ -178,10 +182,10 @@ export class SocketManager {
} }
room.setItemState(itemEvent.itemId, itemEvent.state); room.setItemState(itemEvent.itemId, itemEvent.state);
} catch (e) {
console.error('An error occurred on "item_event"');
console.error(e);
} }
handleVariableEvent(room: GameRoom, user: User, variableMessage: VariableMessage): Promise<void> {
return room.setVariable(variableMessage.getName(), variableMessage.getValue(), user);
} }
emitVideo(room: GameRoom, user: User, data: WebRtcSignalToServerMessage): void { emitVideo(room: GameRoom, user: User, data: WebRtcSignalToServerMessage): void {
@ -250,21 +254,21 @@ 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.roomId); this.roomsPromises.delete(room.roomUrl);
gaugeManager.decNbRoomGauge(); gaugeManager.decNbRoomGauge();
debug('Room is empty. Deleting room "%s"', room.roomId); debug('Room is empty. Deleting room "%s"', room.roomUrl);
} }
} finally { } finally {
clientEventsEmitter.emitClientLeave(user.uuid, room.roomId); clientEventsEmitter.emitClientLeave(user.uuid, room.roomUrl);
console.log("A user left"); console.log("A user left");
} }
} }
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)
); )
.then((gameRoom) => {
gaugeManager.incNbRoomGauge(); gaugeManager.incNbRoomGauge();
this.rooms.set(roomId, world); return gameRoom;
})
.catch((e) => {
this.roomsPromises.delete(roomId);
throw e;
});
this.roomsPromises.set(roomId, roomPromise);
} }
return Promise.resolve(world); return roomPromise;
} }
private async joinRoom( 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.roomId); this.roomsPromises.delete(room.roomUrl);
gaugeManager.decNbRoomGauge(); gaugeManager.decNbRoomGauge();
debug('Room is empty. Deleting room "%s"', room.roomId); 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,6 +711,7 @@ export class SocketManager {
return; return;
} }
for (const recipient of recipients) {
const sendUserMessage = new SendUserMessage(); const sendUserMessage = new SendUserMessage();
sendUserMessage.setMessage(message); sendUserMessage.setMessage(message);
sendUserMessage.setType("ban"); //todo: is the type correct? sendUserMessage.setType("ban"); //todo: is the type correct?
@ -694,9 +721,10 @@ export class SocketManager {
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,6 +744,7 @@ export class SocketManager {
return; return;
} }
for (const recipient of recipients) {
// Let's leave the room now. // Let's leave the room now.
room.leave(recipient); room.leave(recipient);
@ -730,9 +759,10 @@ export class SocketManager {
recipient.socket.write(serverToClientMessage); recipient.socket.write(serverToClientMessage);
recipient.socket.end(); recipient.socket.end();
} }
}
sendAdminRoomMessage(roomId: string, message: string) { async sendAdminRoomMessage(roomId: string, message: string, type: string) {
const room = this.rooms.get(roomId); const room = await this.roomsPromises.get(roomId);
if (!room) { if (!room) {
//todo: this should cause the http call to return a 500 //todo: this should cause the http call to return a 500
console.error( console.error(
@ -746,7 +776,7 @@ export class SocketManager {
room.getUsers().forEach((recipient) => { room.getUsers().forEach((recipient) => {
const sendUserMessage = new SendUserMessage(); const sendUserMessage = new SendUserMessage();
sendUserMessage.setMessage(message); sendUserMessage.setMessage(message);
sendUserMessage.setType("message"); sendUserMessage.setType(type);
const clientMessage = new ServerToClientMessage(); const clientMessage = new ServerToClientMessage();
clientMessage.setSendusermessage(sendUserMessage); clientMessage.setSendusermessage(sendUserMessage);
@ -755,12 +785,12 @@ export class SocketManager {
}); });
} }
dispatchWorlFullWarning(roomId: string): void { async dispatchWorldFullWarning(roomId: string): Promise<void> {
const room = this.rooms.get(roomId); const room = await this.roomsPromises.get(roomId);
if (!room) { if (!room) {
//todo: this should cause the http call to return a 500 //todo: this should cause the http call to return a 500
console.error( console.error(
"In sendAdminRoomMessage, could not find room with id '" + "In dispatchWorldFullWarning, could not find room with id '" +
roomId + roomId +
"'. Maybe the room was closed a few milliseconds ago and there was a race condition?" "'. Maybe the room was closed a few milliseconds ago and there was a race condition?"
); );
@ -777,8 +807,8 @@ export class SocketManager {
}); });
} }
dispatchRoomRefresh(roomId: string): void { async dispatchRoomRefresh(roomId: string): Promise<void> {
const room = this.rooms.get(roomId); const room = await this.roomsPromises.get(roomId);
if (!room) { if (!room) {
return; return;
} }

View File

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

View File

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

View File

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

View File

@ -1,19 +0,0 @@
import {extractDataFromPrivateRoomId, extractRoomSlugPublicRoomId, isRoomAnonymous} from "../src/Model/RoomIdentifier";
describe("RoomIdentifier", () => {
it("should flag public id as anonymous", () => {
expect(isRoomAnonymous('_/global/test')).toBe(true);
});
it("should flag public id as not anonymous", () => {
expect(isRoomAnonymous('@/afup/afup2020/1floor')).toBe(false);
});
it("should extract roomSlug from public ID", () => {
expect(extractRoomSlugPublicRoomId('_/global/npeguin/test.json')).toBe('npeguin/test.json');
});
it("should extract correct from private ID", () => {
const {organizationSlug, worldSlug, roomSlug} = extractDataFromPrivateRoomId('@/afup/afup2020/1floor');
expect(organizationSlug).toBe('afup');
expect(worldSlug).toBe('afup2020');
expect(roomSlug).toBe('1floor');
});
})

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,6 +1,63 @@
{.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
``` ```
WA.player.onPlayerMove(callback: HasPlayerMovedEventCallback): void; WA.player.onPlayerMove(callback: HasPlayerMovedEventCallback): void;

View File

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

View File

@ -79,44 +79,58 @@ Example :
WA.room.setProperty('wikiLayer', 'openWebsite', 'https://www.wikipedia.org/'); WA.room.setProperty('wikiLayer', 'openWebsite', 'https://www.wikipedia.org/');
``` ```
### Getting information on the current room ### Get the room id
```
WA.room.getCurrentRoom(): Promise<Room>
```
Return a promise that resolves to a `Room` object with the following attributes :
* **id (string) :** ID of the current room
* **map (ITiledMap) :** contains the JSON map file with the properties that were set by the script if `setProperty` was called.
* **mapUrl (string) :** Url of the JSON map file
* **startLayer (string | null) :** Name of the layer where the current user started, only if different from `start` layer
Example : ```
```javascript WA.room.id: string;
WA.room.getCurrentRoom((room) => { ```
if (room.id === '42') {
console.log(room.map); The ID of the current room is available from the `WA.room.id` property.
window.open(room.mapUrl, '_blank');
} {.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"
}) })
``` ```
### Getting information on the current user ### Get the map URL
```
WA.player.getCurrentUser(): Promise<User>
```
Return a promise that resolves to a `User` object with the following attributes :
* **id (string) :** ID of the current user
* **nickName (string) :** name displayed above the current user
* **tags (string[]) :** list of all the tags of the current user
Example : ```
```javascript WA.room.mapURL: string;
WA.room.getCurrentUser().then((user) => { ```
if (user.nickName === 'ABC') {
console.log(user.tags); 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
@ -149,3 +163,17 @@ 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'}]);
})
```

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

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

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

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

View File

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

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

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

View File

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

View File

@ -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>

1
front/dist/webpack.config.d.ts vendored Normal file
View File

@ -0,0 +1 @@
export {};

8393
front/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -10,6 +10,7 @@
"@types/mini-css-extract-plugin": "^1.4.3", "@types/mini-css-extract-plugin": "^1.4.3",
"@types/node": "^15.3.0", "@types/node": "^15.3.0",
"@types/quill": "^1.3.7", "@types/quill": "^1.3.7",
"@types/uuidv4": "^5.0.0",
"@types/webpack-dev-server": "^3.11.4", "@types/webpack-dev-server": "^3.11.4",
"@typescript-eslint/eslint-plugin": "^4.23.0", "@typescript-eslint/eslint-plugin": "^4.23.0",
"@typescript-eslint/parser": "^4.23.0", "@typescript-eslint/parser": "^4.23.0",
@ -39,7 +40,7 @@
}, },
"dependencies": { "dependencies": {
"@fontsource/press-start-2p": "^4.3.0", "@fontsource/press-start-2p": "^4.3.0",
"@types/simple-peer": "^9.6.0", "@types/simple-peer": "^9.11.1",
"@types/socket.io-client": "^1.4.32", "@types/socket.io-client": "^1.4.32",
"axios": "^0.21.1", "axios": "^0.21.1",
"cross-env": "^7.0.3", "cross-env": "^7.0.3",
@ -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.6.2", "simple-peer": "^9.11.0",
"socket.io-client": "^2.3.0", "socket.io-client": "^2.3.0",
"standardized-audio-context": "^25.2.4" "standardized-audio-context": "^25.2.4",
"uuidv4": "^6.2.10"
}, },
"scripts": { "scripts": {
"start": "run-p templater serve svelte-check-watch", "start": "run-p templater serve svelte-check-watch",

View File

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

View File

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

View File

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

View File

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

View File

@ -1,4 +1,4 @@
import type { GameStateEvent } from "./GameStateEvent"; import * as tg from "generic-type-guard";
import type { ButtonClickedEvent } from "./ButtonClickedEvent"; import type { ButtonClickedEvent } from "./ButtonClickedEvent";
import type { ChatEvent } from "./ChatEvent"; import type { ChatEvent } from "./ChatEvent";
import type { ClosePopupEvent } from "./ClosePopupEvent"; import type { ClosePopupEvent } from "./ClosePopupEvent";
@ -9,7 +9,6 @@ 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 { 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 +17,12 @@ 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 { LoadTilesetEvent } from "./LoadTilesetEvent";
import { isLoadTilesetEvent } from "./LoadTilesetEvent";
export interface TypedMessageEvent<T> extends MessageEvent { export interface TypedMessageEvent<T> extends MessageEvent {
data: T; data: T;
@ -43,11 +48,11 @@ 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;
}; };
@ -66,8 +71,8 @@ export interface IframeResponseEventMap {
leaveEvent: EnterLeaveEvent; leaveEvent: EnterLeaveEvent;
buttonClickedEvent: ButtonClickedEvent; buttonClickedEvent: ButtonClickedEvent;
hasPlayerMoved: HasPlayerMovedEvent; hasPlayerMoved: HasPlayerMovedEvent;
dataLayer: DataLayerEvent;
menuItemClicked: MenuItemClickedEvent; menuItemClicked: MenuItemClickedEvent;
setVariable: SetVariableEvent;
} }
export interface IframeResponseEvent<T extends keyof IframeResponseEventMap> { export interface IframeResponseEvent<T extends keyof IframeResponseEventMap> {
type: T; type: T;
@ -79,20 +84,43 @@ 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,
},
};
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 +128,36 @@ 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;
}
return iframeQueryMapTypeGuards[type].query(event.data);
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const isIframeQueryWrapper = (event: any): event is IframeQueryWrapper<keyof IframeQueryMap> =>
typeof event.id === "number" && isIframeQuery(event.query);
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 +165,9 @@ export interface IframeErrorAnswerEvent {
error: string; error: string;
} }
export const isIframeErrorAnswerEvent = (event: { type?: string, id?: number, error?: string }): event is IframeErrorAnswerEvent => typeof event.type === 'string' && typeof event.id === 'number' && typeof event.error === 'string'; export const isIframeErrorAnswerEvent = (event: {
type?: string;
id?: number;
error?: string;
}): event is IframeErrorAnswerEvent =>
typeof event.type === "string" && typeof event.id === "number" && typeof event.error === "string";

View File

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

View File

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

View File

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

View File

@ -12,7 +12,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 +26,25 @@ 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";
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 +90,6 @@ class IframeListener {
private readonly _setPropertyStream: Subject<SetPropertyEvent> = new Subject(); private readonly _setPropertyStream: Subject<SetPropertyEvent> = new Subject();
public readonly setPropertyStream = this._setPropertyStream.asObservable(); public readonly setPropertyStream = this._setPropertyStream.asObservable();
private readonly _dataLayerChangeStream: Subject<void> = new Subject();
public readonly dataLayerChangeStream = this._dataLayerChangeStream.asObservable();
private readonly _registerMenuCommandStream: Subject<string> = new Subject(); private readonly _registerMenuCommandStream: Subject<string> = new Subject();
public readonly registerMenuCommandStream = this._registerMenuCommandStream.asObservable(); public readonly registerMenuCommandStream = this._registerMenuCommandStream.asObservable();
@ -111,8 +113,9 @@ class IframeListener {
private readonly scripts = new Map<string, HTMLIFrameElement>(); private readonly scripts = new Map<string, HTMLIFrameElement>();
private sendPlayerMove: boolean = false; private sendPlayerMove: boolean = false;
// Note: we are forced to type this in unknown and later cast with "as" because of https://github.com/microsoft/TypeScript/issues/31904
private answerers: { private answerers: {
[key in keyof IframeQueryMap]?: AnswererCallback<key> [str in keyof IframeQueryMap]?: unknown;
} = {}; } = {};
init() { init() {
@ -152,40 +155,61 @@ 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, id: queryId,
type: query.type, type: query.type,
error: errorMsg error: errorMsg,
} as IframeErrorAnswerEvent, '*'); } 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, id: queryId,
type: query.type, type: query.type,
error: reasonMsg error: reasonMsg,
} as IframeErrorAnswerEvent, '*'); } as IframeErrorAnswerEvent,
}); "*"
);
};
try {
Promise.resolve(answerer(query.data, message.source))
.then((value) => {
iframe?.contentWindow?.postMessage(
{
id: queryId,
type: query.type,
data: value,
},
"*"
);
})
.catch(errorHandler);
} catch (reason) {
errorHandler(reason);
}
} else if (isIframeEventWrapper(payload)) { } else if (isIframeEventWrapper(payload)) {
if (payload.type === "showLayer" && isLayerEvent(payload.data)) { if (payload.type === "showLayer" && isLayerEvent(payload.data)) {
this._showLayerStream.next(payload.data); this._showLayerStream.next(payload.data);
@ -230,8 +254,6 @@ class IframeListener {
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") {
this._dataLayerChangeStream.next();
} else if (isMenuItemRegisterIframeEvent(payload)) { } else if (isMenuItemRegisterIframeEvent(payload)) {
const data = payload.data.menutItem; const data = payload.data.menutItem;
// @ts-ignore // @ts-ignore
@ -248,13 +270,6 @@ class IframeListener {
); );
} }
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 +409,13 @@ class IframeListener {
}); });
} }
setVariable(setVariableEvent: SetVariableEvent) {
this.postMessage({
type: "setVariable",
data: setVariableEvent,
});
}
/** /**
* Sends the message... to all allowed iframes. * Sends the message... to all allowed iframes.
*/ */
@ -411,13 +433,31 @@ class IframeListener {
* @param key The "type" of the query we are answering * @param key The "type" of the query we are answering
* @param callback * @param callback
*/ */
public registerAnswerer<T extends keyof IframeQueryMap>(key: T, callback: (query: IframeQueryMap[T]['query']) => IframeQueryMap[T]['answer']|Promise<IframeQueryMap[T]['answer']> ): void { public registerAnswerer<T extends keyof IframeQueryMap>(key: T, callback: AnswererCallback<T>): void {
this.answerers[key] = callback; this.answerers[key] = callback;
} }
public unregisterAnswerer(key: keyof IframeQueryMap): void { public unregisterAnswerer(key: keyof IframeQueryMap): void {
delete this.answerers[key]; delete this.answerers[key];
} }
dispatchVariableToOtherIframes(key: string, value: unknown, source: MessageEventSource | null) {
// Let's dispatch the message to the other iframes
for (const iframe of this.iframes) {
if (iframe.contentWindow !== source) {
iframe.contentWindow?.postMessage(
{
type: "setVariable",
data: {
key,
value,
},
},
"*"
);
}
}
}
} }
export const iframeListener = new IframeListener(); export const iframeListener = new IframeListener();

View File

@ -6,6 +6,24 @@ import { isHasPlayerMovedEvent } from "../Events/HasPlayerMovedEvent";
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({
@ -24,6 +42,31 @@ export class WorkadventurePlayerCommands extends IframeApiContribution<Workadven
data: null, data: null,
}); });
} }
get name(): string {
if (playerName === undefined) {
throw new Error(
"Player name not initialized yet. You should call WA.player.name within a WA.onInit callback."
);
}
return playerName;
}
get tags(): string[] {
if (tags === undefined) {
throw new Error("Tags not initialized yet. You should call WA.player.tags within a WA.onInit callback.");
}
return tags;
}
get id(): string | undefined {
// Note: this is not a type, we are checking if playerName is undefined because playerName cannot be undefined
// while uuid could.
if (playerName === undefined) {
throw new Error("Player id not initialized yet. You should call WA.player.id within a WA.onInit callback.");
}
return uuid;
}
} }
export default new WorkadventurePlayerCommands(); export default new WorkadventurePlayerCommands();

View File

@ -1,35 +1,14 @@
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 { GameStateEvent } from "../Events/GameStateEvent";
const enterStreams: Map<string, Subject<EnterLeaveEvent>> = new Map<string, Subject<EnterLeaveEvent>>(); const enterStreams: Map<string, Subject<EnterLeaveEvent>> = new Map<string, Subject<EnterLeaveEvent>>();
const leaveStreams: Map<string, Subject<EnterLeaveEvent>> = new Map<string, Subject<EnterLeaveEvent>>(); const leaveStreams: Map<string, Subject<EnterLeaveEvent>> = new Map<string, Subject<EnterLeaveEvent>>();
const dataLayerResolver = new Subject<DataLayerEvent>();
const stateResolvers = new Subject<GameStateEvent>();
let immutableDataPromise: Promise<GameStateEvent> | undefined = undefined;
interface Room {
id: string;
mapUrl: string;
map: ITiledMap;
startLayer: string | null;
}
interface User {
id: string | undefined;
nickName: string | null;
tags: string[];
}
interface TileDescriptor { interface TileDescriptor {
x: number; x: number;
@ -38,19 +17,17 @@ interface TileDescriptor {
layer: string; layer: string;
} }
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 = [
@ -68,13 +45,6 @@ export class WorkadventureRoomCommands extends IframeApiContribution<Workadventu
leaveStreams.get(payloadData.name)?.next(); leaveStreams.get(payloadData.name)?.next();
}, },
}), }),
apiCallback({
type: "dataLayer",
typeChecker: isDataLayerEvent,
callback: (payloadData) => {
dataLayerResolver.next(payloadData);
},
}),
]; ];
onEnterZone(name: string, callback: () => void): void { onEnterZone(name: string, callback: () => void): void {
@ -109,22 +79,9 @@ export class WorkadventureRoomCommands extends IframeApiContribution<Workadventu
}, },
}); });
} }
getCurrentRoom(): Promise<Room> { async getTiledMap(): Promise<ITiledMap> {
return getGameState().then((gameState) => { const event = await queryWorkadventure({ type: "getMapData", data: undefined });
return getDataLayer().then((mapJson) => { return event.data as ITiledMap;
return {
id: gameState.roomId,
map: mapJson.data as ITiledMap,
mapUrl: gameState.mapUrl,
startLayer: gameState.startLayerName,
};
});
});
}
getCurrentUser(): Promise<User> {
return getGameState().then((gameState) => {
return { id: gameState.uuid, nickName: gameState.nickname, tags: gameState.tags };
});
} }
setTiles(tiles: TileDescriptor[]) { setTiles(tiles: TileDescriptor[]) {
sendToWorkadventure({ sendToWorkadventure({
@ -132,6 +89,30 @@ 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,
},
});
}
} }
export default new WorkadventureRoomCommands(); export default new WorkadventureRoomCommands();

View File

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

View File

@ -30,6 +30,12 @@
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";
export let game: Game; export let game: Game;
@ -61,6 +67,16 @@
<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} />
@ -88,7 +104,7 @@
{/if} {/if}
{#if $consoleGlobalMessageManagerVisibleStore} {#if $consoleGlobalMessageManagerVisibleStore}
<div> <div>
<ConsoleGlobalMessageManager game={game}></ConsoleGlobalMessageManager> <ConsoleGlobalMessageManager></ConsoleGlobalMessageManager>
</div> </div>
{/if} {/if}
{#if $helpCameraSettingsVisibleStore} {#if $helpCameraSettingsVisibleStore}
@ -107,4 +123,7 @@
{#if $chatVisibilityStore} {#if $chatVisibilityStore}
<Chat></Chat> <Chat></Chat>
{/if} {/if}
{#if $warningContainerStore}
<WarningContainer></WarningContainer>
{/if}
</div> </div>

View File

@ -3,9 +3,12 @@
import { chatMessagesStore, chatVisibilityStore } from "../../Stores/ChatStore"; import { chatMessagesStore, chatVisibilityStore } from "../../Stores/ChatStore";
import ChatMessageForm from './ChatMessageForm.svelte'; import ChatMessageForm from './ChatMessageForm.svelte';
import ChatElement from './ChatElement.svelte'; import ChatElement from './ChatElement.svelte';
import { afterUpdate, beforeUpdate } from "svelte"; import {afterUpdate, beforeUpdate} from "svelte";
import {HtmlUtils} from "../../WebRtc/HtmlUtils";
let listDom: HTMLElement; let listDom: HTMLElement;
let chatWindowElement: HTMLElement;
let handleFormBlur: { blur():void };
let autoscroll: boolean; let autoscroll: boolean;
beforeUpdate(() => { beforeUpdate(() => {
@ -16,6 +19,12 @@
if (autoscroll) listDom.scrollTo(0, listDom.scrollHeight); if (autoscroll) listDom.scrollTo(0, listDom.scrollHeight);
}); });
function onClick(event: MouseEvent) {
if (HtmlUtils.isClickedOutside(event, chatWindowElement)) {
handleFormBlur.blur();
}
}
function closeChat() { function closeChat() {
chatVisibilityStore.set(false); chatVisibilityStore.set(false);
} }
@ -26,37 +35,42 @@
} }
</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}>
<section class="chatWindowTitle"> <p class="close-icon" on:click={closeChat}>&times</p>
<h1>Your chat history <span class="float-right" on:click={closeChat}>&times</span></h1>
</section>
<section class="messagesList" bind:this={listDom}> <section class="messagesList" bind:this={listDom}>
<ul> <ul>
<li><p class="system-text">Here is your chat history: </p></li>
{#each $chatMessagesStore as message, i} {#each $chatMessagesStore as message, i}
<li><ChatElement message={message} line={i}></ChatElement></li> <li><ChatElement message={message} line={i}></ChatElement></li>
{/each} {/each}
</ul> </ul>
</section> </section>
<section class="messageForm"> <section class="messageForm">
<ChatMessageForm></ChatMessageForm> <ChatMessageForm bind:handleForm={handleFormBlur}></ChatMessageForm>
</section> </section>
</aside> </aside>
<style lang="scss"> <style lang="scss">
h1 { p.close-icon {
font-family: 'Whiteney'; position: absolute;
padding: 4px;
span.float-right { right: 12px;
font-size: 30px; font-size: 30px;
line-height: 25px; line-height: 25px;
font-weight: bold;
float: right;
cursor: pointer; cursor: pointer;
} }
p.system-text {
border-radius: 8px;
margin-bottom: 10px;
padding:6px;
overflow-wrap: break-word;
max-width: 100%;
background: gray;
display: inline-block;
} }
aside.chatWindow { aside.chatWindow {
@ -78,16 +92,8 @@
border-bottom-right-radius: 16px; border-bottom-right-radius: 16px;
border-top-right-radius: 16px; border-top-right-radius: 16px;
h1 {
background-color: #5f5f5f;
border-radius: 8px;
padding: 2px;
}
.chatWindowTitle {
flex: 0 100px;
}
.messagesList { .messagesList {
margin-top: 35px;
overflow-y: auto; overflow-y: auto;
flex: auto; flex: auto;
@ -98,7 +104,7 @@
} }
.messageForm { .messageForm {
flex: 0 70px; flex: 0 70px;
padding-top: 20px; padding-top: 15px;
} }
} }
</style> </style>

View File

@ -7,13 +7,14 @@
export let message: ChatMessage; export let message: ChatMessage;
export let line: number; export let line: number;
const chatStyleLink = "color: white; text-decoration: underline;";
$: author = message.author as PlayerInterface; $: author = message.author as PlayerInterface;
$: targets = message.targets || []; $: targets = message.targets || [];
$: texts = message.text || []; $: texts = message.text || [];
function urlifyText(text: string): string { function urlifyText(text: string): string {
return HtmlUtils.urlify(text) return HtmlUtils.urlify(text, chatStyleLink);
} }
function renderDate(date: Date) { function renderDate(date: Date) {
return date.toLocaleTimeString(navigator.language, { return date.toLocaleTimeString(navigator.language, {
@ -29,7 +30,7 @@
<div class="chatElement"> <div class="chatElement">
<div class="messagePart"> <div class="messagePart">
{#if message.type === ChatMessageTypes.userIncoming} {#if message.type === ChatMessageTypes.userIncoming}
&gt;&gt; {#each targets as target, index}<ChatPlayerName player={target} line={line}></ChatPlayerName>{#if !isLastIteration(index)}, {/if}{/each} enter <span class="date">({renderDate(message.date)})</span> &gt;&gt; {#each targets as target, index}<ChatPlayerName player={target} line={line}></ChatPlayerName>{#if !isLastIteration(index)}, {/if}{/each} entered <span class="date">({renderDate(message.date)})</span>
{:else if message.type === ChatMessageTypes.userOutcoming} {:else if message.type === ChatMessageTypes.userOutcoming}
&lt;&lt; {#each targets as target, index}<ChatPlayerName player={target} line={line}></ChatPlayerName>{#if !isLastIteration(index)}, {/if}{/each} left <span class="date">({renderDate(message.date)})</span> &lt;&lt; {#each targets as target, index}<ChatPlayerName player={target} line={line}></ChatPlayerName>{#if !isLastIteration(index)}, {/if}{/each} left <span class="date">({renderDate(message.date)})</span>
{:else if message.type === ChatMessageTypes.me} {:else if message.type === ChatMessageTypes.me}
@ -48,7 +49,7 @@
<style lang="scss"> <style lang="scss">
h4, p { h4, p {
font-family: 'Whiteney'; font-family: Lato;
} }
div.chatElement { div.chatElement {
display: flex; display: flex;

View File

@ -1,6 +1,12 @@
<script lang="ts"> <script lang="ts">
import {chatMessagesStore, chatInputFocusStore} from "../../Stores/ChatStore"; import {chatMessagesStore, chatInputFocusStore} from "../../Stores/ChatStore";
export const handleForm = {
blur() {
inputElement.blur();
}
}
let inputElement: HTMLElement;
let newMessageText = ''; let newMessageText = '';
function onFocus() { function onFocus() {
@ -18,7 +24,7 @@
</script> </script>
<form on:submit|preventDefault={saveMessage}> <form on:submit|preventDefault={saveMessage}>
<input type="text" bind:value={newMessageText} placeholder="Enter your message..." on:focus={onFocus} on:blur={onBlur} > <input type="text" bind:value={newMessageText} placeholder="Enter your message..." on:focus={onFocus} on:blur={onBlur} bind:this={inputElement}>
<button type="submit"> <button type="submit">
<img src="/static/images/send.png" alt="Send" width="20"> <img src="/static/images/send.png" alt="Send" width="20">
</button> </button>
@ -32,26 +38,25 @@
input { input {
flex: auto; flex: auto;
background-color: #42464d; background-color: #254560;
color: white; color: white;
border-bottom-left-radius: 4px; border-bottom-left-radius: 4px;
border-top-left-radius: 4px; border-top-left-radius: 4px;
border: none; border: none;
font-size: 22px; font-size: 22px;
font-family: Whiteney; font-family: Lato;
padding-left: 6px;
min-width: 0; //Needed so that the input doesn't overflow the container in firefox min-width: 0; //Needed so that the input doesn't overflow the container in firefox
outline: none; outline: none;
} }
button { button {
background-color: #42464d; background-color: #254560;
color: white;
border-bottom-right-radius: 4px; border-bottom-right-radius: 4px;
border-top-right-radius: 4px; border-top-right-radius: 4px;
border: none; border: none;
border-left: solid black 1px; border-left: solid white 1px;
font-size: 16px; font-size: 16px;
font-family: Whiteney;
} }
} }
</style> </style>

View File

@ -1,11 +1,25 @@
<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 { consoleGlobalMessageManagerVisibleStore } from "../../Stores/ConsoleGlobalMessageManagerStore";
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;
@ -16,28 +30,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">
<h2> Global Message </h2>
<div class="global-message">
<div class="menu">
<button class="nes-btn {inputSendTextActive ? 'is-disabled' : ''}" on:click|preventDefault={inputSendTextActivate}>Message</button>
<button class="nes-btn {uploadMusicActive ? 'is-disabled' : ''}" on:click|preventDefault={inputUploadMusicActivate}>Audio</button>
</div> </div>
<div class="main-input"> <div class="main-console-global-message nes-container is-rounded" transition:fly="{{ y: -1000, duration: 500 }}">
<div class="title-console-global-message">
<h2>Global Message</h2>
<button type="button" class="nes-btn is-error" on:click|preventDefault={closeConsoleGlobalMessage}><i class="nes-icon close is-small"></i></button>
</div>
<div class="content-console-global-message">
{#if inputSendTextActive} {#if inputSendTextActive}
<InputTextGlobalMessage gameManager={gameManager}></InputTextGlobalMessage> <InputTextGlobalMessage gameManager={gameManager} bind:handleSending={handleSendText}/>
{/if} {/if}
{#if uploadMusicActive} {#if uploadMusicActive}
<UploadAudioGlobalMessage gameManager={gameManager}></UploadAudioGlobalMessage> <UploadAudioGlobalMessage gameManager={gameManager} bind:handleSending={handleSendAudio}/>
{/if} {/if}
</div> </div>
<div class="footer-console-global-message">
<label>
<input type="checkbox" class="nes-checkbox is-dark nes-pointer" bind:checked={broadcastToWorld}>
<span>Broadcast to all rooms of the world</span>
</label>
<button class="nes-btn is-primary" on:click|preventDefault={send}>Send</button>
</div> </div>
</div> </div>
</div> </div>
<style lang="scss">
.nes-container {
padding: 0 5px;
}
div.console-global-message {
top: 20vh;
width: 50vw;
height: 50vh;
position: relative;
display: flex;
flex-direction: row;
margin-left: auto;
margin-right: auto;
padding: 0;
pointer-events: auto;
div.menu-console-global-message {
flex: 1 1 auto;
max-width: 180px;
text-align: center;
background-color: #333333;
button {
width: 136px;
margin-bottom: 10px;
}
}
div.main-console-global-message {
flex: 1 1 auto;
display: flex;
flex-direction: column;
background-color: #333333;
div.title-console-global-message {
flex: 0 0 auto;
height: 50px;
margin-bottom: 10px;
text-align: center;
color: whitesmoke;
.nes-btn {
position: absolute;
top: 0;
right: 0;
}
}
div.content-console-global-message {
flex: 1 1 auto;
max-height: calc(100% - 120px);
}
div.footer-console-global-message {
height: 50px;
margin-top: 10px;
text-align: center;
label {
margin: 0;
position: absolute;
left: 0;
max-width: 30%;
}
}
}
}
</style>

View File

@ -1,13 +1,13 @@
<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 {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";
//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'],
@ -32,12 +32,31 @@
export let gameManager: GameManager; export let gameManager: GameManager;
let gameScene = gameManager.getCurrentGameScene(); const gameScene = gameManager.getCurrentGameScene();
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 () => {
@ -45,49 +64,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) {
if (range === null && oldRange !== null) {
consoleGlobalMessageManagerFocusStore.set(false);
} else if (range !== null && oldRange === null)
consoleGlobalMessageManagerFocusStore.set(true); consoleGlobalMessageManagerFocusStore.set(true);
}); });
});
onDestroy(() => {
consoleGlobalMessageManagerFocusStore.set(false);
})
function disableConsole() { function disableConsole() {
consoleGlobalMessageManagerVisibleStore.set(false); consoleGlobalMessageManagerVisibleStore.set(false);
consoleGlobalMessageManagerFocusStore.set(false); consoleGlobalMessageManagerFocusStore.set(false);
} }
function SendTextMessage() {
if (gameScene == undefined) {
return;
}
const text = quill.getText(0, quill.getLength());
const GlobalMessage: PlayGlobalMessageInterface = {
id: "1", // FIXME: use another ID?
message: text,
type: MESSAGE_TYPE
};
quill.deleteText(0, quill.getLength());
gameScene.connection?.emitGlobalMessage(GlobalMessage);
disableConsole();
}
</script> </script>
<section class="section-input-send-text"> <section class="section-input-send-text">
<div class="input-send-text" bind:this={INPUT_CONSOLE_MESSAGE}></div> <div class="input-send-text" bind:this={INPUT_CONSOLE_MESSAGE}></div>
<div class="btn-action">
<button class="nes-btn is-primary" on:click|preventDefault={SendTextMessage}>Send</button>
</div>
</section> </section>

View File

@ -1,10 +1,10 @@
<script lang="ts"> <script lang="ts">
import {HtmlUtils} from "../../WebRtc/HtmlUtils"; import { HtmlUtils } from "../../WebRtc/HtmlUtils";
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 type {PlayGlobalMessageInterface} from "../../Connexion/ConnexionModels";
interface EventTargetFiles extends EventTarget { interface EventTargetFiles extends EventTarget {
files: Array<File>; files: Array<File>;
@ -13,22 +13,22 @@
export let gameManager: GameManager; export let gameManager: GameManager;
let gameScene = gameManager.getCurrentGameScene(); let gameScene = gameManager.getCurrentGameScene();
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 function SendAudioMessage() { async sendAudioMessage(broadcast: boolean) {
if (gameScene == undefined) { if (gameScene == undefined) {
return; return;
} }
const inputAudio = HtmlUtils.getElementByIdOrFail<HTMLInputElement>("input-send-audio"); const inputAudio = HtmlUtils.getElementByIdOrFail<HTMLInputElement>("input-send-audio");
const selectedFile = inputAudio.files ? inputAudio.files[0] : null; const selectedFile = inputAudio.files ? inputAudio.files[0] : null;
if (!selectedFile) { if (!selectedFile) {
errorfile = true; errorFile = true;
throw 'no file selected'; throw 'no file selected';
} }
@ -36,15 +36,16 @@
fd.append('file', selectedFile); fd.append('file', selectedFile);
const res = await gameScene.connection?.uploadAudio(fd); const res = await gameScene.connection?.uploadAudio(fd);
const GlobalMessage: PlayGlobalMessageInterface = { const audioGlobalMessage: PlayGlobalMessageInterface = {
id: (res as { id: string }).id, content: (res as { path: string }).path,
message: (res as { path: string }).path, type: AUDIO_TYPE,
type: AUDIO_TYPE broadcastToWorld: broadcast
} }
inputAudio.value = ''; inputAudio.value = '';
gameScene.connection?.emitGlobalMessage(GlobalMessage); gameScene.connection?.emitGlobalMessage(audioGlobalMessage);
disableConsole(); disableConsole();
} }
}
function inputAudioFile(event: Event) { function inputAudioFile(event: Event) {
const eventTarget : EventTargetFiles = (event.target as EventTargetFiles); const eventTarget : EventTargetFiles = (event.target as EventTargetFiles);
@ -57,9 +58,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) {
@ -82,46 +83,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;
img {
flex: 1 1 auto;
max-height: 80%;
margin-bottom: 20px;
} }
.section-input-send-audio #input-send-audio{ p {
flex: 1 1 auto;
margin-bottom: 5px;
color: whitesmoke;
font-size: 1rem;
&.err {
color: #ce372b;
}
}
input {
display: none; display: none;
} }
.section-input-send-audio div.input-send-audio label{
color: white;
}
.section-input-send-audio div.input-send-audio p.err {
color: #ce372b;
text-align: center;
}
.section-input-send-audio div.input-send-audio img{
height: 150px;
cursor: url('../../../style/images/cursor_pointer.png'), pointer;
} }
</style> </style>

View File

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

View File

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

View File

@ -37,9 +37,7 @@
<img alt="Report this user" src={reportImg}> <img alt="Report this user" src={reportImg}>
<span>Report/Block</span> <span>Report/Block</span>
</button> </button>
{#if $streamStore }
<video use:srcObject={$streamStore} autoplay playsinline on:click={() => videoFocusStore.toggleFocus(peer)}></video> <video use:srcObject={$streamStore} autoplay playsinline on:click={() => videoFocusStore.toggleFocus(peer)}></video>
{/if}
<img src={blockSignImg} class="block-logo" alt="Block" /> <img src={blockSignImg} class="block-logo" alt="Block" />
{#if $constraintStore && $constraintStore.audio !== false} {#if $constraintStore && $constraintStore.audio !== false}
<SoundMeterWidget stream={$streamStore}></SoundMeterWidget> <SoundMeterWidget stream={$streamStore}></SoundMeterWidget>

View File

@ -1,3 +1,6 @@
import type { UserSimplePeerInterface } from "../../WebRtc/SimplePeer";
import { STUN_SERVER, TURN_PASSWORD, TURN_SERVER, TURN_USER } from "../../Enum/EnvironmentVariable";
export function getColorByString(str: string): string | null { export function getColorByString(str: string): string | null {
let hash = 0; let hash = 0;
if (str.length === 0) { if (str.length === 0) {
@ -15,7 +18,7 @@ export function getColorByString(str: string): string | null {
return color; return color;
} }
export function srcObject(node: HTMLVideoElement, stream: MediaStream) { export function srcObject(node: HTMLVideoElement, stream: MediaStream | null) {
node.srcObject = stream; node.srcObject = stream;
return { return {
update(newStream: MediaStream) { update(newStream: MediaStream) {
@ -25,3 +28,19 @@ export function srcObject(node: HTMLVideoElement, stream: MediaStream) {
}, },
}; };
} }
export function getIceServersConfig(user: UserSimplePeerInterface): RTCIceServer[] {
const config: RTCIceServer[] = [
{
urls: STUN_SERVER.split(","),
},
];
if (TURN_SERVER !== "") {
config.push({
urls: TURN_SERVER.split(","),
username: user.webRtcUser || TURN_USER,
credential: user.webRtcPassword || TURN_PASSWORD,
});
}
return config;
}

View File

@ -45,8 +45,9 @@
.visitCard { .visitCard {
pointer-events: all; pointer-events: all;
margin-left: auto; position: absolute;
margin-right: auto; left: 50%;
transform: translate(-50%, 0);
margin-top: 200px; margin-top: 200px;
max-width: 80vw; max-width: 80vw;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -3,91 +3,103 @@ import { PUSHER_URL } from "../Enum/EnvironmentVariable";
import type { CharacterTexture } from "./LocalUser"; import type { CharacterTexture } from "./LocalUser";
export class MapDetail { export class MapDetail {
constructor(public readonly mapUrl: string, public readonly textures: CharacterTexture[] | undefined) { constructor(public readonly mapUrl: string, public readonly textures: CharacterTexture[] | undefined) {}
} }
export interface RoomRedirect {
redirectUrl: string;
} }
export class Room { export class Room {
public readonly id: string; public readonly id: string;
public readonly isPublic: boolean; public readonly isPublic: boolean;
private mapUrl: string | undefined; private _mapUrl: string | undefined;
private textures: CharacterTexture[] | undefined; private _textures: CharacterTexture[] | undefined;
private instance: string | undefined; private instance: string | undefined;
private _search: URLSearchParams; private readonly _search: URLSearchParams;
constructor(id: string) { private constructor(private roomUrl: URL) {
const url = new URL(id, 'https://example.com'); this.id = roomUrl.pathname;
this.id = url.pathname; if (this.id.startsWith("/")) {
if (this.id.startsWith('/')) {
this.id = this.id.substr(1); this.id = this.id.substr(1);
} }
if (this.id.startsWith('_/')) { if (this.id.startsWith("_/")) {
this.isPublic = true; this.isPublic = true;
} else if (this.id.startsWith('@/')) { } else if (this.id.startsWith("@/")) {
this.isPublic = false; this.isPublic = false;
} else { } else {
throw new Error('Invalid room ID'); throw new Error("Invalid room ID");
} }
this._search = new URLSearchParams(url.search); this._search = new URLSearchParams(roomUrl.search);
} }
public static getIdFromIdentifier(identifier: string, baseUrl: string, currentInstance: string): { roomId: string, hash: string | null } { /**
let roomId = ''; * Creates a "Room" object representing the room.
let hash = null; * This method will follow room redirects if necessary, so the instance returned is a "real" room.
if (!identifier.startsWith('/_/') && !identifier.startsWith('/@/')) { //relative file link */
//Relative identifier can be deep enough to rewrite the base domain, so we cannot use the variable 'baseUrl' as the actual base url for the URL objects. public static async createRoom(roomUrl: URL): Promise<Room> {
//We instead use 'workadventure' as a dummy base value. let redirectCount = 0;
const baseUrlObject = new URL(baseUrl); while (redirectCount < 32) {
const absoluteExitSceneUrl = new URL(identifier, 'http://workadventure/_/' + currentInstance + '/' + baseUrlObject.hostname + baseUrlObject.pathname); const room = new Room(roomUrl);
roomId = absoluteExitSceneUrl.pathname; //in case of a relative url, we need to create a public roomId const result = await room.getMapDetail();
roomId = roomId.substring(1); //remove the leading slash if (result instanceof MapDetail) {
hash = absoluteExitSceneUrl.hash; return room;
hash = hash.substring(1); //remove the leading diese
if (!hash.length) {
hash = null
} }
} else { //absolute room Id redirectCount++;
const parts = identifier.split('#'); roomUrl = new URL(result.redirectUrl);
roomId = parts[0];
roomId = roomId.substring(1); //remove the leading slash
if (parts.length > 1) {
hash = parts[1]
} }
} throw new Error("Room resolving seems stuck in a redirect loop after 32 redirect attempts");
return { roomId, hash }
} }
public async getMapDetail(): Promise<MapDetail> { public static getRoomPathFromExitUrl(exitUrl: string, currentRoomUrl: string): URL {
return new Promise<MapDetail>((resolve, reject) => { const url = new URL(exitUrl, currentRoomUrl);
if (this.mapUrl !== undefined && this.textures != undefined) { return url;
resolve(new MapDetail(this.mapUrl, this.textures));
return;
} }
if (this.isPublic) { /**
const match = /_\/[^/]+\/(.+)/.exec(this.id); * @deprecated USage of exitSceneUrl is deprecated and therefore, this method is deprecated too.
if (!match) throw new Error('Could not extract url from "' + this.id + '"'); */
this.mapUrl = window.location.protocol + '//' + match[1]; public static getRoomPathFromExitSceneUrl(
resolve(new MapDetail(this.mapUrl, this.textures)); exitSceneUrl: string,
return; currentRoomUrl: string,
} else { currentMapUrl: string
// We have a private ID, we need to query the map URL from the server. ): URL {
const urlParts = this.parsePrivateUrl(this.id); const absoluteExitSceneUrl = new URL(exitSceneUrl, currentMapUrl);
const baseUrl = new URL(currentRoomUrl);
Axios.get(`${PUSHER_URL}/map`, { const currentRoom = new Room(baseUrl);
params: urlParts let instance: string = "global";
}).then(({ data }) => { if (currentRoom.isPublic) {
console.log('Map ', this.id, ' resolves to URL ', data.mapUrl); instance = currentRoom.instance as string;
resolve(data); }
return;
}).catch((reason) => { baseUrl.pathname = "/_/" + instance + "/" + absoluteExitSceneUrl.host + absoluteExitSceneUrl.pathname;
reject(reason); if (absoluteExitSceneUrl.hash) {
baseUrl.hash = absoluteExitSceneUrl.hash;
}
return baseUrl;
}
private async getMapDetail(): Promise<MapDetail | RoomRedirect> {
const result = await Axios.get(`${PUSHER_URL}/map`, {
params: {
playUri: this.roomUrl.toString(),
},
}); });
const data = result.data;
if (data.redirectUrl) {
return {
redirectUrl: data.redirectUrl as string,
};
} }
}); console.log("Map ", this.id, " resolves to URL ", data.mapUrl);
this._mapUrl = data.mapUrl;
this._textures = data.textures;
return new MapDetail(data.mapUrl, data.textures);
} }
/** /**
@ -108,21 +120,24 @@ export class Room {
} else { } else {
const match = /@\/([^/]+)\/([^/]+)\/.+/.exec(this.id); const match = /@\/([^/]+)\/([^/]+)\/.+/.exec(this.id);
if (!match) throw new Error('Could not extract instance from "' + this.id + '"'); if (!match) throw new Error('Could not extract instance from "' + this.id + '"');
this.instance = match[1] + '/' + match[2]; this.instance = match[1] + "/" + match[2];
return this.instance; return this.instance;
} }
} }
private parsePrivateUrl(url: string): { organizationSlug: string, worldSlug: string, roomSlug?: string } { /**
* @deprecated
*/
private parsePrivateUrl(url: string): { organizationSlug: string; worldSlug: string; roomSlug?: string } {
const regex = /@\/([^/]+)\/([^/]+)(?:\/([^/]*))?/gm; const regex = /@\/([^/]+)\/([^/]+)(?:\/([^/]*))?/gm;
const match = regex.exec(url); const match = regex.exec(url);
if (!match) { if (!match) {
throw new Error('Invalid URL ' + url); throw new Error("Invalid URL " + url);
} }
const results: { organizationSlug: string, worldSlug: string, roomSlug?: string } = { const results: { organizationSlug: string; worldSlug: string; roomSlug?: string } = {
organizationSlug: match[1], organizationSlug: match[1],
worldSlug: match[2], worldSlug: match[2],
} };
if (match[3] !== undefined) { if (match[3] !== undefined) {
results.roomSlug = match[3]; results.roomSlug = match[3];
} }
@ -130,8 +145,8 @@ export class Room {
} }
public isDisconnected(): boolean { public isDisconnected(): boolean {
const alone = this._search.get('alone'); const alone = this._search.get("alone");
if (alone && alone !== '0' && alone.toLowerCase() !== 'false') { if (alone && alone !== "0" && alone.toLowerCase() !== "false") {
return true; return true;
} }
return false; return false;
@ -140,4 +155,33 @@ export class Room {
public get search(): URLSearchParams { public get search(): URLSearchParams {
return this._search; return this._search;
} }
/**
* 2 rooms are equal if they share the same path (but not necessarily the same hash)
* @param room
*/
public isEqual(room: Room): boolean {
return room.key === this.key;
}
/**
* A key representing this room
*/
public get key(): string {
const newUrl = new URL(this.roomUrl.toString());
newUrl.search = "";
newUrl.hash = "";
return newUrl.toString();
}
get textures(): CharacterTexture[] | undefined {
return this._textures;
}
get mapUrl(): string {
if (!this._mapUrl) {
throw new Error("Map URL not fetched yet");
}
return this._mapUrl;
}
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -32,7 +32,7 @@ export class GameManager {
public async init(scenePlugin: Phaser.Scenes.ScenePlugin): Promise<string> { public async init(scenePlugin: Phaser.Scenes.ScenePlugin): Promise<string> {
this.scenePlugin = scenePlugin; this.scenePlugin = scenePlugin;
this.startRoom = await connectionManager.initGameConnexion(); this.startRoom = await connectionManager.initGameConnexion();
await this.loadMap(this.startRoom); this.loadMap(this.startRoom);
if (!this.playerName) { if (!this.playerName) {
return LoginSceneName; return LoginSceneName;
@ -72,19 +72,19 @@ export class GameManager {
return this.companion; return this.companion;
} }
public async loadMap(room: Room): Promise<void> { public loadMap(room: Room) {
const roomID = room.id; const roomID = room.key;
const mapDetail = await room.getMapDetail();
const gameIndex = this.scenePlugin.getIndex(roomID); const gameIndex = this.scenePlugin.getIndex(roomID);
if (gameIndex === -1) { if (gameIndex === -1) {
const game: Phaser.Scene = new GameScene(room, mapDetail.mapUrl); const game: Phaser.Scene = new GameScene(room, room.mapUrl);
this.scenePlugin.add(roomID, game, false); this.scenePlugin.add(roomID, game, false);
} }
} }
public goToStartingMap(): void { public goToStartingMap(): void {
this.scenePlugin.start(this.currentGameSceneName || this.startRoom.id); console.log("starting " + (this.currentGameSceneName || this.startRoom.key));
this.scenePlugin.start(this.currentGameSceneName || this.startRoom.key);
this.scenePlugin.launch(MenuSceneName); this.scenePlugin.launch(MenuSceneName);
if ( if (

View File

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

View File

@ -1,5 +1,4 @@
import type { Subscription } from "rxjs"; import type { Subscription } from "rxjs";
import { GlobalMessageManager } from "../../Administration/GlobalMessageManager";
import { userMessageManager } from "../../Administration/UserMessageManager"; import { userMessageManager } from "../../Administration/UserMessageManager";
import { iframeListener } from "../../Api/IframeListener"; import { iframeListener } from "../../Api/IframeListener";
import { connectionManager } from "../../Connexion/ConnectionManager"; import { connectionManager } from "../../Connexion/ConnectionManager";
@ -47,13 +46,7 @@ import { RemotePlayer } from "../Entity/RemotePlayer";
import type { ActionableItem } from "../Items/ActionableItem"; import type { ActionableItem } from "../Items/ActionableItem";
import type { ItemFactoryInterface } from "../Items/ItemFactoryInterface"; import type { ItemFactoryInterface } from "../Items/ItemFactoryInterface";
import { SelectCharacterScene, SelectCharacterSceneName } from "../Login/SelectCharacterScene"; import { SelectCharacterScene, SelectCharacterSceneName } from "../Login/SelectCharacterScene";
import type { import type { ITiledMap, ITiledMapLayer, ITiledMapProperty, ITiledMapObject, ITiledTileSet } from "../Map/ITiledMap";
ITiledMap,
ITiledMapLayer,
ITiledMapLayerProperty,
ITiledMapObject,
ITiledTileSet,
} from "../Map/ITiledMap";
import { MenuScene, MenuSceneName } from "../Menu/MenuScene"; import { MenuScene, MenuSceneName } from "../Menu/MenuScene";
import { PlayerAnimationDirections } from "../Player/Animation"; import { PlayerAnimationDirections } from "../Player/Animation";
import { hasMovedEventName, Player, requestEmoteEventName } from "../Player/Player"; import { hasMovedEventName, Player, requestEmoteEventName } from "../Player/Player";
@ -81,8 +74,6 @@ import { joystickBaseImg, joystickBaseKey, joystickThumbImg, joystickThumbKey }
import { waScaleManager } from "../Services/WaScaleManager"; import { waScaleManager } from "../Services/WaScaleManager";
import { EmoteManager } from "./EmoteManager"; import { EmoteManager } from "./EmoteManager";
import EVENT_TYPE = Phaser.Scenes.Events; import EVENT_TYPE = Phaser.Scenes.Events;
import RenderTexture = Phaser.GameObjects.RenderTexture;
import Tilemap = Phaser.Tilemaps.Tilemap;
import type { HasPlayerMovedEvent } from "../../Api/Events/HasPlayerMovedEvent"; import type { HasPlayerMovedEvent } from "../../Api/Events/HasPlayerMovedEvent";
import AnimatedTiles from "phaser-animated-tiles"; import AnimatedTiles from "phaser-animated-tiles";
@ -91,8 +82,11 @@ import { soundManager } from "./SoundManager";
import { peerStore, screenSharingPeerStore } from "../../Stores/PeerStore"; import { peerStore, screenSharingPeerStore } from "../../Stores/PeerStore";
import { videoFocusStore } from "../../Stores/VideoFocusStore"; import { videoFocusStore } from "../../Stores/VideoFocusStore";
import { biggestAvailableAreaStore } from "../../Stores/BiggestAvailableAreaStore"; import { biggestAvailableAreaStore } from "../../Stores/BiggestAvailableAreaStore";
import { SharedVariablesManager } from "./SharedVariablesManager";
import { playersStore } from "../../Stores/PlayersStore"; import { playersStore } from "../../Stores/PlayersStore";
import { chatVisibilityStore } from "../../Stores/ChatStore"; import { chatVisibilityStore } from "../../Stores/ChatStore";
import Tileset = Phaser.Tilemaps.Tileset;
import { userIsAdminStore } from "../../Stores/GameStore";
export interface GameSceneInitInterface { export interface GameSceneInitInterface {
initPosition: PointInterface | null; initPosition: PointInterface | null;
@ -160,7 +154,6 @@ export class GameScene extends DirtyScene {
private playersPositionInterpolator = new PlayersPositionInterpolator(); private playersPositionInterpolator = new PlayersPositionInterpolator();
public connection: RoomConnection | undefined; public connection: RoomConnection | undefined;
private simplePeer!: SimplePeer; private simplePeer!: SimplePeer;
private GlobalMessageManager!: GlobalMessageManager;
private connectionAnswerPromise: Promise<RoomJoinedMessageInterface>; private connectionAnswerPromise: Promise<RoomJoinedMessageInterface>;
private connectionAnswerPromiseResolve!: ( private connectionAnswerPromiseResolve!: (
value: RoomJoinedMessageInterface | PromiseLike<RoomJoinedMessageInterface> value: RoomJoinedMessageInterface | PromiseLike<RoomJoinedMessageInterface>
@ -173,7 +166,7 @@ export class GameScene extends DirtyScene {
private chatVisibilityUnsubscribe!: () => void; private chatVisibilityUnsubscribe!: () => void;
private biggestAvailableAreaStoreUnsubscribe!: () => void; private biggestAvailableAreaStoreUnsubscribe!: () => void;
MapUrlFile: string; MapUrlFile: string;
RoomId: string; roomUrl: string;
instance: string; instance: string;
currentTick!: number; currentTick!: number;
@ -199,21 +192,22 @@ export class GameScene extends DirtyScene {
private popUpElements: Map<number, DOMElement> = new Map<number, Phaser.GameObjects.DOMElement>(); private popUpElements: Map<number, DOMElement> = new Map<number, Phaser.GameObjects.DOMElement>();
private originalMapUrl: string | undefined; private originalMapUrl: string | undefined;
private pinchManager: PinchManager | undefined; private pinchManager: PinchManager | undefined;
private mapTransitioning: boolean = false; //used to prevent transitions happenning at the same time. private mapTransitioning: boolean = false; //used to prevent transitions happening at the same time.
private emoteManager!: EmoteManager; private emoteManager!: EmoteManager;
private preloading: boolean = true; private preloading: boolean = true;
startPositionCalculator!: StartPositionCalculator; private startPositionCalculator!: StartPositionCalculator;
private sharedVariablesManager!: SharedVariablesManager;
constructor(private room: Room, MapUrlFile: string, customKey?: string | undefined) { constructor(private room: Room, MapUrlFile: string, customKey?: string | undefined) {
super({ super({
key: customKey ?? room.id, key: customKey ?? room.key,
}); });
this.Terrains = []; this.Terrains = [];
this.groups = new Map<number, Sprite>(); this.groups = new Map<number, Sprite>();
this.instance = room.getInstance(); this.instance = room.getInstance();
this.MapUrlFile = MapUrlFile; this.MapUrlFile = MapUrlFile;
this.RoomId = room.id; this.roomUrl = room.key;
this.createPromise = new Promise<void>((resolve, reject): void => { this.createPromise = new Promise<void>((resolve, reject): void => {
this.createPromiseResolve = resolve; this.createPromiseResolve = resolve;
@ -225,6 +219,9 @@ export class GameScene extends DirtyScene {
//hook preload scene //hook preload scene
preload(): void { preload(): void {
//initialize frame event of scripting API
this.listenToIframeEvents();
const localUser = localUserStore.getLocalUser(); const localUser = localUserStore.getLocalUser();
const textures = localUser?.textures; const textures = localUser?.textures;
if (textures) { if (textures) {
@ -440,7 +437,7 @@ export class GameScene extends DirtyScene {
this.characterLayers = gameManager.getCharacterLayers(); this.characterLayers = gameManager.getCharacterLayers();
this.companion = gameManager.getCompanion(); this.companion = gameManager.getCompanion();
//initalise map //initialise map
this.Map = this.add.tilemap(this.MapUrlFile); this.Map = this.add.tilemap(this.MapUrlFile);
const mapDirUrl = this.MapUrlFile.substr(0, this.MapUrlFile.lastIndexOf("/")); const mapDirUrl = this.MapUrlFile.substr(0, this.MapUrlFile.lastIndexOf("/"));
this.mapFile.tilesets.forEach((tileset: ITiledTileSet) => { this.mapFile.tilesets.forEach((tileset: ITiledTileSet) => {
@ -465,11 +462,13 @@ export class GameScene extends DirtyScene {
if (layer.type === "tilelayer") { if (layer.type === "tilelayer") {
const exitSceneUrl = this.getExitSceneUrl(layer); const exitSceneUrl = this.getExitSceneUrl(layer);
if (exitSceneUrl !== undefined) { if (exitSceneUrl !== undefined) {
this.loadNextGame(exitSceneUrl); this.loadNextGame(
Room.getRoomPathFromExitSceneUrl(exitSceneUrl, window.location.toString(), this.MapUrlFile)
);
} }
const exitUrl = this.getExitUrl(layer); const exitUrl = this.getExitUrl(layer);
if (exitUrl !== undefined) { if (exitUrl !== undefined) {
this.loadNextGame(exitUrl); this.loadNextGameFromExitUrl(exitUrl);
} }
} }
if (layer.type === "objectgroup") { if (layer.type === "objectgroup") {
@ -482,7 +481,7 @@ export class GameScene extends DirtyScene {
} }
this.gameMap.exitUrls.forEach((exitUrl) => { this.gameMap.exitUrls.forEach((exitUrl) => {
this.loadNextGame(exitUrl); this.loadNextGameFromExitUrl(exitUrl);
}); });
this.startPositionCalculator = new StartPositionCalculator( this.startPositionCalculator = new StartPositionCalculator(
@ -551,7 +550,6 @@ export class GameScene extends DirtyScene {
); );
this.triggerOnMapLayerPropertyChange(); this.triggerOnMapLayerPropertyChange();
this.listenToIframeEvents();
if (!this.room.isDisconnected()) { if (!this.room.isDisconnected()) {
this.connect(); this.connect();
@ -587,7 +585,7 @@ export class GameScene extends DirtyScene {
connectionManager connectionManager
.connectToRoomSocket( .connectToRoomSocket(
this.RoomId, this.roomUrl,
this.playerName, this.playerName,
this.characterLayers, this.characterLayers,
{ {
@ -606,6 +604,8 @@ export class GameScene extends DirtyScene {
playersStore.connectToRoomConnection(this.connection); playersStore.connectToRoomConnection(this.connection);
userIsAdminStore.set(this.connection.hasTag("admin"));
this.connection.onUserJoins((message: MessageUserJoined) => { this.connection.onUserJoins((message: MessageUserJoined) => {
const userMessage: AddPlayerInterface = { const userMessage: AddPlayerInterface = {
userId: message.userId, userId: message.userId,
@ -692,7 +692,6 @@ export class GameScene extends DirtyScene {
peerStore.connectToSimplePeer(this.simplePeer); peerStore.connectToSimplePeer(this.simplePeer);
screenSharingPeerStore.connectToSimplePeer(this.simplePeer); screenSharingPeerStore.connectToSimplePeer(this.simplePeer);
videoFocusStore.connectToSimplePeer(this.simplePeer); videoFocusStore.connectToSimplePeer(this.simplePeer);
this.GlobalMessageManager = new GlobalMessageManager(this.connection);
userMessageManager.setReceiveBanListener(this.bannedUser.bind(this)); userMessageManager.setReceiveBanListener(this.bannedUser.bind(this));
const self = this; const self = this;
@ -716,6 +715,13 @@ export class GameScene extends DirtyScene {
this.gameMap.setPosition(event.x, event.y); this.gameMap.setPosition(event.x, event.y);
}); });
// Set up variables manager
this.sharedVariablesManager = new SharedVariablesManager(
this.connection,
this.gameMap,
onConnect.room.variables
);
//this.initUsersPosition(roomJoinedMessage.users); //this.initUsersPosition(roomJoinedMessage.users);
this.connectionAnswerPromiseResolve(onConnect.room); this.connectionAnswerPromiseResolve(onConnect.room);
// Analyze tags to find if we are admin. If yes, show console. // Analyze tags to find if we are admin. If yes, show console.
@ -775,10 +781,13 @@ export class GameScene extends DirtyScene {
private triggerOnMapLayerPropertyChange() { private triggerOnMapLayerPropertyChange() {
this.gameMap.onPropertyChange("exitSceneUrl", (newValue, oldValue) => { this.gameMap.onPropertyChange("exitSceneUrl", (newValue, oldValue) => {
if (newValue) this.onMapExit(newValue as string); if (newValue)
this.onMapExit(
Room.getRoomPathFromExitSceneUrl(newValue as string, window.location.toString(), this.MapUrlFile)
);
}); });
this.gameMap.onPropertyChange("exitUrl", (newValue, oldValue) => { this.gameMap.onPropertyChange("exitUrl", (newValue, oldValue) => {
if (newValue) this.onMapExit(newValue as string); if (newValue) this.onMapExit(Room.getRoomPathFromExitUrl(newValue as string, window.location.toString()));
}); });
this.gameMap.onPropertyChange("openWebsite", (newValue, oldValue, allProps) => { this.gameMap.onPropertyChange("openWebsite", (newValue, oldValue, allProps) => {
if (newValue === undefined) { if (newValue === undefined) {
@ -1003,9 +1012,9 @@ ${escapedMessage}
); );
this.iframeSubscriptionList.push( this.iframeSubscriptionList.push(
iframeListener.loadPageStream.subscribe((url: string) => { iframeListener.loadPageStream.subscribe((url: string) => {
this.loadNextGame(url).then(() => { this.loadNextGameFromExitUrl(url).then(() => {
this.events.once(EVENT_TYPE.POST_UPDATE, () => { this.events.once(EVENT_TYPE.POST_UPDATE, () => {
this.onMapExit(url); this.onMapExit(Room.getRoomPathFromExitUrl(url, window.location.toString()));
}); });
}); });
}) })
@ -1048,20 +1057,24 @@ ${escapedMessage}
}) })
); );
this.iframeSubscriptionList.push( iframeListener.registerAnswerer("getMapData", () => {
iframeListener.dataLayerChangeStream.subscribe(() => { return {
iframeListener.sendDataLayerEvent({ data: this.gameMap.getMap() }); data: this.gameMap.getMap(),
}) };
); });
iframeListener.registerAnswerer("getState", () => { iframeListener.registerAnswerer("getState", async () => {
// The sharedVariablesManager is not instantiated before the connection is established. So we need to wait
// for the connection to send back the answer.
await this.connectionAnswerPromise;
return { return {
mapUrl: this.MapUrlFile, mapUrl: this.MapUrlFile,
startLayerName: this.startPositionCalculator.startLayerName, startLayerName: this.startPositionCalculator.startLayerName,
uuid: localUserStore.getLocalUser()?.uuid, uuid: localUserStore.getLocalUser()?.uuid,
nickname: localUserStore.getName(), nickname: this.playerName,
roomId: this.RoomId, roomId: this.roomUrl,
tags: this.connection ? this.connection.getAllTags() : [], tags: this.connection ? this.connection.getAllTags() : [],
variables: this.sharedVariablesManager.variables,
}; };
}); });
this.iframeSubscriptionList.push( this.iframeSubscriptionList.push(
@ -1069,8 +1082,74 @@ ${escapedMessage}
for (const eventTile of eventTiles) { for (const eventTile of eventTiles) {
this.gameMap.putTile(eventTile.tile, eventTile.x, eventTile.y, eventTile.layer); this.gameMap.putTile(eventTile.tile, eventTile.x, eventTile.y, eventTile.layer);
} }
this.markDirty();
}) })
); );
iframeListener.registerAnswerer("loadTileset", (eventTileset) => {
return this.connectionAnswerPromise.then(() => {
const jsonTilesetDir = eventTileset.url.substr(0, eventTileset.url.lastIndexOf("/"));
//Initialise the firstgid to 1 because if there is no tileset in the tilemap, the firstgid will be 1
let newFirstgid = 1;
const lastTileset = this.mapFile.tilesets[this.mapFile.tilesets.length - 1];
if (lastTileset) {
//If there is at least one tileset in the tilemap then calculate the firstgid of the new tileset
newFirstgid = lastTileset.firstgid + lastTileset.tilecount;
}
return new Promise((resolve, reject) => {
this.load.on("filecomplete-json-" + eventTileset.url, () => {
let jsonTileset = this.cache.json.get(eventTileset.url);
const imageUrl = jsonTilesetDir + "/" + jsonTileset.image;
this.load.image(imageUrl, imageUrl);
this.load.on("filecomplete-image-" + imageUrl, () => {
//Add the firstgid of the tileset to the json file
jsonTileset = { ...jsonTileset, firstgid: newFirstgid };
this.mapFile.tilesets.push(jsonTileset);
this.Map.tilesets.push(
new Tileset(
jsonTileset.name,
jsonTileset.firstgid,
jsonTileset.tileWidth,
jsonTileset.tileHeight,
jsonTileset.margin,
jsonTileset.spacing,
jsonTileset.tiles
)
);
this.Terrains.push(
this.Map.addTilesetImage(
jsonTileset.name,
imageUrl,
jsonTileset.tilewidth,
jsonTileset.tileheight,
jsonTileset.margin,
jsonTileset.spacing
)
);
//destroy the tilemapayer because they are unique and we need to reuse their key and layerdData
for (const layer of this.Map.layers) {
layer.tilemapLayer.destroy(false);
}
//Create a new GameMap with the changed file
this.gameMap = new GameMap(this.mapFile, this.Map, this.Terrains);
//Destroy the colliders of the old tilemapLayer
this.physics.add.world.colliders.destroy();
//Create new colliders with the new GameMap
this.createCollisionWithPlayer();
//Create new trigger with the new GameMap
this.triggerOnMapLayerPropertyChange();
resolve(newFirstgid);
});
});
this.load.on("loaderror", () => {
console.error("Error while loading " + eventTileset.url + ".");
reject(-1);
});
this.load.json(eventTileset.url, eventTileset.url);
this.load.start();
});
});
});
} }
private setPropertyLayer( private setPropertyLayer(
@ -1084,7 +1163,7 @@ ${escapedMessage}
return; return;
} }
if (propertyName === "exitUrl" && typeof propertyValue === "string") { if (propertyName === "exitUrl" && typeof propertyValue === "string") {
this.loadNextGame(propertyValue); this.loadNextGameFromExitUrl(propertyValue);
} }
if (layer.properties === undefined) { if (layer.properties === undefined) {
layer.properties = []; layer.properties = [];
@ -1131,28 +1210,38 @@ ${escapedMessage}
return this.MapUrlFile.substr(0, this.MapUrlFile.lastIndexOf("/")); return this.MapUrlFile.substr(0, this.MapUrlFile.lastIndexOf("/"));
} }
private onMapExit(exitKey: string) { private async onMapExit(roomUrl: URL) {
if (this.mapTransitioning) return; if (this.mapTransitioning) return;
this.mapTransitioning = true; this.mapTransitioning = true;
const { roomId, hash } = Room.getIdFromIdentifier(exitKey, this.MapUrlFile, this.instance);
if (!roomId) throw new Error("Could not find the room from its exit key: " + exitKey); let targetRoom: Room;
if (hash) { try {
urlManager.pushStartLayerNameToUrl(hash); targetRoom = await Room.createRoom(roomUrl);
} catch (e) {
console.error('Error while fetching new room "' + roomUrl.toString() + '"', e);
this.mapTransitioning = false;
return;
} }
if (roomUrl.hash) {
urlManager.pushStartLayerNameToUrl(roomUrl.hash);
}
const menuScene: MenuScene = this.scene.get(MenuSceneName) as MenuScene; const menuScene: MenuScene = this.scene.get(MenuSceneName) as MenuScene;
menuScene.reset(); menuScene.reset();
if (roomId !== this.scene.key) {
if (this.scene.get(roomId) === null) { if (!targetRoom.isEqual(this.room)) {
console.error("next room not loaded", exitKey); if (this.scene.get(targetRoom.key) === null) {
console.error("next room not loaded", targetRoom.key);
return; return;
} }
this.cleanupClosingScene(); this.cleanupClosingScene();
this.scene.stop(); this.scene.stop();
this.scene.start(targetRoom.key);
this.scene.remove(this.scene.key); this.scene.remove(this.scene.key);
this.scene.start(roomId);
} else { } else {
//if the exit points to the current map, we simply teleport the user back to the startLayer //if the exit points to the current map, we simply teleport the user back to the startLayer
this.startPositionCalculator.initPositionFromLayerName(hash, hash); this.startPositionCalculator.initPositionFromLayerName(roomUrl.hash, roomUrl.hash);
this.CurrentPlayer.x = this.startPositionCalculator.startPosition.x; this.CurrentPlayer.x = this.startPositionCalculator.startPosition.x;
this.CurrentPlayer.y = this.startPositionCalculator.startPosition.y; this.CurrentPlayer.y = this.startPositionCalculator.startPosition.y;
setTimeout(() => (this.mapTransitioning = false), 500); setTimeout(() => (this.mapTransitioning = false), 500);
@ -1182,6 +1271,8 @@ ${escapedMessage}
this.chatVisibilityUnsubscribe(); this.chatVisibilityUnsubscribe();
this.biggestAvailableAreaStoreUnsubscribe(); this.biggestAvailableAreaStoreUnsubscribe();
iframeListener.unregisterAnswerer("getState"); iframeListener.unregisterAnswerer("getState");
iframeListener.unregisterAnswerer("loadTileset");
this.sharedVariablesManager?.close();
mediaManager.hideGameOverlay(); mediaManager.hideGameOverlay();
@ -1221,12 +1312,12 @@ ${escapedMessage}
} }
private getProperty(layer: ITiledMapLayer | ITiledMap, name: string): string | boolean | number | undefined { private getProperty(layer: ITiledMapLayer | ITiledMap, name: string): string | boolean | number | undefined {
const properties: ITiledMapLayerProperty[] | undefined = layer.properties; const properties: ITiledMapProperty[] | undefined = layer.properties;
if (!properties) { if (!properties) {
return undefined; return undefined;
} }
const obj = properties.find( const obj = properties.find(
(property: ITiledMapLayerProperty) => property.name.toLowerCase() === name.toLowerCase() (property: ITiledMapProperty) => property.name.toLowerCase() === name.toLowerCase()
); );
if (obj === undefined) { if (obj === undefined) {
return undefined; return undefined;
@ -1235,20 +1326,27 @@ ${escapedMessage}
} }
private getProperties(layer: ITiledMapLayer | ITiledMap, name: string): (string | number | boolean | undefined)[] { private getProperties(layer: ITiledMapLayer | ITiledMap, name: string): (string | number | boolean | undefined)[] {
const properties: ITiledMapLayerProperty[] | undefined = layer.properties; const properties: ITiledMapProperty[] | undefined = layer.properties;
if (!properties) { if (!properties) {
return []; return [];
} }
return properties return properties
.filter((property: ITiledMapLayerProperty) => property.name.toLowerCase() === name.toLowerCase()) .filter((property: ITiledMapProperty) => property.name.toLowerCase() === name.toLowerCase())
.map((property) => property.value); .map((property) => property.value);
} }
private loadNextGameFromExitUrl(exitUrl: string): Promise<void> {
return this.loadNextGame(Room.getRoomPathFromExitUrl(exitUrl, window.location.toString()));
}
//todo: push that into the gameManager //todo: push that into the gameManager
private loadNextGame(exitSceneIdentifier: string): Promise<void> { private async loadNextGame(exitRoomPath: URL): Promise<void> {
const { roomId, hash } = Room.getIdFromIdentifier(exitSceneIdentifier, this.MapUrlFile, this.instance); try {
const room = new Room(roomId); const room = await Room.createRoom(exitRoomPath);
return gameManager.loadMap(room).catch(() => {}); return gameManager.loadMap(room);
} catch (e) {
console.warn('Error while pre-loading exit room "' + exitRoomPath.toString() + '"', e);
}
} }
//todo: in a dedicated class/function? //todo: in a dedicated class/function?
@ -1691,7 +1789,7 @@ ${escapedMessage}
this.scene.start(ErrorSceneName, { this.scene.start(ErrorSceneName, {
title: "Banned", title: "Banned",
subTitle: "You were banned from WorkAdventure", subTitle: "You were banned from WorkAdventure",
message: "If you want more information, you may contact us at: workadventure@thecodingmachine.com", message: "If you want more information, you may contact us at: hello@workadventu.re",
}); });
} }
@ -1706,14 +1804,14 @@ ${escapedMessage}
this.scene.start(ErrorSceneName, { this.scene.start(ErrorSceneName, {
title: "Connection rejected", title: "Connection rejected",
subTitle: "The world you are trying to join is full. Try again later.", subTitle: "The world you are trying to join is full. Try again later.",
message: "If you want more information, you may contact us at: workadventure@thecodingmachine.com", message: "If you want more information, you may contact us at: hello@workadventu.re",
}); });
} else { } else {
this.scene.start(ErrorSceneName, { this.scene.start(ErrorSceneName, {
title: "Connection rejected", title: "Connection rejected",
subTitle: "You cannot join the World. Try again later. \n\r \n\r Error: " + message + ".", subTitle: "You cannot join the World. Try again later. \n\r \n\r Error: " + message + ".",
message: message:
"If you want more information, you may contact administrator or contact us at: workadventure@thecodingmachine.com", "If you want more information, you may contact administrator or contact us at: hello@workadventu.re",
}); });
} }
} }

View File

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

View File

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

View File

@ -244,6 +244,7 @@ export class CustomizeScene extends AbstractCharacterScene {
update(time: number, delta: number): void { update(time: number, delta: number): void {
if (this.lazyloadingAttempt) { if (this.lazyloadingAttempt) {
this.moveLayers(); this.moveLayers();
this.doMoveCursorHorizontally(this.moveHorizontally);
this.lazyloadingAttempt = false; this.lazyloadingAttempt = false;
} }

View File

@ -1,8 +1,8 @@
import {gameManager} from "../Game/GameManager"; import { gameManager } from "../Game/GameManager";
import {Scene} from "phaser"; import { Scene } from "phaser";
import {ErrorScene} from "../Reconnecting/ErrorScene"; import { ErrorScene } from "../Reconnecting/ErrorScene";
import {WAError} from "../Reconnecting/WAError"; import { WAError } from "../Reconnecting/WAError";
import {waScaleManager} from "../Services/WaScaleManager"; import { waScaleManager } from "../Services/WaScaleManager";
export const EntrySceneName = "EntryScene"; export const EntrySceneName = "EntryScene";
@ -13,23 +13,29 @@ export const EntrySceneName = "EntryScene";
export class EntryScene extends Scene { export class EntryScene extends Scene {
constructor() { constructor() {
super({ super({
key: EntrySceneName key: EntrySceneName,
}); });
} }
create() { create() {
gameManager
gameManager.init(this.scene).then((nextSceneName) => { .init(this.scene)
.then((nextSceneName) => {
// Let's rescale before starting the game // Let's rescale before starting the game
// We can do it at this stage. // We can do it at this stage.
waScaleManager.applyNewSize(); waScaleManager.applyNewSize();
this.scene.start(nextSceneName); this.scene.start(nextSceneName);
}).catch((err) => { })
.catch((err) => {
if (err.response && err.response.status == 404) { if (err.response && err.response.status == 404) {
ErrorScene.showError(new WAError( ErrorScene.showError(
'Access link incorrect', new WAError(
'Could not find map. Please check your access link.', "Access link incorrect",
'If you want more information, you may contact administrator or contact us at: workadventure@thecodingmachine.com'), this.scene); "Could not find map. Please check your access link.",
"If you want more information, you may contact administrator or contact us at: hello@workadventu.re"
),
this.scene
);
} else { } else {
ErrorScene.showError(err, this.scene); ErrorScene.showError(err, this.scene);
} }

View File

@ -152,6 +152,9 @@ export class SelectCharacterScene extends AbstractCharacterScene {
}); });
this.players.push(player); this.players.push(player);
} }
if (this.currentSelectUser >= this.players.length) {
this.currentSelectUser = 0;
}
this.selectedPlayer = this.players[this.currentSelectUser]; this.selectedPlayer = this.players[this.currentSelectUser];
this.selectedPlayer.play(this.playerModels[this.currentSelectUser].name); this.selectedPlayer.play(this.playerModels[this.currentSelectUser].name);
} }

View File

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

View File

@ -6,8 +6,6 @@ import { localUserStore } from "../../Connexion/LocalUserStore";
import { gameReportKey, gameReportRessource, ReportMenu } from "./ReportMenu"; import { gameReportKey, gameReportRessource, ReportMenu } from "./ReportMenu";
import { connectionManager } from "../../Connexion/ConnectionManager"; import { connectionManager } from "../../Connexion/ConnectionManager";
import { GameConnexionTypes } from "../../Url/UrlManager"; import { GameConnexionTypes } from "../../Url/UrlManager";
import { WarningContainer, warningContainerHtml, warningContainerKey } from "../Components/WarningContainer";
import { worldFullWarningStream } from "../../Connexion/WorldFullWarningStream";
import { menuIconVisible, menuVisible } from "../../Stores/MenuStore"; import { menuIconVisible, menuVisible } from "../../Stores/MenuStore";
import { videoConstraintStore } from "../../Stores/MediaStore"; import { videoConstraintStore } from "../../Stores/MediaStore";
import { showReportScreenStore } from "../../Stores/ShowReportScreenStore"; import { showReportScreenStore } from "../../Stores/ShowReportScreenStore";
@ -18,6 +16,10 @@ import { registerMenuCommandStream } from "../../Api/Events/ui/MenuItemRegisterE
import { sendMenuClickedEvent } from "../../Api/iframe/Ui/MenuItem"; import { sendMenuClickedEvent } from "../../Api/iframe/Ui/MenuItem";
import { consoleGlobalMessageManagerVisibleStore } from "../../Stores/ConsoleGlobalMessageManagerStore"; import { consoleGlobalMessageManagerVisibleStore } from "../../Stores/ConsoleGlobalMessageManagerStore";
import { get } from "svelte/store"; import { get } from "svelte/store";
import { playersStore } from "../../Stores/PlayersStore";
import { mediaManager } from "../../WebRtc/MediaManager";
import { chatVisibilityStore } from "../../Stores/ChatStore";
import { ADMIN_URL } from "../../Enum/EnvironmentVariable";
export const MenuSceneName = "MenuScene"; export const MenuSceneName = "MenuScene";
const gameMenuKey = "gameMenu"; const gameMenuKey = "gameMenu";
@ -42,8 +44,6 @@ export class MenuScene extends Phaser.Scene {
private gameQualityValue: number; private gameQualityValue: number;
private videoQualityValue: number; private videoQualityValue: number;
private menuButton!: Phaser.GameObjects.DOMElement; private menuButton!: Phaser.GameObjects.DOMElement;
private warningContainer: WarningContainer | null = null;
private warningContainerTimeout: NodeJS.Timeout | null = null;
private subscriptions = new Subscription(); private subscriptions = new Subscription();
constructor() { constructor() {
super({ key: MenuSceneName }); super({ key: MenuSceneName });
@ -88,7 +88,6 @@ export class MenuScene extends Phaser.Scene {
this.load.html(gameSettingsMenuKey, "resources/html/gameQualityMenu.html"); this.load.html(gameSettingsMenuKey, "resources/html/gameQualityMenu.html");
this.load.html(gameShare, "resources/html/gameShare.html"); this.load.html(gameShare, "resources/html/gameShare.html");
this.load.html(gameReportKey, gameReportRessource); this.load.html(gameReportKey, gameReportRessource);
this.load.html(warningContainerKey, warningContainerHtml);
} }
create() { create() {
@ -96,6 +95,10 @@ export class MenuScene extends Phaser.Scene {
this.menuElement.setOrigin(0); this.menuElement.setOrigin(0);
MenuScene.revealMenusAfterInit(this.menuElement, "gameMenu"); MenuScene.revealMenusAfterInit(this.menuElement, "gameMenu");
if (mediaManager.hasNotification()) {
HtmlUtils.getElementByIdOrFail("enableNotification").hidden = true;
}
const middleX = window.innerWidth / 3 - 298; const middleX = window.innerWidth / 3 - 298;
this.gameQualityMenuElement = this.add.dom(middleX, -400).createFromCache(gameSettingsMenuKey); this.gameQualityMenuElement = this.add.dom(middleX, -400).createFromCache(gameSettingsMenuKey);
MenuScene.revealMenusAfterInit(this.gameQualityMenuElement, "gameQuality"); MenuScene.revealMenusAfterInit(this.gameQualityMenuElement, "gameQuality");
@ -135,7 +138,9 @@ export class MenuScene extends Phaser.Scene {
this.menuElement.addListener("click"); this.menuElement.addListener("click");
this.menuElement.on("click", this.onMenuClick.bind(this)); this.menuElement.on("click", this.onMenuClick.bind(this));
worldFullWarningStream.stream.subscribe(() => this.showWorldCapacityWarning()); chatVisibilityStore.subscribe((v) => {
this.menuButton.setVisible(!v);
});
} }
//todo put this method in a parent menuElement class //todo put this method in a parent menuElement class
@ -182,20 +187,6 @@ export class MenuScene extends Phaser.Scene {
});*/ });*/
} }
private showWorldCapacityWarning() {
if (!this.warningContainer) {
this.warningContainer = new WarningContainer(this);
}
if (this.warningContainerTimeout) {
clearTimeout(this.warningContainerTimeout);
}
this.warningContainerTimeout = setTimeout(() => {
this.warningContainer?.destroy();
this.warningContainer = null;
this.warningContainerTimeout = null;
}, 120000);
}
private closeSideMenu(): void { private closeSideMenu(): void {
menuVisible.set(false); menuVisible.set(false);
/* if (!this.sideMenuOpened) return; /* if (!this.sideMenuOpened) return;
@ -352,9 +343,15 @@ export class MenuScene extends Phaser.Scene {
case "editGameSettingsButton": case "editGameSettingsButton":
this.openGameSettingsMenu(); this.openGameSettingsMenu();
break; break;
case "oidcLogin":
connectionManager.loadOpenIDScreen();
break;
case "toggleFullscreen": case "toggleFullscreen":
this.toggleFullscreen(); this.toggleFullscreen();
break; break;
case "enableNotification":
this.enableNotification();
break;
case "adminConsoleButton": case "adminConsoleButton":
if (get(consoleGlobalMessageManagerVisibleStore)) { if (get(consoleGlobalMessageManagerVisibleStore)) {
consoleGlobalMessageManagerVisibleStore.set(false); consoleGlobalMessageManagerVisibleStore.set(false);
@ -389,6 +386,10 @@ export class MenuScene extends Phaser.Scene {
private gotToCreateMapPage() { private gotToCreateMapPage() {
//const sparkHost = 'https://'+window.location.host.replace('play.', '')+'/choose-map.html'; //const sparkHost = 'https://'+window.location.host.replace('play.', '')+'/choose-map.html';
//TODO fix me: this button can to send us on WorkAdventure BO. //TODO fix me: this button can to send us on WorkAdventure BO.
//const sparkHost = ADMIN_URL + "/getting-started";
//The redirection must be only on workadventu.re domain
//To day the domain staging cannot be use by customer
const sparkHost = "https://workadventu.re/getting-started"; const sparkHost = "https://workadventu.re/getting-started";
window.open(sparkHost, "_blank"); window.open(sparkHost, "_blank");
} }
@ -417,4 +418,12 @@ export class MenuScene extends Phaser.Scene {
public isDirty(): boolean { public isDirty(): boolean {
return false; return false;
} }
private enableNotification() {
mediaManager.requestNotification().then(() => {
if (mediaManager.hasNotification()) {
HtmlUtils.getElementByIdOrFail("enableNotification").hidden = true;
}
});
}
} }

View File

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

View File

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

View File

@ -1,12 +1,12 @@
import type { Direction } from "../../types"; import type { Direction } from "../../types";
import type {GameScene} from "../Game/GameScene"; import type { GameScene } from "../Game/GameScene";
import {touchScreenManager} from "../../Touch/TouchScreenManager"; import { touchScreenManager } from "../../Touch/TouchScreenManager";
import {MobileJoystick} from "../Components/MobileJoystick"; import { MobileJoystick } from "../Components/MobileJoystick";
import {enableUserInputsStore} from "../../Stores/UserInputStore"; import { enableUserInputsStore } from "../../Stores/UserInputStore";
interface UserInputManagerDatum { interface UserInputManagerDatum {
keyInstance: Phaser.Input.Keyboard.Key; keyInstance: Phaser.Input.Keyboard.Key;
event: UserInputEvent event: UserInputEvent;
} }
export enum UserInputEvent { export enum UserInputEvent {
@ -20,10 +20,9 @@ export enum UserInputEvent {
JoystickMove, JoystickMove,
} }
//we cannot use a map structure so we have to create a replacement
//we cannot use a map structure so we have to create a replacment
export class ActiveEventList { export class ActiveEventList {
private eventMap : Map<UserInputEvent, boolean> = new Map<UserInputEvent, boolean>(); private eventMap: Map<UserInputEvent, boolean> = new Map<UserInputEvent, boolean>();
get(event: UserInputEvent): boolean { get(event: UserInputEvent): boolean {
return this.eventMap.get(event) || false; return this.eventMap.get(event) || false;
@ -43,7 +42,7 @@ export class ActiveEventList {
export class UserInputManager { export class UserInputManager {
private KeysCode!: UserInputManagerDatum[]; private KeysCode!: UserInputManagerDatum[];
private Scene: GameScene; private Scene: GameScene;
private isInputDisabled : boolean; private isInputDisabled: boolean;
private joystick!: MobileJoystick; private joystick!: MobileJoystick;
private joystickEvents = new ActiveEventList(); private joystickEvents = new ActiveEventList();
@ -61,8 +60,8 @@ export class UserInputManager {
} }
enableUserInputsStore.subscribe((enable) => { enableUserInputsStore.subscribe((enable) => {
enable ? this.restoreControls() : this.disableControls() enable ? this.restoreControls() : this.disableControls();
}) });
} }
initVirtualJoystick() { initVirtualJoystick() {
@ -91,39 +90,81 @@ export class UserInputManager {
}); });
} }
initKeyBoardEvent(){ initKeyBoardEvent() {
this.KeysCode = [ this.KeysCode = [
{event: UserInputEvent.MoveUp, keyInstance: this.Scene.input.keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.Z, false) }, {
{event: UserInputEvent.MoveUp, keyInstance: this.Scene.input.keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.W, false) }, event: UserInputEvent.MoveUp,
{event: UserInputEvent.MoveLeft, keyInstance: this.Scene.input.keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.Q, false) }, keyInstance: this.Scene.input.keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.Z, false),
{event: UserInputEvent.MoveLeft, keyInstance: this.Scene.input.keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.A, false) }, },
{event: UserInputEvent.MoveDown, keyInstance: this.Scene.input.keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.S, false) }, {
{event: UserInputEvent.MoveRight, keyInstance: this.Scene.input.keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.D, false) }, event: UserInputEvent.MoveUp,
keyInstance: this.Scene.input.keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.W, false),
},
{
event: UserInputEvent.MoveLeft,
keyInstance: this.Scene.input.keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.Q, false),
},
{
event: UserInputEvent.MoveLeft,
keyInstance: this.Scene.input.keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.A, false),
},
{
event: UserInputEvent.MoveDown,
keyInstance: this.Scene.input.keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.S, false),
},
{
event: UserInputEvent.MoveRight,
keyInstance: this.Scene.input.keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.D, false),
},
{event: UserInputEvent.MoveUp, keyInstance: this.Scene.input.keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.UP, false) }, {
{event: UserInputEvent.MoveLeft, keyInstance: this.Scene.input.keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.LEFT, false) }, event: UserInputEvent.MoveUp,
{event: UserInputEvent.MoveDown, keyInstance: this.Scene.input.keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.DOWN, false) }, keyInstance: this.Scene.input.keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.UP, false),
{event: UserInputEvent.MoveRight, keyInstance: this.Scene.input.keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.RIGHT, false) }, },
{
event: UserInputEvent.MoveLeft,
keyInstance: this.Scene.input.keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.LEFT, false),
},
{
event: UserInputEvent.MoveDown,
keyInstance: this.Scene.input.keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.DOWN, false),
},
{
event: UserInputEvent.MoveRight,
keyInstance: this.Scene.input.keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.RIGHT, false),
},
{event: UserInputEvent.SpeedUp, keyInstance: this.Scene.input.keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.SHIFT, false) }, {
event: UserInputEvent.SpeedUp,
keyInstance: this.Scene.input.keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.SHIFT, false),
},
{event: UserInputEvent.Interact, keyInstance: this.Scene.input.keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.E, false) }, {
{event: UserInputEvent.Interact, keyInstance: this.Scene.input.keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.SPACE, false) }, event: UserInputEvent.Interact,
{event: UserInputEvent.Shout, keyInstance: this.Scene.input.keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.F, false) }, keyInstance: this.Scene.input.keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.E, false),
},
{
event: UserInputEvent.Interact,
keyInstance: this.Scene.input.keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.SPACE, false),
},
{
event: UserInputEvent.Shout,
keyInstance: this.Scene.input.keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.F, false),
},
]; ];
} }
clearAllListeners(){ clearAllListeners() {
this.Scene.input.keyboard.removeAllListeners(); this.Scene.input.keyboard.removeAllListeners();
} }
//todo: should we also disable the joystick? //todo: should we also disable the joystick?
disableControls(){ disableControls() {
this.Scene.input.keyboard.removeAllKeys(); this.Scene.input.keyboard.removeAllKeys();
this.isInputDisabled = true; this.isInputDisabled = true;
} }
restoreControls(){ restoreControls() {
this.initKeyBoardEvent(); this.initKeyBoardEvent();
this.isInputDisabled = false; this.isInputDisabled = false;
} }
@ -155,7 +196,7 @@ export class UserInputManager {
} }
}); });
eventsMap.set(UserInputEvent.JoystickMove, this.joystickEvents.any()); eventsMap.set(UserInputEvent.JoystickMove, this.joystickEvents.any());
this.KeysCode.forEach(d => { this.KeysCode.forEach((d) => {
if (d.keyInstance.isDown) { if (d.keyInstance.isDown) {
eventsMap.set(d.event, true); eventsMap.set(d.event, true);
} }
@ -163,18 +204,18 @@ export class UserInputManager {
return eventsMap; return eventsMap;
} }
spaceEvent(callback : Function){ spaceEvent(callback: Function) {
this.Scene.input.keyboard.on('keyup-SPACE', (event: Event) => { this.Scene.input.keyboard.on("keyup-SPACE", (event: Event) => {
callback(); callback();
return event; return event;
}); });
} }
addSpaceEventListner(callback : Function){ addSpaceEventListner(callback: Function) {
this.Scene.input.keyboard.addListener('keyup-SPACE', callback); this.Scene.input.keyboard.addListener("keyup-SPACE", callback);
} }
removeSpaceEventListner(callback : Function){ removeSpaceEventListner(callback: Function) {
this.Scene.input.keyboard.removeListener('keyup-SPACE', callback); this.Scene.input.keyboard.removeListener("keyup-SPACE", callback);
} }
destroy(): void { destroy(): void {
@ -182,8 +223,11 @@ export class UserInputManager {
} }
private initMouseWheel() { private initMouseWheel() {
this.Scene.input.on('wheel', (pointer: unknown, gameObjects: unknown, deltaX: number, deltaY: number, deltaZ: number) => { this.Scene.input.on(
this.Scene.zoomByFactor(1 - deltaY / 53 * 0.1); "wheel",
}); (pointer: unknown, gameObjects: unknown, deltaX: number, deltaY: number, deltaZ: number) => {
this.Scene.zoomByFactor(1 - (deltaY / 53) * 0.1);
}
);
} }
} }

View File

@ -96,6 +96,7 @@ function createChatMessagesStore() {
} }
return list; return list;
}); });
chatVisibilityStore.set(true);
}, },
}; };
} }

View File

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

View File

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

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