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

This commit is contained in:
David Négrier 2021-07-16 11:22:36 +02:00
commit 5c7ea7b258
66 changed files with 1296 additions and 967 deletions

View File

@ -11,7 +11,7 @@
- Use `WA.onInit(): Promise<void>` to wait for scripting API initialization - 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 or change existing property of a layer - Use `WA.room.setProperty() : void` to add, delete or change existing property of a layer
- Use `WA.player.onPlayerMove(): void` to track the movement of the current player - Use `WA.player.onPlayerMove(): void` to track the movement of the current player
- Use `WA.player.id: string|undefined` to get the ID of the current player - Use `WA.player.id: string|undefined` to get the ID of the current player
- Use `WA.player.name: string` to get the name of the current player - Use `WA.player.name: string` to get the name of the current player
@ -20,13 +20,23 @@
- 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.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.getMap(): Promise<ITiledMap>` to get the JSON map file
- Use `WA.room.setTiles(): void` to 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.ui.registerMenuCommand(): void` to add a custom menu
- Use `WA.state.loadVariable(key: string): unknown` to retrieve a variable - 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.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.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`) - 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 chat is now persistent bewteen discussions and always accesible
- The chat now tracks incoming and outcoming users in your conversation
- The chat allows your to see the visit card of users
- 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

@ -5,27 +5,21 @@ 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 { arrayIntersect } from "../Services/ArrayHelper";
import { import {
BatchToPusherMessage, BatchToPusherMessage,
BatchToPusherRoomMessage, BatchToPusherRoomMessage,
EmoteEventMessage, EmoteEventMessage,
JoinRoomMessage, SubToPusherRoomMessage, VariableMessage JoinRoomMessage,
SubToPusherRoomMessage,
VariableMessage,
} from "../Messages/generated/messages_pb"; } from "../Messages/generated/messages_pb";
import { ProtobufUtils } from "../Model/Websocket/ProtobufUtils"; import { ProtobufUtils } from "../Model/Websocket/ProtobufUtils";
import {RoomSocket, ZoneSocket} from "src/RoomManager"; import { RoomSocket, ZoneSocket } from "src/RoomManager";
import { Admin } from "../Model/Admin"; import { Admin } from "../Model/Admin";
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 enum GameRoomPolicyTypes {
ANONYMOUS_POLICY = 1,
MEMBERS_ONLY_POLICY,
USE_TAGS_POLICY,
}
export class GameRoom { export class GameRoom {
private readonly minDistance: number; private readonly minDistance: number;
private readonly groupRadius: number; private readonly groupRadius: number;
@ -43,17 +37,14 @@ export class GameRoom {
public readonly variables = new Map<string, string>(); public readonly variables = new Map<string, string>();
private readonly positionNotifier: PositionNotifier; private readonly positionNotifier: PositionNotifier;
public readonly roomId: string; public readonly roomUrl: 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;
private roomListeners: Set<RoomSocket> = new Set<RoomSocket>(); private roomListeners: Set<RoomSocket> = new Set<RoomSocket>();
constructor( constructor(
roomId: string, roomUrl: string,
connectCallback: ConnectCallback, connectCallback: ConnectCallback,
disconnectCallback: DisconnectCallback, disconnectCallback: DisconnectCallback,
minDistance: number, minDistance: number,
@ -63,16 +54,7 @@ export class GameRoom {
onLeaves: LeavesCallback, onLeaves: LeavesCallback,
onEmote: EmoteCallback onEmote: EmoteCallback
) { ) {
this.roomId = roomId; this.roomUrl = roomUrl;
if (isRoomAnonymous(roomId)) {
this.roomSlug = extractRoomSlugPublicRoomId(this.roomId);
} else {
const { organizationSlug, worldSlug, roomSlug } = extractDataFromPrivateRoomId(this.roomId);
this.roomSlug = roomSlug;
this.organizationSlug = organizationSlug;
this.worldSlug = worldSlug;
}
this.users = new Map<number, User>(); this.users = new Map<number, User>();
this.usersByUuid = new Map<string, User>(); this.usersByUuid = new Map<string, User>();
@ -191,7 +173,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,

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

@ -30,7 +30,9 @@ import {
BanUserMessage, BanUserMessage,
RefreshRoomMessage, RefreshRoomMessage,
EmotePromptMessage, EmotePromptMessage,
VariableMessage, BatchToPusherRoomMessage, SubToPusherRoomMessage, 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";
@ -49,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 {RoomSocket, 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";
@ -270,12 +272,12 @@ 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.rooms.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");
} }
} }
@ -467,10 +469,7 @@ export class SocketManager {
const serverToClientMessage1 = new ServerToClientMessage(); const serverToClientMessage1 = new ServerToClientMessage();
serverToClientMessage1.setWebrtcstartmessage(webrtcStartMessage1); serverToClientMessage1.setWebrtcstartmessage(webrtcStartMessage1);
//if (!user.socket.disconnecting) {
user.socket.write(serverToClientMessage1); user.socket.write(serverToClientMessage1);
//console.log('Sending webrtcstart initiator to '+user.socket.userId)
//}
const webrtcStartMessage2 = new WebRtcStartMessage(); const webrtcStartMessage2 = new WebRtcStartMessage();
webrtcStartMessage2.setUserid(user.id); webrtcStartMessage2.setUserid(user.id);
@ -484,10 +483,7 @@ export class SocketManager {
const serverToClientMessage2 = new ServerToClientMessage(); const serverToClientMessage2 = new ServerToClientMessage();
serverToClientMessage2.setWebrtcstartmessage(webrtcStartMessage2); serverToClientMessage2.setWebrtcstartmessage(webrtcStartMessage2);
//if (!otherUser.socket.disconnecting) {
otherUser.socket.write(serverToClientMessage2); otherUser.socket.write(serverToClientMessage2);
//console.log('Sending webrtcstart to '+otherUser.socket.userId)
//}
} }
} }
@ -731,9 +727,9 @@ 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.rooms.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);
} }
} }
@ -875,7 +871,6 @@ export class SocketManager {
emoteEventMessage.setActoruserid(user.id); emoteEventMessage.setActoruserid(user.id);
room.emitEmoteEvent(user, emoteEventMessage); room.emitEmoteEvent(user, emoteEventMessage);
} }
} }
export const socketManager = new SocketManager(); export const socketManager = new SocketManager();

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

@ -54,6 +54,7 @@ WA.room.showLayer(layerName : string): void
WA.room.hideLayer(layerName : string) : void WA.room.hideLayer(layerName : string) : void
``` ```
These 2 methods can be used to show and hide a layer. These 2 methods can be used to show and hide a layer.
if `layerName` is the name of a group layer, show/hide all the layer in that group layer.
Example : Example :
```javascript ```javascript
@ -70,6 +71,9 @@ WA.room.setProperty(layerName : string, propertyName : string, propertyValue : s
Set the value of the `propertyName` property of the layer `layerName` at `propertyValue`. If the property doesn't exist, create the property `propertyName` and set the value of the property at `propertyValue`. Set the value of the `propertyName` property of the layer `layerName` at `propertyValue`. If the property doesn't exist, create the property `propertyName` and set the value of the property at `propertyValue`.
Note :
To unset a property from a layer, use `setProperty` with `propertyValue` set to `undefined`.
Example : Example :
```javascript ```javascript
WA.room.setProperty('wikiLayer', 'openWebsite', 'https://www.wikipedia.org/'); WA.room.setProperty('wikiLayer', 'openWebsite', 'https://www.wikipedia.org/');
@ -148,6 +152,7 @@ If `tile` is a string, it's not the id of the tile but the value of the property
**Important !** : If you use `tile` as a number, be sure to add the `firstgid` of the tileset of the tile that you want to the id of the tile in Tiled Editor. **Important !** : If you use `tile` as a number, be sure to add the `firstgid` of the tileset of the tile that you want to the id of the tile in Tiled Editor.
Note: If you want to unset a tile, use `setTiles` with `tile` set to `null`.
Example : Example :
```javascript ```javascript

View File

@ -37,8 +37,7 @@
<div class="main-container" id="main-container"> <div class="main-container" id="main-container">
<!-- Create the editor container --> <!-- Create the editor container -->
<div id="game" class="game"> <div id="game" class="game">
<div id="svelte-overlay"> <div id="svelte-overlay"></div>
</div>
<div id="game-overlay" class="game-overlay"> <div id="game-overlay" class="game-overlay">
<div id="main-section" class="main-section"> <div id="main-section" class="main-section">
</div> </div>

View File

@ -57,6 +57,9 @@
<section> <section>
<button id="toggleFullscreen">Toggle fullscreen</button> <button id="toggleFullscreen">Toggle fullscreen</button>
</section> </section>
<section>
<button id="enableNotification">Enable notifications</button>
</section>
<section> <section>
<button id="sparkButton">Create map</button> <button id="sparkButton">Create map</button>
</section> </section>

53
front/dist/resources/service-worker.js vendored Normal file
View File

@ -0,0 +1,53 @@
let CACHE_NAME = 'workavdenture-cache-v1';
let urlsToCache = [
'/'
];
self.addEventListener('install', function(event) {
// Perform install steps
event.waitUntil(
caches.open(CACHE_NAME)
.then(function(cache) {
console.log('Opened cache');
return cache.addAll(urlsToCache);
})
);
});
self.addEventListener('fetch', function(event) {
event.respondWith(
caches.match(event.request)
.then(function(response) {
// Cache hit - return response
if (response) {
return response;
}
return fetch(event.request).then(
function(response) {
// Check if we received a valid response
if(!response || response.status !== 200 || response.type !== 'basic') {
return response;
}
// IMPORTANT: Clone the response. A response is a stream
// and because we want the browser to consume the response
// as well as the cache consuming the response, we need
// to clone it so we have two streams.
var responseToCache = response.clone();
caches.open(CACHE_NAME)
.then(function(cache) {
cache.put(event.request, responseToCache);
});
return response;
}
);
})
);
});
self.addEventListener('activate', function(event) {
//TODO activate service worker
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

View File

@ -119,7 +119,13 @@
"src": "/static/images/favicons/android-icon-192x192.png", "src": "/static/images/favicons/android-icon-192x192.png",
"sizes": "192x192", "sizes": "192x192",
"type": "image\/png", "type": "image\/png",
"density": "4.0" "density": "4.0",
"purpose": "any maskable"
},
{
"src": "/static/images/favicons/icon-512x512.png",
"sizes": "512x512",
"type": "image\/png"
} }
], ],
"start_url": "/", "start_url": "/",
@ -127,6 +133,7 @@
"display_override": ["window-control-overlay", "minimal-ui"], "display_override": ["window-control-overlay", "minimal-ui"],
"display": "standalone", "display": "standalone",
"scope": "/", "scope": "/",
"lang": "en",
"theme_color": "#000000", "theme_color": "#000000",
"shortcuts": [ "shortcuts": [
{ {
@ -134,7 +141,7 @@
"short_name": "WA", "short_name": "WA",
"description": "WorkAdventure application", "description": "WorkAdventure application",
"url": "/", "url": "/",
"icons": [{ "src": "/static/images/favicons/android-icon-192x192.png", "sizes": "192x192" }] "icons": [{ "src": "/static/images/favicons/android-icon-192x192.png", "sizes": "192x192", "type": "image/png" }]
} }
], ],
"description": "WorkAdventure application", "description": "WorkAdventure application",

BIN
front/dist/static/images/send.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

View File

@ -39,7 +39,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",
@ -51,7 +51,7 @@
"queue-typescript": "^1.0.1", "queue-typescript": "^1.0.1",
"quill": "1.3.6", "quill": "1.3.6",
"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"
}, },

View File

@ -5,7 +5,7 @@ export const isSetTilesEvent = tg.isArray(
.withProperties({ .withProperties({
x: tg.isNumber, x: tg.isNumber,
y: tg.isNumber, y: tg.isNumber,
tile: tg.isUnion(tg.isNumber, tg.isString), tile: tg.isUnion(tg.isUnion(tg.isNumber, tg.isString), tg.isNull),
layer: tg.isString, layer: tg.isString,
}) })
.get() .get()

View File

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

View File

@ -1,8 +1,8 @@
import {Observable, Subject} from "rxjs"; import { Observable, Subject } from "rxjs";
import { EnterLeaveEvent, isEnterLeaveEvent } from "../Events/EnterLeaveEvent"; import { EnterLeaveEvent, isEnterLeaveEvent } from "../Events/EnterLeaveEvent";
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";
@ -13,21 +13,21 @@ const leaveStreams: Map<string, Subject<EnterLeaveEvent>> = new Map<string, Subj
interface TileDescriptor { interface TileDescriptor {
x: number; x: number;
y: number; y: number;
tile: number | string; tile: number | string | null;
layer: string; layer: string;
} }
let roomId: string|undefined; let roomId: string | undefined;
export const setRoomId = (id: string) => { export const setRoomId = (id: string) => {
roomId = id; roomId = id;
} };
let mapURL: string|undefined; let mapURL: string | undefined;
export const setMapURL = (url: string) => { export const setMapURL = (url: string) => {
mapURL = url; mapURL = url;
} };
export class WorkadventureRoomCommands extends IframeApiContribution<WorkadventureRoomCommands> { export class WorkadventureRoomCommands extends IframeApiContribution<WorkadventureRoomCommands> {
callbacks = [ callbacks = [
@ -90,16 +90,18 @@ export class WorkadventureRoomCommands extends IframeApiContribution<Workadventu
}); });
} }
get id() : string { get id(): string {
if (roomId === undefined) { if (roomId === undefined) {
throw new Error('Room id not initialized yet. You should call WA.room.id within a WA.onInit callback.'); throw new Error("Room id not initialized yet. You should call WA.room.id within a WA.onInit callback.");
} }
return roomId; return roomId;
} }
get mapURL() : string { get mapURL(): string {
if (mapURL === undefined) { if (mapURL === undefined) {
throw new Error('mapURL is not initialized yet. You should call WA.room.mapURL within a WA.onInit callback.'); throw new Error(
"mapURL is not initialized yet. You should call WA.room.mapURL within a WA.onInit callback."
);
} }
return mapURL; return mapURL;
} }

View File

@ -10,12 +10,14 @@
import {errorStore} from "../Stores/ErrorStore"; import {errorStore} from "../Stores/ErrorStore";
import CustomCharacterScene from "./CustomCharacterScene/CustomCharacterScene.svelte"; import CustomCharacterScene from "./CustomCharacterScene/CustomCharacterScene.svelte";
import LoginScene from "./Login/LoginScene.svelte"; import LoginScene from "./Login/LoginScene.svelte";
import Chat from "./Chat/Chat.svelte";
import {loginSceneVisibleStore} from "../Stores/LoginSceneStore"; import {loginSceneVisibleStore} from "../Stores/LoginSceneStore";
import EnableCameraScene from "./EnableCamera/EnableCameraScene.svelte"; import EnableCameraScene from "./EnableCamera/EnableCameraScene.svelte";
import VisitCard from "./VisitCard/VisitCard.svelte"; import VisitCard from "./VisitCard/VisitCard.svelte";
import {requestVisitCardsStore} from "../Stores/GameStore"; import {requestVisitCardsStore} from "../Stores/GameStore";
import type {Game} from "../Phaser/Game/Game"; import type {Game} from "../Phaser/Game/Game";
import {chatVisibilityStore} from "../Stores/ChatStore";
import {helpCameraSettingsVisibleStore} from "../Stores/HelpCameraSettingsStore"; import {helpCameraSettingsVisibleStore} from "../Stores/HelpCameraSettingsStore";
import HelpCameraSettingsPopup from "./HelpCameraSettings/HelpCameraSettingsPopup.svelte"; import HelpCameraSettingsPopup from "./HelpCameraSettings/HelpCameraSettingsPopup.svelte";
import AudioPlaying from "./UI/AudioPlaying.svelte"; import AudioPlaying from "./UI/AudioPlaying.svelte";
@ -61,14 +63,6 @@
<AudioPlaying url={$soundPlayingStore} /> <AudioPlaying url={$soundPlayingStore} />
</div> </div>
{/if} {/if}
<!--
{#if $menuIconVisible}
<div>
<MenuIcon />
</div>
{/if}
-->
{#if $gameOverlayVisibilityStore} {#if $gameOverlayVisibilityStore}
<div> <div>
<VideoOverlay></VideoOverlay> <VideoOverlay></VideoOverlay>
@ -94,4 +88,7 @@
<ErrorDialog></ErrorDialog> <ErrorDialog></ErrorDialog>
</div> </div>
{/if} {/if}
{#if $chatVisibilityStore}
<Chat></Chat>
{/if}
</div> </div>

View File

@ -0,0 +1,104 @@
<script lang="ts">
import { fly } from 'svelte/transition';
import { chatMessagesStore, chatVisibilityStore } from "../../Stores/ChatStore";
import ChatMessageForm from './ChatMessageForm.svelte';
import ChatElement from './ChatElement.svelte';
import { afterUpdate, beforeUpdate } from "svelte";
let listDom: HTMLElement;
let autoscroll: boolean;
beforeUpdate(() => {
autoscroll = listDom && (listDom.offsetHeight + listDom.scrollTop) > (listDom.scrollHeight - 20);
});
afterUpdate(() => {
if (autoscroll) listDom.scrollTo(0, listDom.scrollHeight);
});
function closeChat() {
chatVisibilityStore.set(false);
}
function onKeyDown(e:KeyboardEvent) {
if (e.key === 'Escape') {
closeChat();
}
}
</script>
<svelte:window on:keydown={onKeyDown}/>
<aside class="chatWindow" transition:fly="{{ x: -1000, duration: 500 }}">
<section class="chatWindowTitle">
<h1>Your chat history <span class="float-right" on:click={closeChat}>&times</span></h1>
</section>
<section class="messagesList" bind:this={listDom}>
<ul>
{#each $chatMessagesStore as message, i}
<li><ChatElement message={message} line={i}></ChatElement></li>
{/each}
</ul>
</section>
<section class="messageForm">
<ChatMessageForm></ChatMessageForm>
</section>
</aside>
<style lang="scss">
h1 {
font-family: Lato;
span.float-right {
font-size: 30px;
line-height: 25px;
font-weight: bold;
float: right;
cursor: pointer;
}
}
aside.chatWindow {
z-index:100;
pointer-events: auto;
position: absolute;
top: 0;
left: 0;
height: 100vh;
width:30vw;
min-width: 350px;
background: rgb(5, 31, 51, 0.9);
color: whitesmoke;
display: flex;
flex-direction: column;
padding: 10px;
border-bottom-right-radius: 16px;
border-top-right-radius: 16px;
h1 {
background-color: #5f5f5f;
border-radius: 8px;
padding: 2px;
}
.chatWindowTitle {
flex: 0 100px;
}
.messagesList {
overflow-y: auto;
flex: auto;
ul {
list-style-type: none;
padding-left: 0;
}
}
.messageForm {
flex: 0 70px;
padding-top: 20px;
}
}
</style>

View File

@ -0,0 +1,83 @@
<script lang="ts">
import {ChatMessageTypes} from "../../Stores/ChatStore";
import type {ChatMessage} from "../../Stores/ChatStore";
import {HtmlUtils} from "../../WebRtc/HtmlUtils";
import ChatPlayerName from './ChatPlayerName.svelte';
import type {PlayerInterface} from "../../Phaser/Game/PlayerInterface";
export let message: ChatMessage;
export let line: number;
$: author = message.author as PlayerInterface;
$: targets = message.targets || [];
$: texts = message.text || [];
function urlifyText(text: string): string {
return HtmlUtils.urlify(text)
}
function renderDate(date: Date) {
return date.toLocaleTimeString(navigator.language, {
hour: '2-digit',
minute:'2-digit'
});
}
function isLastIteration(index: number) {
return targets.length -1 === index;
}
</script>
<div class="chatElement">
<div class="messagePart">
{#if message.type === ChatMessageTypes.userIncoming}
&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}
&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}
<h4>Me: <span class="date">({renderDate(message.date)})</span></h4>
{#each texts as text}
<div><p class="my-text">{@html urlifyText(text)}</p></div>
{/each}
{:else}
<h4><ChatPlayerName player={author} line={line}></ChatPlayerName>: <span class="date">({renderDate(message.date)})</span></h4>
{#each texts as text}
<div><p class="other-text">{@html urlifyText(text)}</p></div>
{/each}
{/if}
</div>
</div>
<style lang="scss">
h4, p {
font-family: Lato;
}
div.chatElement {
display: flex;
margin-bottom: 20px;
.messagePart {
flex-grow:1;
max-width: 100%;
span.date {
font-size: 80%;
color: gray;
}
div > p {
border-radius: 8px;
margin-bottom: 10px;
padding:6px;
overflow-wrap: break-word;
max-width: 100%;
display: inline-block;
&.other-text {
background: gray;
}
&.my-text {
background: #6489ff;
}
}
}
}
</style>

View File

@ -0,0 +1,56 @@
<script lang="ts">
import {chatMessagesStore, chatInputFocusStore} from "../../Stores/ChatStore";
let newMessageText = '';
function onFocus() {
chatInputFocusStore.set(true);
}
function onBlur() {
chatInputFocusStore.set(false);
}
function saveMessage() {
if (!newMessageText) return;
chatMessagesStore.addPersonnalMessage(newMessageText);
newMessageText = '';
}
</script>
<form on:submit|preventDefault={saveMessage}>
<input type="text" bind:value={newMessageText} placeholder="Enter your message..." on:focus={onFocus} on:blur={onBlur} >
<button type="submit">
<img src="/static/images/send.png" alt="Send" width="20">
</button>
</form>
<style lang="scss">
form {
display: flex;
padding-left: 4px;
padding-right: 4px;
input {
flex: auto;
background-color: #42464d;
color: white;
border-bottom-left-radius: 4px;
border-top-left-radius: 4px;
border: none;
font-size: 22px;
font-family: Lato;
padding-left: 6px;
min-width: 0; //Needed so that the input doesn't overflow the container in firefox
outline: none;
}
button {
background-color: #42464d;
border-bottom-right-radius: 4px;
border-top-right-radius: 4px;
border: none;
border-left: solid black 1px;
font-size: 16px;
}
}
</style>

View File

@ -0,0 +1,51 @@
<script lang="ts">
import type {PlayerInterface} from "../../Phaser/Game/PlayerInterface";
import {chatSubMenuVisbilityStore} from "../../Stores/ChatStore";
import {onDestroy, onMount} from "svelte";
import type {Unsubscriber} from "svelte/store";
import ChatSubMenu from "./ChatSubMenu.svelte";
export let player: PlayerInterface;
export let line: number;
let isSubMenuOpen: boolean;
let chatSubMenuVisivilytUnsubcribe: Unsubscriber;
function openSubMenu() {
chatSubMenuVisbilityStore.openSubMenu(player.name, line);
}
onMount(() => {
chatSubMenuVisivilytUnsubcribe = chatSubMenuVisbilityStore.subscribe((newValue) => {
isSubMenuOpen = (newValue === player.name + line);
})
})
onDestroy(() => {
chatSubMenuVisivilytUnsubcribe();
})
</script>
<span class="subMenu">
<span class="chatPlayerName" style="color: {player.color || 'white'}" on:click={openSubMenu}>
{player.name}
</span>
{#if isSubMenuOpen}
<ChatSubMenu player={player}/>
{/if}
</span>
<style lang="scss">
span.subMenu {
display: inline-block;
}
span.chatPlayerName {
margin-left: 3px;
}
.chatPlayerName:hover {
text-decoration: underline;
cursor: pointer;
}
</style>

View File

@ -0,0 +1,33 @@
<script lang="ts">
import type {PlayerInterface} from "../../Phaser/Game/PlayerInterface";
import {requestVisitCardsStore} from "../../Stores/GameStore";
export let player: PlayerInterface;
function openVisitCard() {
if (player.visitCardUrl) {
requestVisitCardsStore.set(player.visitCardUrl);
}
}
</script>
<ul class="selectMenu" style="border-top: {player.color || 'whitesmoke'} 5px solid">
<li><button class="text-btn" disabled={!player.visitCardUrl} on:click={openVisitCard}>Visit card</button></li>
<li><button class="text-btn" disabled>Add friend</button></li>
</ul>
<style lang="scss">
ul.selectMenu {
background-color: whitesmoke;
position: absolute;
padding: 5px;
border-radius: 4px;
list-style-type: none;
li {
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

@ -1,92 +1,107 @@
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";
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;
get unloading () { 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);
}) });
} }
/** /**
* 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) { if (connexionType === GameConnexionTypes.register) {
const organizationMemberToken = urlManager.getOrganizationToken(); const organizationMemberToken = urlManager.getOrganizationToken();
const data = await Axios.post(`${PUSHER_URL}/register`, {organizationMemberToken}).then(res => res.data); const data = await Axios.post(`${PUSHER_URL}/register`, { organizationMemberToken }).then(
(res) => res.data
);
this.localUser = new LocalUser(data.userUuid, data.authToken, data.textures); this.localUser = new LocalUser(data.userUuid, data.authToken, data.textures);
localUserStore.saveUser(this.localUser); localUserStore.saveUser(this.localUser);
const organizationSlug = data.organizationSlug; const roomUrl = data.roomUrl;
const worldSlug = data.worldSlug;
const roomSlug = data.roomSlug;
const room = new Room('/@/'+organizationSlug+'/'+worldSlug+'/'+roomSlug + window.location.search + window.location.hash); const room = await Room.createRoom(
new URL(
window.location.protocol +
"//" +
window.location.host +
roomUrl +
window.location.search +
window.location.hash
)
);
urlManager.pushRoomIdToUrl(room); urlManager.pushRoomIdToUrl(room);
return Promise.resolve(room); return Promise.resolve(room);
} else if (connexionType === GameConnexionTypes.organization || connexionType === GameConnexionTypes.anonymous || connexionType === GameConnexionTypes.empty) { } else if (
connexionType === GameConnexionTypes.organization ||
connexionType === GameConnexionTypes.anonymous ||
connexionType === GameConnexionTypes.empty
) {
let localUser = localUserStore.getLocalUser(); let localUser = localUserStore.getLocalUser();
if (localUser && localUser.jwtToken && localUser.uuid && localUser.textures) { if (localUser && localUser.jwtToken && localUser.uuid && localUser.textures) {
this.localUser = localUser; this.localUser = localUser;
try { try {
await this.verifyToken(localUser.jwtToken); await this.verifyToken(localUser.jwtToken);
} catch(e) { } catch (e) {
// If the token is invalid, let's generate an anonymous one. // If the token is invalid, let's generate an anonymous one.
console.error('JWT token invalid. Did it expire? Login anonymously instead.'); console.error("JWT token invalid. Did it expire? Login anonymously instead.");
await this.anonymousLogin(); await this.anonymousLogin();
} }
}else{ } else {
await this.anonymousLogin(); await this.anonymousLogin();
} }
localUser = localUserStore.getLocalUser(); localUser = localUserStore.getLocalUser();
if(!localUser){ if (!localUser) {
throw "Error to store local user data"; throw "Error to store local user data";
} }
let roomId: string; let roomPath: 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); const 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 (localUser.textures.length === 0) {
localUser.textures = mapDetail.textures; 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 = localUser?.textures.find((c) => newTexture.id === c.id);
if(localUser?.textures.findIndex((c) => newTexture.id === c.id) !== -1){ if (localUser?.textures.findIndex((c) => newTexture.id === c.id) !== -1) {
return; return;
} }
localUser?.textures.push(newTexture) localUser?.textures.push(newTexture);
}); });
} }
this.localUser = localUser; this.localUser = localUser;
@ -95,55 +110,79 @@ class ConnectionManager {
return Promise.resolve(room); return Promise.resolve(room);
} }
return Promise.reject(new Error('Invalid URL')); return Promise.reject(new Error("Invalid URL"));
} }
private async verifyToken(token: string): Promise<void> { private async verifyToken(token: string): Promise<void> {
await Axios.get(`${PUSHER_URL}/verify`, {params: {token}}); 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, data.authToken, []);
if (!isBenchmark) { // In benchmark, we don't have a local storage. if (!isBenchmark) {
// In benchmark, we don't have a local storage.
localUserStore.saveUser(this.localUser); localUserStore.saveUser(this.localUser);
} }
} }
public initBenchmark(): void { public initBenchmark(): void {
this.localUser = new LocalUser('', 'test', []); this.localUser = new LocalUser("", "test", []);
} }
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.localUser.jwtToken,
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) => {
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

@ -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
const parts = identifier.split('#');
roomId = parts[0];
roomId = roomId.substring(1); //remove the leading slash
if (parts.length > 1) {
hash = parts[1]
} }
redirectCount++;
roomUrl = new URL(result.redirectUrl);
} }
return { roomId, hash } throw new Error("Room resolving seems stuck in a redirect loop after 32 redirect attempts");
} }
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,32 @@ 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.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

@ -76,11 +76,11 @@ export class RoomConnection implements RoomConnection {
/** /**
* *
* @param token A JWT token containing the UUID of the user * @param token A JWT token containing the UUID 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,
@ -93,7 +93,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) {

View File

@ -1,7 +1,7 @@
import {discussionManager} from "../../WebRtc/DiscussionManager"; import { DEPTH_INGAME_TEXT_INDEX } from "../Game/DepthIndexes";
import {DEPTH_INGAME_TEXT_INDEX} from "../Game/DepthIndexes"; import { chatVisibilityStore } from "../../Stores/ChatStore";
export const openChatIconName = 'openChatIcon'; export const openChatIconName = "openChatIcon";
export class OpenChatIcon extends Phaser.GameObjects.Image { export class OpenChatIcon extends Phaser.GameObjects.Image {
constructor(scene: Phaser.Scene, x: number, y: number) { constructor(scene: Phaser.Scene, x: number, y: number) {
super(scene, x, y, openChatIconName, 3); super(scene, x, y, openChatIconName, 3);
@ -9,9 +9,9 @@ export class OpenChatIcon extends Phaser.GameObjects.Image {
this.setScrollFactor(0, 0); this.setScrollFactor(0, 0);
this.setOrigin(0, 1); this.setOrigin(0, 1);
this.setInteractive(); this.setInteractive();
this.setVisible(false); //this.setVisible(false);
this.setDepth(DEPTH_INGAME_TEXT_INDEX); this.setDepth(DEPTH_INGAME_TEXT_INDEX);
this.on("pointerup", () => discussionManager.showDiscussionPart()); this.on("pointerup", () => chatVisibilityStore.set(true));
} }
} }

View File

@ -101,7 +101,6 @@ export const createLoadingPromise = (
frameConfig: FrameConfig frameConfig: FrameConfig
) => { ) => {
return new Promise<BodyResourceDescriptionInterface>((res, rej) => { return new Promise<BodyResourceDescriptionInterface>((res, rej) => {
console.log("count", loadPlugin.listenerCount("loaderror"));
if (loadPlugin.textureManager.exists(playerResourceDescriptor.name)) { if (loadPlugin.textureManager.exists(playerResourceDescriptor.name)) {
return res(playerResourceDescriptor); return res(playerResourceDescriptor);
} }

View File

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

View File

@ -152,7 +152,10 @@ export class GameMap {
} }
private getTileProperty(index: number): Array<ITiledMapProperty> { private getTileProperty(index: number): Array<ITiledMapProperty> {
return this.tileSetPropertyMap[index]; if (this.tileSetPropertyMap[index]) {
return this.tileSetPropertyMap[index];
}
return [];
} }
private trigger( private trigger(
@ -189,6 +192,10 @@ export class GameMap {
return this.phaserLayers.find((layer) => layer.layer.name === layerName); return this.phaserLayers.find((layer) => layer.layer.name === layerName);
} }
public findPhaserLayers(groupName: string): TilemapLayer[] {
return this.phaserLayers.filter((l) => l.layer.name.includes(groupName));
}
public addTerrain(terrain: Phaser.Tilemaps.Tileset): void { public addTerrain(terrain: Phaser.Tilemaps.Tileset): void {
for (const phaserLayer of this.phaserLayers) { for (const phaserLayer of this.phaserLayers) {
phaserLayer.tileset.push(terrain); phaserLayer.tileset.push(terrain);
@ -198,37 +205,45 @@ export class GameMap {
private putTileInFlatLayer(index: number, x: number, y: number, layer: string): void { private putTileInFlatLayer(index: number, x: number, y: number, layer: string): void {
const fLayer = this.findLayer(layer); const fLayer = this.findLayer(layer);
if (fLayer == undefined) { if (fLayer == undefined) {
console.error("The layer that you want to change doesn't exist."); console.error("The layer '" + layer + "' that you want to change doesn't exist.");
return; return;
} }
if (fLayer.type !== "tilelayer") { if (fLayer.type !== "tilelayer") {
console.error("The layer that you want to change is not a tilelayer. Tile can only be put in tilelayer."); console.error(
"The layer '" +
layer +
"' that you want to change is not a tilelayer. Tile can only be put in tilelayer."
);
return; return;
} }
if (typeof fLayer.data === "string") { if (typeof fLayer.data === "string") {
console.error("Data of the layer that you want to change is only readable."); console.error("Data of the layer '" + layer + "' that you want to change is only readable.");
return; return;
} }
fLayer.data[x + y * fLayer.height] = index; fLayer.data[x + y * fLayer.width] = index;
} }
public putTile(tile: string | number, x: number, y: number, layer: string): void { public putTile(tile: string | number | null, x: number, y: number, layer: string): void {
const phaserLayer = this.findPhaserLayer(layer); const phaserLayer = this.findPhaserLayer(layer);
if (phaserLayer) { if (phaserLayer) {
if (tile === null) {
phaserLayer.putTileAt(-1, x, y);
return;
}
const tileIndex = this.getIndexForTileType(tile); const tileIndex = this.getIndexForTileType(tile);
if (tileIndex !== undefined) { if (tileIndex !== undefined) {
this.putTileInFlatLayer(tileIndex, x, y, layer); this.putTileInFlatLayer(tileIndex, x, y, layer);
const phaserTile = phaserLayer.putTileAt(tileIndex, x, y); const phaserTile = phaserLayer.putTileAt(tileIndex, x, y);
for (const property of this.getTileProperty(tileIndex)) { for (const property of this.getTileProperty(tileIndex)) {
if (property.name === "collides" && property.value === "true") { if (property.name === "collides" && property.value) {
phaserTile.setCollision(true); phaserTile.setCollision(true);
} }
} }
} else { } else {
console.error("The tile that you want to place doesn't exist."); console.error("The tile '" + tile + "' that you want to place doesn't exist.");
} }
} else { } else {
console.error("The layer that you want to change is not a tilelayer. Tile can only be put in tilelayer."); console.error("The layer '" + layer + "' does not exist (or is not a tilelaye).");
} }
} }

View File

@ -87,6 +87,7 @@ import { videoFocusStore } from "../../Stores/VideoFocusStore";
import { biggestAvailableAreaStore } from "../../Stores/BiggestAvailableAreaStore"; import { biggestAvailableAreaStore } from "../../Stores/BiggestAvailableAreaStore";
import { SharedVariablesManager } from "./SharedVariablesManager"; import { SharedVariablesManager } from "./SharedVariablesManager";
import { playersStore } from "../../Stores/PlayersStore"; import { playersStore } from "../../Stores/PlayersStore";
import { chatVisibilityStore } from "../../Stores/ChatStore";
export interface GameSceneInitInterface { export interface GameSceneInitInterface {
initPosition: PointInterface | null; initPosition: PointInterface | null;
@ -164,9 +165,10 @@ export class GameScene extends DirtyScene {
private createPromiseResolve!: (value?: void | PromiseLike<void>) => void; private createPromiseResolve!: (value?: void | PromiseLike<void>) => void;
private iframeSubscriptionList!: Array<Subscription>; private iframeSubscriptionList!: Array<Subscription>;
private peerStoreUnsubscribe!: () => void; private peerStoreUnsubscribe!: () => 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;
@ -200,14 +202,14 @@ export class GameScene extends DirtyScene {
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;
@ -459,11 +461,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") {
@ -476,7 +480,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(
@ -567,6 +571,10 @@ export class GameScene extends DirtyScene {
} }
oldPeerNumber = newPeerNumber; oldPeerNumber = newPeerNumber;
}); });
this.chatVisibilityUnsubscribe = chatVisibilityStore.subscribe((v) => {
this.openChatIcon.setVisible(!v);
});
} }
/** /**
@ -577,7 +585,7 @@ export class GameScene extends DirtyScene {
connectionManager connectionManager
.connectToRoomSocket( .connectToRoomSocket(
this.RoomId, this.roomUrl,
this.playerName, this.playerName,
this.characterLayers, this.characterLayers,
{ {
@ -688,12 +696,12 @@ export class GameScene extends DirtyScene {
const self = this; const self = this;
this.simplePeer.registerPeerConnectionListener({ this.simplePeer.registerPeerConnectionListener({
onConnect(peer) { onConnect(peer) {
self.openChatIcon.setVisible(true); //self.openChatIcon.setVisible(true);
audioManager.decreaseVolume(); audioManager.decreaseVolume();
}, },
onDisconnect(userId: number) { onDisconnect(userId: number) {
if (self.simplePeer.getNbConnections() === 0) { if (self.simplePeer.getNbConnections() === 0) {
self.openChatIcon.setVisible(false); //self.openChatIcon.setVisible(false);
audioManager.restoreVolume(); audioManager.restoreVolume();
} }
}, },
@ -707,7 +715,11 @@ export class GameScene extends DirtyScene {
}); });
// Set up variables manager // Set up variables manager
this.sharedVariablesManager = new SharedVariablesManager(this.connection, this.gameMap, onConnect.room.variables); 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);
@ -768,10 +780,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) {
@ -996,9 +1011,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()));
}); });
}); });
}) })
@ -1056,7 +1071,7 @@ ${escapedMessage}
startLayerName: this.startPositionCalculator.startLayerName, startLayerName: this.startPositionCalculator.startLayerName,
uuid: localUserStore.getLocalUser()?.uuid, uuid: localUserStore.getLocalUser()?.uuid,
nickname: this.playerName, 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, variables: this.sharedVariablesManager.variables,
}; };
@ -1080,53 +1095,86 @@ ${escapedMessage}
console.warn('Could not find layer "' + layerName + '" when calling setProperty'); console.warn('Could not find layer "' + layerName + '" when calling setProperty');
return; return;
} }
if (propertyName === "exitUrl" && typeof propertyValue === "string") {
this.loadNextGameFromExitUrl(propertyValue);
}
if (layer.properties === undefined) { if (layer.properties === undefined) {
layer.properties = []; layer.properties = [];
} }
const property = layer.properties.find((property) => property.name === propertyName); const property = layer.properties.find((property) => property.name === propertyName);
if (property === undefined) { if (property === undefined) {
if (propertyValue === undefined) {
return;
}
layer.properties.push({ name: propertyName, type: typeof propertyValue, value: propertyValue }); layer.properties.push({ name: propertyName, type: typeof propertyValue, value: propertyValue });
return; return;
} }
if (propertyValue === undefined) {
const index = layer.properties.indexOf(property);
layer.properties.splice(index, 1);
}
property.value = propertyValue; property.value = propertyValue;
} }
private setLayerVisibility(layerName: string, visible: boolean): void { private setLayerVisibility(layerName: string, visible: boolean): void {
const phaserLayer = this.gameMap.findPhaserLayer(layerName); const phaserLayer = this.gameMap.findPhaserLayer(layerName);
if (phaserLayer === undefined) { if (phaserLayer != undefined) {
console.warn('Could not find layer "' + layerName + '" when calling WA.hideLayer / WA.showLayer'); phaserLayer.setVisible(visible);
return; phaserLayer.setCollisionByProperty({ collides: true }, visible);
} else {
const phaserLayers = this.gameMap.findPhaserLayers(layerName + "/");
if (phaserLayers === []) {
console.warn(
'Could not find layer with name that contains "' +
layerName +
'" when calling WA.hideLayer / WA.showLayer'
);
return;
}
for (let i = 0; i < phaserLayers.length; i++) {
phaserLayers[i].setVisible(visible);
phaserLayers[i].setCollisionByProperty({ collides: true }, visible);
}
} }
phaserLayer.setVisible(visible); this.markDirty();
this.dirty = true;
} }
private getMapDirUrl(): string { private getMapDirUrl(): string {
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: unknown) {
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);
@ -1153,6 +1201,7 @@ ${escapedMessage}
this.pinchManager?.destroy(); this.pinchManager?.destroy();
this.emoteManager.destroy(); this.emoteManager.destroy();
this.peerStoreUnsubscribe(); this.peerStoreUnsubscribe();
this.chatVisibilityUnsubscribe();
this.biggestAvailableAreaStoreUnsubscribe(); this.biggestAvailableAreaStoreUnsubscribe();
iframeListener.unregisterAnswerer("getState"); iframeListener.unregisterAnswerer("getState");
this.sharedVariablesManager?.close(); this.sharedVariablesManager?.close();
@ -1218,11 +1267,18 @@ ${escapedMessage}
.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, this.scene).catch(() => {}); return gameManager.loadMap(room, this.scene);
} catch (e: unknown) {
console.warn('Error while pre-loading exit room "' + exitRoomPath.toString() + '"', e);
}
} }
//todo: in a dedicated class/function? //todo: in a dedicated class/function?

View File

@ -7,4 +7,5 @@ export interface PlayerInterface {
visitCardUrl: string | null; visitCardUrl: string | null;
companion: string | null; companion: string | null;
userUuid: string; userUuid: string;
color?: string;
} }

View File

@ -19,6 +19,7 @@ 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 { playersStore } from "../../Stores/PlayersStore";
import { mediaManager } from "../../WebRtc/MediaManager";
export const MenuSceneName = "MenuScene"; export const MenuSceneName = "MenuScene";
const gameMenuKey = "gameMenu"; const gameMenuKey = "gameMenu";
@ -98,6 +99,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");
@ -357,6 +362,9 @@ export class MenuScene extends Phaser.Scene {
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);
@ -419,4 +427,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

@ -0,0 +1,118 @@
import { writable } from "svelte/store";
import { playersStore } from "./PlayersStore";
import type { PlayerInterface } from "../Phaser/Game/PlayerInterface";
export const chatVisibilityStore = writable(false);
export const chatInputFocusStore = writable(false);
export const newChatMessageStore = writable<string | null>(null);
export enum ChatMessageTypes {
text = 1,
me,
userIncoming,
userOutcoming,
}
export interface ChatMessage {
type: ChatMessageTypes;
date: Date;
author?: PlayerInterface;
targets?: PlayerInterface[];
text?: string[];
}
function getAuthor(authorId: number): PlayerInterface {
const author = playersStore.getPlayerById(authorId);
if (!author) {
throw "Could not find data for author " + authorId;
}
return author;
}
function createChatMessagesStore() {
const { subscribe, update } = writable<ChatMessage[]>([]);
return {
subscribe,
addIncomingUser(authorId: number) {
update((list) => {
const lastMessage = list[list.length - 1];
if (lastMessage && lastMessage.type === ChatMessageTypes.userIncoming && lastMessage.targets) {
lastMessage.targets.push(getAuthor(authorId));
} else {
list.push({
type: ChatMessageTypes.userIncoming,
targets: [getAuthor(authorId)],
date: new Date(),
});
}
return list;
});
},
addOutcomingUser(authorId: number) {
update((list) => {
const lastMessage = list[list.length - 1];
if (lastMessage && lastMessage.type === ChatMessageTypes.userOutcoming && lastMessage.targets) {
lastMessage.targets.push(getAuthor(authorId));
} else {
list.push({
type: ChatMessageTypes.userOutcoming,
targets: [getAuthor(authorId)],
date: new Date(),
});
}
return list;
});
},
addPersonnalMessage(text: string) {
newChatMessageStore.set(text);
update((list) => {
const lastMessage = list[list.length - 1];
if (lastMessage && lastMessage.type === ChatMessageTypes.me && lastMessage.text) {
lastMessage.text.push(text);
} else {
list.push({
type: ChatMessageTypes.me,
text: [text],
date: new Date(),
});
}
return list;
});
},
addExternalMessage(authorId: number, text: string) {
update((list) => {
const lastMessage = list[list.length - 1];
if (lastMessage && lastMessage.type === ChatMessageTypes.text && lastMessage.text) {
lastMessage.text.push(text);
} else {
list.push({
type: ChatMessageTypes.text,
text: [text],
author: getAuthor(authorId),
date: new Date(),
});
}
return list;
});
},
};
}
export const chatMessagesStore = createChatMessagesStore();
function createChatSubMenuVisibilityStore() {
const { subscribe, update } = writable<string>("");
return {
subscribe,
openSubMenu(playerName: string, index: number) {
const id = playerName + index;
update((oldValue) => {
return oldValue === id ? "" : id;
});
},
};
}
export const chatSubMenuVisbilityStore = createChatSubMenuVisibilityStore();

View File

@ -1,6 +1,7 @@
import { writable } from "svelte/store"; import { writable } from "svelte/store";
import type { PlayerInterface } from "../Phaser/Game/PlayerInterface"; import type { PlayerInterface } from "../Phaser/Game/PlayerInterface";
import type { RoomConnection } from "../Connexion/RoomConnection"; import type { RoomConnection } from "../Connexion/RoomConnection";
import { getRandomColor } from "../WebRtc/ColorGenerator";
/** /**
* A store that contains the list of players currently known. * A store that contains the list of players currently known.
@ -24,6 +25,7 @@ function createPlayersStore() {
visitCardUrl: message.visitCardUrl, visitCardUrl: message.visitCardUrl,
companion: message.companion, companion: message.companion,
userUuid: message.userUuid, userUuid: message.userUuid,
color: getRandomColor(),
}); });
return users; return users;
}); });

View File

@ -1,10 +1,11 @@
import {derived} from "svelte/store"; import { derived } from "svelte/store";
import {consoleGlobalMessageManagerFocusStore} from "./ConsoleGlobalMessageManagerStore"; import { consoleGlobalMessageManagerFocusStore } from "./ConsoleGlobalMessageManagerStore";
import { chatInputFocusStore } from "./ChatStore";
//derived from the focus on Menu, ConsoleGlobal, Chat and ... //derived from the focus on Menu, ConsoleGlobal, Chat and ...
export const enableUserInputsStore = derived( export const enableUserInputsStore = derived(
consoleGlobalMessageManagerFocusStore, [consoleGlobalMessageManagerFocusStore, chatInputFocusStore],
($consoleGlobalMessageManagerFocusStore) => { ([$consoleGlobalMessageManagerFocusStore, $chatInputFocusStore]) => {
return !$consoleGlobalMessageManagerFocusStore; return !$consoleGlobalMessageManagerFocusStore && !$chatInputFocusStore;
} }
); );

View File

@ -0,0 +1,52 @@
export function getRandomColor(): string {
const golden_ratio_conjugate = 0.618033988749895;
let hue = Math.random();
hue += golden_ratio_conjugate;
hue %= 1;
return hsv_to_rgb(hue, 0.5, 0.95);
}
//todo: test this.
function hsv_to_rgb(hue: number, saturation: number, brightness: number): string {
const h_i = Math.floor(hue * 6);
const f = hue * 6 - h_i;
const p = brightness * (1 - saturation);
const q = brightness * (1 - f * saturation);
const t = brightness * (1 - (1 - f) * saturation);
let r: number, g: number, b: number;
switch (h_i) {
case 0:
r = brightness;
g = t;
b = p;
break;
case 1:
r = q;
g = brightness;
b = p;
break;
case 2:
r = p;
g = brightness;
b = t;
break;
case 3:
r = p;
g = q;
b = brightness;
break;
case 4:
r = t;
g = p;
b = brightness;
break;
case 5:
r = brightness;
g = p;
b = q;
break;
default:
throw "h_i cannot be " + h_i;
}
return "#" + Math.floor(r * 256).toString(16) + Math.floor(g * 256).toString(16) + Math.floor(b * 256).toString(16);
}

View File

@ -1,232 +1,12 @@
import { HtmlUtils } from "./HtmlUtils";
import type { UserInputManager } from "../Phaser/UserInput/UserInputManager";
import { connectionManager } from "../Connexion/ConnectionManager";
import { GameConnexionTypes } from "../Url/UrlManager";
import { iframeListener } from "../Api/IframeListener"; import { iframeListener } from "../Api/IframeListener";
import { showReportScreenStore } from "../Stores/ShowReportScreenStore"; import { chatMessagesStore, chatVisibilityStore } from "../Stores/ChatStore";
export type SendMessageCallback = (message: string) => void;
export class DiscussionManager { export class DiscussionManager {
private mainContainer: HTMLDivElement;
private divDiscuss?: HTMLDivElement;
private divParticipants?: HTMLDivElement;
private nbpParticipants?: HTMLParagraphElement;
private divMessages?: HTMLParagraphElement;
private participants: Map<number | string, HTMLDivElement> = new Map<number | string, HTMLDivElement>();
private activeDiscussion: boolean = false;
private sendMessageCallBack: Map<number | string, SendMessageCallback> = new Map<
number | string,
SendMessageCallback
>();
private userInputManager?: UserInputManager;
constructor() { constructor() {
this.mainContainer = HtmlUtils.getElementByIdOrFail<HTMLDivElement>("main-container");
this.createDiscussPart(""); //todo: why do we always use empty string?
iframeListener.chatStream.subscribe((chatEvent) => { iframeListener.chatStream.subscribe((chatEvent) => {
this.addMessage(chatEvent.author, chatEvent.message, false); chatMessagesStore.addExternalMessage(parseInt(chatEvent.author), chatEvent.message);
this.showDiscussion(); chatVisibilityStore.set(true);
}); });
this.onSendMessageCallback("iframe_listener", (message) => {
iframeListener.sendUserInputChat(message);
});
}
private createDiscussPart(name: string) {
this.divDiscuss = document.createElement("div");
this.divDiscuss.classList.add("discussion");
const buttonCloseDiscussion: HTMLButtonElement = document.createElement("button");
buttonCloseDiscussion.classList.add("close-btn");
buttonCloseDiscussion.innerHTML = `<img src="resources/logos/close.svg"/>`;
buttonCloseDiscussion.addEventListener("click", () => {
this.hideDiscussion();
});
this.divDiscuss.appendChild(buttonCloseDiscussion);
const myName: HTMLParagraphElement = document.createElement("p");
myName.innerText = name.toUpperCase();
this.nbpParticipants = document.createElement("p");
this.nbpParticipants.innerText = "PARTICIPANTS (1)";
this.divParticipants = document.createElement("div");
this.divParticipants.classList.add("participants");
this.divMessages = document.createElement("div");
this.divMessages.classList.add("messages");
this.divMessages.innerHTML = "<h2>Local messages</h2>";
this.divDiscuss.appendChild(myName);
this.divDiscuss.appendChild(this.nbpParticipants);
this.divDiscuss.appendChild(this.divParticipants);
this.divDiscuss.appendChild(this.divMessages);
const sendDivMessage: HTMLDivElement = document.createElement("div");
sendDivMessage.classList.add("send-message");
const inputMessage: HTMLInputElement = document.createElement("input");
inputMessage.onfocus = () => {
if (this.userInputManager) {
this.userInputManager.disableControls();
}
};
inputMessage.onblur = () => {
if (this.userInputManager) {
this.userInputManager.restoreControls();
}
};
inputMessage.type = "text";
inputMessage.addEventListener("keyup", (event: KeyboardEvent) => {
if (event.key === "Enter") {
event.preventDefault();
if (inputMessage.value === null || inputMessage.value === "" || inputMessage.value === undefined) {
return;
}
this.addMessage(name, inputMessage.value, true);
for (const callback of this.sendMessageCallBack.values()) {
callback(inputMessage.value);
}
inputMessage.value = "";
}
});
sendDivMessage.appendChild(inputMessage);
this.divDiscuss.appendChild(sendDivMessage);
//append in main container
this.mainContainer.appendChild(this.divDiscuss);
this.addParticipant("me", "Moi", undefined, true);
}
public addParticipant(
userId: number | "me",
name: string | undefined,
img?: string | undefined,
isMe: boolean = false
) {
const divParticipant: HTMLDivElement = document.createElement("div");
divParticipant.classList.add("participant");
divParticipant.id = `participant-${userId}`;
const divImgParticipant: HTMLImageElement = document.createElement("img");
divImgParticipant.src = "resources/logos/boy.svg";
if (img !== undefined) {
divImgParticipant.src = img;
}
const divPParticipant: HTMLParagraphElement = document.createElement("p");
if (!name) {
name = "Anonymous";
}
divPParticipant.innerText = name;
divParticipant.appendChild(divImgParticipant);
divParticipant.appendChild(divPParticipant);
if (
!isMe &&
connectionManager.getConnexionType &&
connectionManager.getConnexionType !== GameConnexionTypes.anonymous &&
userId !== "me"
) {
const reportBanUserAction: HTMLButtonElement = document.createElement("button");
reportBanUserAction.classList.add("report-btn");
reportBanUserAction.innerText = "Report";
reportBanUserAction.addEventListener("click", () => {
showReportScreenStore.set({ userId: userId, userName: name ? name : "" });
});
divParticipant.appendChild(reportBanUserAction);
}
this.divParticipants?.appendChild(divParticipant);
this.participants.set(userId, divParticipant);
this.updateParticipant(this.participants.size);
}
public updateParticipant(nb: number) {
if (!this.nbpParticipants) {
return;
}
this.nbpParticipants.innerText = `PARTICIPANTS (${nb})`;
}
public addMessage(name: string, message: string, isMe: boolean = false) {
const divMessage: HTMLDivElement = document.createElement("div");
divMessage.classList.add("message");
if (isMe) {
divMessage.classList.add("me");
}
const pMessage: HTMLParagraphElement = document.createElement("p");
const date = new Date();
if (isMe) {
name = "Me";
} else {
name = HtmlUtils.escapeHtml(name);
}
pMessage.innerHTML = `<span style="font-weight: bold">${name}</span>
<span style="color:#bac2cc;display:inline-block;font-size:12px;">
${date.getHours()}:${date.getMinutes()}
</span>`;
divMessage.appendChild(pMessage);
const userMessage: HTMLParagraphElement = document.createElement("p");
userMessage.innerHTML = HtmlUtils.urlify(message);
userMessage.classList.add("body");
divMessage.appendChild(userMessage);
this.divMessages?.appendChild(divMessage);
//automatic scroll when there are new message
setTimeout(() => {
this.divMessages?.scroll({
top: this.divMessages?.scrollTop + divMessage.getBoundingClientRect().y,
behavior: "smooth",
});
}, 200);
}
public removeParticipant(userId: number | string) {
const element = this.participants.get(userId);
if (element) {
element.remove();
this.participants.delete(userId);
}
//if all participant leave, hide discussion button
this.sendMessageCallBack.delete(userId);
}
public onSendMessageCallback(userId: string | number, callback: SendMessageCallback): void {
this.sendMessageCallBack.set(userId, callback);
}
get activatedDiscussion() {
return this.activeDiscussion;
}
private showDiscussion() {
this.activeDiscussion = true;
this.divDiscuss?.classList.add("active");
}
private hideDiscussion() {
this.activeDiscussion = false;
this.divDiscuss?.classList.remove("active");
}
public setUserInputManager(userInputManager: UserInputManager) {
this.userInputManager = userInputManager;
}
public showDiscussionPart() {
this.showDiscussion();
} }
} }

View File

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

View File

@ -1,16 +1,10 @@
import { DivImportance, layoutManager } from "./LayoutManager"; import { layoutManager } from "./LayoutManager";
import { HtmlUtils } from "./HtmlUtils"; import { HtmlUtils } from "./HtmlUtils";
import { discussionManager, SendMessageCallback } from "./DiscussionManager";
import type { UserInputManager } from "../Phaser/UserInput/UserInputManager"; import type { UserInputManager } from "../Phaser/UserInput/UserInputManager";
import { localUserStore } from "../Connexion/LocalUserStore";
import type { UserSimplePeerInterface } from "./SimplePeer";
import { SoundMeter } from "../Phaser/Components/SoundMeter";
import { DISABLE_NOTIFICATIONS } from "../Enum/EnvironmentVariable";
import { localStreamStore } from "../Stores/MediaStore"; import { localStreamStore } from "../Stores/MediaStore";
import { screenSharingLocalStreamStore } from "../Stores/ScreenSharingStore"; import { screenSharingLocalStreamStore } from "../Stores/ScreenSharingStore";
import { helpCameraSettingsVisibleStore } from "../Stores/HelpCameraSettingsStore"; import { helpCameraSettingsVisibleStore } from "../Stores/HelpCameraSettingsStore";
export type UpdatedLocalStreamCallback = (media: MediaStream | null) => void;
export type StartScreenSharingCallback = (media: MediaStream) => void; export type StartScreenSharingCallback = (media: MediaStream) => void;
export type StopScreenSharingCallback = (media: MediaStream) => void; export type StopScreenSharingCallback = (media: MediaStream) => void;
@ -21,16 +15,11 @@ export class MediaManager {
startScreenSharingCallBacks: Set<StartScreenSharingCallback> = new Set<StartScreenSharingCallback>(); startScreenSharingCallBacks: Set<StartScreenSharingCallback> = new Set<StartScreenSharingCallback>();
stopScreenSharingCallBacks: Set<StopScreenSharingCallback> = new Set<StopScreenSharingCallback>(); stopScreenSharingCallBacks: Set<StopScreenSharingCallback> = new Set<StopScreenSharingCallback>();
private focused: boolean = true;
private triggerCloseJistiFrame: Map<String, Function> = new Map<String, Function>(); private triggerCloseJistiFrame: Map<String, Function> = new Map<String, Function>();
private userInputManager?: UserInputManager; private userInputManager?: UserInputManager;
constructor() { constructor() {
//Check of ask notification navigator permission
this.getNotification();
localStreamStore.subscribe((result) => { localStreamStore.subscribe((result) => {
if (result.type === "error") { if (result.type === "error") {
console.error(result.error); console.error(result.error);
@ -182,67 +171,35 @@ export class MediaManager {
} }
} }
public addNewMessage(name: string, message: string, isMe: boolean = false) {
discussionManager.addMessage(name, message, isMe);
//when there are new message, show discussion
if (!discussionManager.activatedDiscussion) {
discussionManager.showDiscussionPart();
}
}
public addSendMessageCallback(userId: string | number, callback: SendMessageCallback) {
discussionManager.onSendMessageCallback(userId, callback);
}
public setUserInputManager(userInputManager: UserInputManager) { public setUserInputManager(userInputManager: UserInputManager) {
this.userInputManager = userInputManager; this.userInputManager = userInputManager;
discussionManager.setUserInputManager(userInputManager);
} }
public getNotification() { public hasNotification(): boolean {
//Get notification return Notification.permission === "granted";
if (!DISABLE_NOTIFICATIONS && window.Notification && Notification.permission !== "granted") {
if (this.checkNotificationPromise()) {
Notification.requestPermission().catch((err) => {
console.error(`Notification permission error`, err);
});
} else {
Notification.requestPermission();
}
}
} }
/** public requestNotification() {
* Return true if the browser supports the modern version of the Notification API (which is Promise based) or false if (window.Notification && Notification.permission !== "granted") {
* if we are on Safari... return Notification.requestPermission();
* } else {
* See https://developer.mozilla.org/en-US/docs/Web/API/Notifications_API/Using_the_Notifications_API return Promise.reject();
*/
private checkNotificationPromise(): boolean {
try {
Notification.requestPermission().then();
} catch (e) {
return false;
} }
return true;
} }
public createNotification(userName: string) { public createNotification(userName: string) {
if (this.focused) { if (document.hasFocus()) {
return; return;
} }
if (window.Notification && Notification.permission === "granted") {
const title = "WorkAdventure"; if (this.hasNotification()) {
const title = `${userName} wants to discuss with you`;
const options = { const options = {
body: `Hi! ${userName} wants to discuss with you, don't be afraid!`,
icon: "/resources/logos/logo-WA-min.png", icon: "/resources/logos/logo-WA-min.png",
image: "/resources/logos/logo-WA-min.png", image: "/resources/logos/logo-WA-min.png",
badge: "/resources/logos/logo-WA-min.png", badge: "/resources/logos/logo-WA-min.png",
}; };
new Notification(title, options); new Notification(title, options);
//new Notification(`Hi! ${userName} wants to discuss with you, don't be afraid!`);
} }
} }
} }

View File

@ -1,11 +1,10 @@
import type * as SimplePeerNamespace from "simple-peer"; import type * as SimplePeerNamespace from "simple-peer";
import { mediaManager } from "./MediaManager";
import { STUN_SERVER, TURN_PASSWORD, TURN_SERVER, TURN_USER } from "../Enum/EnvironmentVariable";
import type { RoomConnection } from "../Connexion/RoomConnection"; import type { RoomConnection } from "../Connexion/RoomConnection";
import { MESSAGE_TYPE_CONSTRAINT, PeerStatus } from "./VideoPeer"; import { MESSAGE_TYPE_CONSTRAINT, PeerStatus } from "./VideoPeer";
import type { UserSimplePeerInterface } from "./SimplePeer"; import type { UserSimplePeerInterface } from "./SimplePeer";
import { Readable, readable, writable, Writable } from "svelte/store"; import { Readable, readable } from "svelte/store";
import { videoFocusStore } from "../Stores/VideoFocusStore"; import { videoFocusStore } from "../Stores/VideoFocusStore";
import { getIceServersConfig } from "../Components/Video/utils";
const Peer: SimplePeerNamespace.SimplePeer = require("simple-peer"); const Peer: SimplePeerNamespace.SimplePeer = require("simple-peer");
@ -32,21 +31,9 @@ export class ScreenSharingPeer extends Peer {
stream: MediaStream | null stream: MediaStream | null
) { ) {
super({ super({
initiator: initiator ? initiator : false, initiator,
//reconnectTimer: 10000,
config: { config: {
iceServers: [ iceServers: getIceServersConfig(user),
{
urls: STUN_SERVER.split(","),
},
TURN_SERVER !== ""
? {
urls: TURN_SERVER.split(","),
username: user.webRtcUser || TURN_USER,
credential: user.webRtcPassword || TURN_PASSWORD,
}
: undefined,
].filter((value) => value !== undefined),
}, },
}); });

View File

@ -12,6 +12,7 @@ import { localStreamStore, LocalStreamStoreValue, obtainedMediaConstraintStore }
import { screenSharingLocalStreamStore } from "../Stores/ScreenSharingStore"; import { screenSharingLocalStreamStore } from "../Stores/ScreenSharingStore";
import { discussionManager } from "./DiscussionManager"; import { discussionManager } from "./DiscussionManager";
import { playersStore } from "../Stores/PlayersStore"; import { playersStore } from "../Stores/PlayersStore";
import { newChatMessageStore } from "../Stores/ChatStore";
export interface UserSimplePeerInterface { export interface UserSimplePeerInterface {
userId: number; userId: number;
@ -155,27 +156,11 @@ export class SimplePeer {
const name = this.getName(user.userId); const name = this.getName(user.userId);
discussionManager.removeParticipant(user.userId);
this.lastWebrtcUserName = user.webRtcUser; this.lastWebrtcUserName = user.webRtcUser;
this.lastWebrtcPassword = user.webRtcPassword; this.lastWebrtcPassword = user.webRtcPassword;
const peer = new VideoPeer(user, user.initiator ? user.initiator : false, name, this.Connection, localStream); const peer = new VideoPeer(user, user.initiator ? user.initiator : false, name, this.Connection, localStream);
//permit to send message
mediaManager.addSendMessageCallback(user.userId, (message: string) => {
peer.write(
new Buffer(
JSON.stringify({
type: MESSAGE_TYPE_MESSAGE,
name: this.myName.toUpperCase(),
userId: this.userId,
message: message,
})
)
);
});
peer.toClose = false; peer.toClose = false;
// When a connection is established to a video stream, and if a screen sharing is taking place, // When a connection is established to a video stream, and if a screen sharing is taking place,
// the user sharing screen should also initiate a connection to the remote user! // the user sharing screen should also initiate a connection to the remote user!

View File

@ -1,14 +1,14 @@
import type * as SimplePeerNamespace from "simple-peer"; import type * as SimplePeerNamespace from "simple-peer";
import { mediaManager } from "./MediaManager"; import { mediaManager } from "./MediaManager";
import { STUN_SERVER, TURN_PASSWORD, TURN_SERVER, TURN_USER } from "../Enum/EnvironmentVariable";
import type { RoomConnection } from "../Connexion/RoomConnection"; import type { RoomConnection } from "../Connexion/RoomConnection";
import { blackListManager } from "./BlackListManager"; import { blackListManager } from "./BlackListManager";
import type { Subscription } from "rxjs"; import type { Subscription } from "rxjs";
import type { UserSimplePeerInterface } from "./SimplePeer"; import type { UserSimplePeerInterface } from "./SimplePeer";
import { get, readable, Readable } from "svelte/store"; import { get, readable, Readable, Unsubscriber } from "svelte/store";
import { obtainedMediaConstraintStore } from "../Stores/MediaStore"; import { obtainedMediaConstraintStore } from "../Stores/MediaStore";
import { discussionManager } from "./DiscussionManager";
import { playersStore } from "../Stores/PlayersStore"; import { playersStore } from "../Stores/PlayersStore";
import { chatMessagesStore, chatVisibilityStore, newChatMessageStore } from "../Stores/ChatStore";
import { getIceServersConfig } from "../Components/Video/utils";
const Peer: SimplePeerNamespace.SimplePeer = require("simple-peer"); const Peer: SimplePeerNamespace.SimplePeer = require("simple-peer");
@ -34,6 +34,8 @@ export class VideoPeer extends Peer {
public readonly streamStore: Readable<MediaStream | null>; public readonly streamStore: Readable<MediaStream | null>;
public readonly statusStore: Readable<PeerStatus>; public readonly statusStore: Readable<PeerStatus>;
public readonly constraintsStore: Readable<MediaStreamConstraints | null>; public readonly constraintsStore: Readable<MediaStreamConstraints | null>;
private newMessageunsubscriber: Unsubscriber | null = null;
private closing: Boolean = false; //this is used to prevent destroy() from being called twice
constructor( constructor(
public user: UserSimplePeerInterface, public user: UserSimplePeerInterface,
@ -43,21 +45,9 @@ export class VideoPeer extends Peer {
localStream: MediaStream | null localStream: MediaStream | null
) { ) {
super({ super({
initiator: initiator ? initiator : false, initiator,
//reconnectTimer: 10000,
config: { config: {
iceServers: [ iceServers: getIceServersConfig(user),
{
urls: STUN_SERVER.split(","),
},
TURN_SERVER !== ""
? {
urls: TURN_SERVER.split(","),
username: user.webRtcUser || TURN_USER,
credential: user.webRtcPassword || TURN_PASSWORD,
}
: undefined,
].filter((value) => value !== undefined),
}, },
}); });
@ -147,6 +137,20 @@ export class VideoPeer extends Peer {
this.on("connect", () => { this.on("connect", () => {
this._connected = true; this._connected = true;
chatMessagesStore.addIncomingUser(this.userId);
this.newMessageunsubscriber = newChatMessageStore.subscribe((newMessage) => {
if (!newMessage) return;
this.write(
new Buffer(
JSON.stringify({
type: MESSAGE_TYPE_MESSAGE,
message: newMessage,
})
)
); //send more data
newChatMessageStore.set(null); //This is to prevent a newly created SimplePeer to send an old message a 2nd time. Is there a better way?
});
}); });
this.on("data", (chunk: Buffer) => { this.on("data", (chunk: Buffer) => {
@ -164,8 +168,9 @@ export class VideoPeer extends Peer {
mediaManager.disabledVideoByUserId(this.userId); mediaManager.disabledVideoByUserId(this.userId);
} }
} else if (message.type === MESSAGE_TYPE_MESSAGE) { } else if (message.type === MESSAGE_TYPE_MESSAGE) {
if (!blackListManager.isBlackListed(message.userId)) { if (!blackListManager.isBlackListed(this.userUuid)) {
mediaManager.addNewMessage(message.name, message.message); chatMessagesStore.addExternalMessage(this.userId, message.message);
chatVisibilityStore.set(true);
} }
} else if (message.type === MESSAGE_TYPE_BLOCKED) { } else if (message.type === MESSAGE_TYPE_BLOCKED) {
//FIXME when A blacklists B, the output stream from A is muted in B's js client. This is insecure since B can manipulate the code to unmute A stream. //FIXME when A blacklists B, the output stream from A is muted in B's js client. This is insecure since B can manipulate the code to unmute A stream.
@ -245,18 +250,18 @@ export class VideoPeer extends Peer {
/** /**
* This is triggered twice. Once by the server, and once by a remote client disconnecting * This is triggered twice. Once by the server, and once by a remote client disconnecting
*/ */
public destroy(error?: Error): void { public destroy(): void {
try { try {
this._connected = false; this._connected = false;
if (!this.toClose) { if (!this.toClose || this.closing) {
return; return;
} }
this.closing = true;
this.onBlockSubscribe.unsubscribe(); this.onBlockSubscribe.unsubscribe();
this.onUnBlockSubscribe.unsubscribe(); this.onUnBlockSubscribe.unsubscribe();
discussionManager.removeParticipant(this.userId); if (this.newMessageunsubscriber) this.newMessageunsubscriber();
// FIXME: I don't understand why "Closing connection with" message is displayed TWICE before "Nb users in peerConnectionArray" chatMessagesStore.addOutcomingUser(this.userId);
// I do understand the method closeConnection is called twice, but I don't understand how they manage to run in parallel. super.destroy();
super.destroy(error);
} catch (err) { } catch (err) {
console.error("VideoPeer::destroy", err); console.error("VideoPeer::destroy", err);
} }

View File

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

View File

@ -1,9 +1,5 @@
@import "~@fontsource/press-start-2p/index.css"; @import "~@fontsource/press-start-2p/index.css";
*{
font-family: PixelFont-7,monospace;
}
.nes-btn { .nes-btn {
font-family: "Press Start 2P"; font-family: "Press Start 2P";
} }

View File

@ -1,5 +1,5 @@
*{ *{
font-family: 'Open Sans', sans-serif; font-family: Lato;
cursor: url('./images/cursor_normal.png'), auto; cursor: url('./images/cursor_normal.png'), auto;
} }
* a, button, select{ * a, button, select{

View File

@ -1,58 +0,0 @@
import "jasmine";
import { Room } from "../../../src/Connexion/Room";
describe("Room getIdFromIdentifier()", () => {
it("should work with an absolute room id and no hash as parameter", () => {
const { roomId, hash } = Room.getIdFromIdentifier('/_/global/maps.workadventu.re/test2.json', '', '');
expect(roomId).toEqual('_/global/maps.workadventu.re/test2.json');
expect(hash).toEqual(null);
});
it("should work with an absolute room id and a hash as parameters", () => {
const { roomId, hash } = Room.getIdFromIdentifier('/_/global/maps.workadventu.re/test2.json#start', '', '');
expect(roomId).toEqual('_/global/maps.workadventu.re/test2.json');
expect(hash).toEqual("start");
});
it("should work with an absolute room id, regardless of baseUrl or instance", () => {
const { roomId, hash } = Room.getIdFromIdentifier('/_/global/maps.workadventu.re/test2.json', 'https://another.domain/_/global/test.json', 'lol');
expect(roomId).toEqual('_/global/maps.workadventu.re/test2.json');
expect(hash).toEqual(null);
});
it("should work with a relative file link and no hash as parameters", () => {
const { roomId, hash } = Room.getIdFromIdentifier('./test2.json', 'https://maps.workadventu.re/test.json', 'global');
expect(roomId).toEqual('_/global/maps.workadventu.re/test2.json');
expect(hash).toEqual(null);
});
it("should work with a relative file link with no dot", () => {
const { roomId, hash } = Room.getIdFromIdentifier('test2.json', 'https://maps.workadventu.re/test.json', 'global');
expect(roomId).toEqual('_/global/maps.workadventu.re/test2.json');
expect(hash).toEqual(null);
});
it("should work with a relative file link two levels deep", () => {
const { roomId, hash } = Room.getIdFromIdentifier('../floor1/Floor1.json', 'https://maps.workadventu.re/floor0/Floor0.json', 'global');
expect(roomId).toEqual('_/global/maps.workadventu.re/floor1/Floor1.json');
expect(hash).toEqual(null);
});
it("should work with a relative file link that rewrite the map domain", () => {
const { roomId, hash } = Room.getIdFromIdentifier('../../maps.workadventure.localhost/Floor1/floor1.json', 'https://maps.workadventu.re/floor0/Floor0.json', 'global');
expect(roomId).toEqual('_/global/maps.workadventure.localhost/Floor1/floor1.json');
expect(hash).toEqual(null);
});
it("should work with a relative file link that rewrite the map instance", () => {
const { roomId, hash } = Room.getIdFromIdentifier('../../../notglobal/maps.workadventu.re/Floor1/floor1.json', 'https://maps.workadventu.re/floor0/Floor0.json', 'global');
expect(roomId).toEqual('_/notglobal/maps.workadventu.re/Floor1/floor1.json');
expect(hash).toEqual(null);
});
it("should work with a relative file link that change the map type", () => {
const { roomId, hash } = Room.getIdFromIdentifier('../../../../@/tcm/is/great', 'https://maps.workadventu.re/floor0/Floor0.json', 'global');
expect(roomId).toEqual('@/tcm/is/great');
expect(hash).toEqual(null);
});
it("should work with a relative file link and a hash as parameters", () => {
const { roomId, hash } = Room.getIdFromIdentifier('./test2.json#start', 'https://maps.workadventu.re/test.json', 'global');
expect(roomId).toEqual('_/global/maps.workadventu.re/test2.json');
expect(hash).toEqual("start");
});
});

View File

@ -7,7 +7,6 @@ import MiniCssExtractPlugin from "mini-css-extract-plugin";
import sveltePreprocess from "svelte-preprocess"; import sveltePreprocess from "svelte-preprocess";
import ForkTsCheckerWebpackPlugin from "fork-ts-checker-webpack-plugin"; import ForkTsCheckerWebpackPlugin from "fork-ts-checker-webpack-plugin";
import NodePolyfillPlugin from "node-polyfill-webpack-plugin"; import NodePolyfillPlugin from "node-polyfill-webpack-plugin";
import { DISPLAY_TERMS_OF_USE } from "./src/Enum/EnvironmentVariable";
const mode = process.env.NODE_ENV ?? "development"; const mode = process.env.NODE_ENV ?? "development";
const buildNpmTypingsForApi = !!process.env.BUILD_TYPINGS; const buildNpmTypingsForApi = !!process.env.BUILD_TYPINGS;

View File

@ -262,10 +262,10 @@
"@types/mime" "^1" "@types/mime" "^1"
"@types/node" "*" "@types/node" "*"
"@types/simple-peer@^9.6.0": "@types/simple-peer@^9.11.1":
version "9.6.3" version "9.11.1"
resolved "https://registry.yarnpkg.com/@types/simple-peer/-/simple-peer-9.6.3.tgz#aa118a57e036f4ce2059a7e25367526a4764206d" resolved "https://registry.yarnpkg.com/@types/simple-peer/-/simple-peer-9.11.1.tgz#bef6ff1e75178d83438e33aa6a4df2fd98fded1d"
integrity sha512-zrXEBch9tF4NgkZDsGR3c1D0kq99M1bBCjzEyL0PVfEWzCIXrK64TuxRz3XKOx1B0KoEQ9kTs+AhMDuQaHy5RQ== integrity sha512-Pzqbau/WlivSXdRC0He2Wz/ANj2wbi4gzJrtysZz93jvOyI2jo/ibMjUe6AvPllFl/UO6QXT/A0Rcp44bDQB5A==
dependencies: dependencies:
"@types/node" "*" "@types/node" "*"
@ -5008,7 +5008,7 @@ signal-exit@^3.0.0, signal-exit@^3.0.2, signal-exit@^3.0.3:
resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.3.tgz#a1410c2edd8f077b08b4e253c8eacfcaf057461c" resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.3.tgz#a1410c2edd8f077b08b4e253c8eacfcaf057461c"
integrity sha512-VUJ49FC8U1OxwZLxIbTTrDvLnf/6TDgxZcK8wxR8zs13xpx7xbG60ndBlhNrFi2EMuFRoeDoJO7wthSLq42EjA== integrity sha512-VUJ49FC8U1OxwZLxIbTTrDvLnf/6TDgxZcK8wxR8zs13xpx7xbG60ndBlhNrFi2EMuFRoeDoJO7wthSLq42EjA==
simple-peer@^9.6.2: simple-peer@^9.11.0:
version "9.11.0" version "9.11.0"
resolved "https://registry.yarnpkg.com/simple-peer/-/simple-peer-9.11.0.tgz#e8d27609c7a610c3ddd75767da868e8daab67571" resolved "https://registry.yarnpkg.com/simple-peer/-/simple-peer-9.11.0.tgz#e8d27609c7a610c3ddd75767da868e8daab67571"
integrity sha512-qvdNu/dGMHBm2uQ7oLhQBMhYlrOZC1ywXNCH/i8I4etxR1vrjCnU6ZSQBptndB1gcakjo2+w4OHo7Sjza1SHxg== integrity sha512-qvdNu/dGMHBm2uQ7oLhQBMhYlrOZC1ywXNCH/i8I4etxR1vrjCnU6ZSQBptndB1gcakjo2+w4OHo7Sjza1SHxg==

View File

@ -1,11 +1,4 @@
{ "compressionlevel":-1, { "compressionlevel":-1,
"editorsettings":
{
"export":
{
"target":"."
}
},
"height":26, "height":26,
"infinite":false, "infinite":false,
"layers":[ "layers":[
@ -101,7 +94,7 @@
"opacity":1, "opacity":1,
"properties":[ "properties":[
{ {
"name":"exitSceneUrl", "name":"exitUrl",
"type":"string", "type":"string",
"value":"\/@\/tcm\/workadventure\/floor2#down-the-stairs" "value":"\/@\/tcm\/workadventure\/floor2#down-the-stairs"
}], }],
@ -119,7 +112,7 @@
"opacity":1, "opacity":1,
"properties":[ "properties":[
{ {
"name":"exitSceneUrl", "name":"exitUrl",
"type":"string", "type":"string",
"value":"\/@\/tcm\/workadventure\/floor2#down-the-stairs-secours" "value":"\/@\/tcm\/workadventure\/floor2#down-the-stairs-secours"
}], }],
@ -264,7 +257,7 @@
"nextobjectid":1, "nextobjectid":1,
"orientation":"orthogonal", "orientation":"orthogonal",
"renderorder":"right-down", "renderorder":"right-down",
"tiledversion":"1.3.3", "tiledversion":"2021.03.23",
"tileheight":32, "tileheight":32,
"tilesets":[ "tilesets":[
{ {
@ -1959,6 +1952,6 @@
}], }],
"tilewidth":32, "tilewidth":32,
"type":"map", "type":"map",
"version":1.2, "version":1.5,
"width":46 "width":46
} }

View File

@ -39,9 +39,7 @@ export class AuthenticateController extends BaseController {
if (typeof organizationMemberToken != "string") throw new Error("No organization token"); if (typeof organizationMemberToken != "string") throw new Error("No organization token");
const data = await adminApi.fetchMemberDataByToken(organizationMemberToken); const data = await adminApi.fetchMemberDataByToken(organizationMemberToken);
const userUuid = data.userUuid; const userUuid = data.userUuid;
const organizationSlug = data.organizationSlug; const roomUrl = data.roomUrl;
const worldSlug = data.worldSlug;
const roomSlug = data.roomSlug;
const mapUrlStart = data.mapUrlStart; const mapUrlStart = data.mapUrlStart;
const textures = data.textures; const textures = data.textures;
@ -52,9 +50,7 @@ export class AuthenticateController extends BaseController {
JSON.stringify({ JSON.stringify({
authToken, authToken,
userUuid, userUuid,
organizationSlug, roomUrl,
worldSlug,
roomSlug,
mapUrlStart, mapUrlStart,
organizationMemberToken, organizationMemberToken,
textures, textures,

View File

@ -16,19 +16,21 @@ import {
SendUserMessage, SendUserMessage,
ServerToClientMessage, ServerToClientMessage,
CompanionMessage, CompanionMessage,
EmotePromptMessage, VariableMessage, EmotePromptMessage,
VariableMessage,
} from "../Messages/generated/messages_pb"; } from "../Messages/generated/messages_pb";
import { UserMovesMessage } from "../Messages/generated/messages_pb"; import { UserMovesMessage } from "../Messages/generated/messages_pb";
import { TemplatedApp } from "uWebSockets.js"; import { TemplatedApp } from "uWebSockets.js";
import { parse } from "query-string"; import { parse } from "query-string";
import { jwtTokenManager } from "../Services/JWTTokenManager"; import { jwtTokenManager } from "../Services/JWTTokenManager";
import { adminApi, CharacterTexture, FetchMemberDataByUuidResponse } from "../Services/AdminApi"; import { adminApi, FetchMemberDataByUuidResponse } from "../Services/AdminApi";
import { SocketManager, socketManager } from "../Services/SocketManager"; import { SocketManager, socketManager } from "../Services/SocketManager";
import { emitInBatch } from "../Services/IoSocketHelpers"; import { emitInBatch } from "../Services/IoSocketHelpers";
import { ADMIN_API_TOKEN, ADMIN_API_URL, SOCKET_IDLE_TIMER } from "../Enum/EnvironmentVariable"; import { ADMIN_API_TOKEN, ADMIN_API_URL, SOCKET_IDLE_TIMER } from "../Enum/EnvironmentVariable";
import { Zone } from "_Model/Zone"; import { Zone } from "_Model/Zone";
import { ExAdminSocketInterface } from "_Model/Websocket/ExAdminSocketInterface"; import { ExAdminSocketInterface } from "_Model/Websocket/ExAdminSocketInterface";
import { v4 } from "uuid"; import { v4 } from "uuid";
import { CharacterTexture } from "../Services/AdminApi/CharacterTexture";
export class IoSocketController { export class IoSocketController {
private nextUserId: number = 1; private nextUserId: number = 1;
@ -221,14 +223,12 @@ export class IoSocketController {
memberVisitCardUrl = userData.visitCardUrl; memberVisitCardUrl = userData.visitCardUrl;
memberTextures = userData.textures; memberTextures = userData.textures;
if ( if (
!room.public &&
room.policyType === GameRoomPolicyTypes.USE_TAGS_POLICY && room.policyType === GameRoomPolicyTypes.USE_TAGS_POLICY &&
(userData.anonymous === true || !room.canAccess(memberTags)) (userData.anonymous === true || !room.canAccess(memberTags))
) { ) {
throw new Error("Insufficient privileges to access this room"); throw new Error("Insufficient privileges to access this room");
} }
if ( if (
!room.public &&
room.policyType === GameRoomPolicyTypes.MEMBERS_ONLY_POLICY && room.policyType === GameRoomPolicyTypes.MEMBERS_ONLY_POLICY &&
userData.anonymous === true userData.anonymous === true
) { ) {

View File

@ -2,6 +2,9 @@ import { HttpRequest, HttpResponse, TemplatedApp } from "uWebSockets.js";
import { BaseController } from "./BaseController"; import { BaseController } from "./BaseController";
import { parse } from "query-string"; import { parse } from "query-string";
import { adminApi } from "../Services/AdminApi"; import { adminApi } from "../Services/AdminApi";
import { ADMIN_API_URL } from "../Enum/EnvironmentVariable";
import { GameRoomPolicyTypes } from "../Model/PusherRoom";
import { MapDetailsData } from "../Services/AdminApi/MapDetailsData";
export class MapController extends BaseController { export class MapController extends BaseController {
constructor(private App: TemplatedApp) { constructor(private App: TemplatedApp) {
@ -25,35 +28,46 @@ export class MapController extends BaseController {
const query = parse(req.getQuery()); const query = parse(req.getQuery());
if (typeof query.organizationSlug !== "string") { if (typeof query.playUri !== "string") {
console.error("Expected organizationSlug parameter"); console.error("Expected playUri parameter in /map endpoint");
res.writeStatus("400 Bad request"); res.writeStatus("400 Bad request");
this.addCorsHeaders(res); this.addCorsHeaders(res);
res.end("Expected organizationSlug parameter"); res.end("Expected playUri parameter");
return; return;
} }
if (typeof query.worldSlug !== "string") {
console.error("Expected worldSlug parameter"); // If no admin URL is set, let's react on '/_/[instance]/[map url]' URLs
res.writeStatus("400 Bad request"); if (!ADMIN_API_URL) {
const roomUrl = new URL(query.playUri);
const match = /\/_\/[^/]+\/(.+)/.exec(roomUrl.pathname);
if (!match) {
res.writeStatus("404 Not Found");
this.addCorsHeaders(res);
res.end(JSON.stringify({}));
return;
}
const mapUrl = roomUrl.protocol + "//" + match[1];
res.writeStatus("200 OK");
this.addCorsHeaders(res); this.addCorsHeaders(res);
res.end("Expected worldSlug parameter"); res.end(
return; JSON.stringify({
} mapUrl,
if (typeof query.roomSlug !== "string" && query.roomSlug !== undefined) { policy_type: GameRoomPolicyTypes.ANONYMOUS_POLICY,
console.error("Expected only one roomSlug parameter"); roomSlug: "", // Deprecated
res.writeStatus("400 Bad request"); tags: [],
this.addCorsHeaders(res); textures: [],
res.end("Expected only one roomSlug parameter"); } as MapDetailsData)
);
return; return;
} }
(async () => { (async () => {
try { try {
const mapDetails = await adminApi.fetchMapDetails( const mapDetails = await adminApi.fetchMapDetails(query.playUri as string);
query.organizationSlug as string,
query.worldSlug as string,
query.roomSlug as string | undefined
);
res.writeStatus("200 OK"); res.writeStatus("200 OK");
this.addCorsHeaders(res); this.addCorsHeaders(res);

View File

@ -1,19 +1,26 @@
import { ExSocketInterface } from "_Model/Websocket/ExSocketInterface"; import { ExSocketInterface } from "_Model/Websocket/ExSocketInterface";
import { PositionDispatcher } from "./PositionDispatcher"; import { PositionDispatcher } from "./PositionDispatcher";
import { ViewportInterface } from "_Model/Websocket/ViewportMessage"; import { ViewportInterface } from "_Model/Websocket/ViewportMessage";
import { extractDataFromPrivateRoomId, extractRoomSlugPublicRoomId, isRoomAnonymous } from "./RoomIdentifier";
import { arrayIntersect } from "../Services/ArrayHelper"; import { arrayIntersect } from "../Services/ArrayHelper";
import {GroupDescriptor, UserDescriptor, ZoneEventListener} from "_Model/Zone"; import { GroupDescriptor, UserDescriptor, ZoneEventListener } from "_Model/Zone";
import {apiClientRepository} from "../Services/ApiClientRepository"; import { apiClientRepository } from "../Services/ApiClientRepository";
import { import {
BatchToPusherMessage, BatchToPusherRoomMessage, EmoteEventMessage, GroupLeftZoneMessage, BatchToPusherMessage,
GroupUpdateZoneMessage, RoomMessage, SubMessage, BatchToPusherRoomMessage,
UserJoinedZoneMessage, UserLeftZoneMessage, UserMovedMessage, VariableMessage, EmoteEventMessage,
ZoneMessage GroupLeftZoneMessage,
GroupUpdateZoneMessage,
RoomMessage,
SubMessage,
UserJoinedZoneMessage,
UserLeftZoneMessage,
UserMovedMessage,
VariableMessage,
ZoneMessage,
} from "../Messages/generated/messages_pb"; } from "../Messages/generated/messages_pb";
import Debug from "debug"; import Debug from "debug";
import {ClientReadableStream} from "grpc"; import { ClientReadableStream } from "grpc";
import {ExAdminSocketInterface} from "_Model/Websocket/ExAdminSocketInterface"; import { ExAdminSocketInterface } from "_Model/Websocket/ExAdminSocketInterface";
const debug = Debug("room"); const debug = Debug("room");
@ -25,20 +32,15 @@ export enum GameRoomPolicyTypes {
export class PusherRoom { export class PusherRoom {
private readonly positionNotifier: PositionDispatcher; private readonly positionNotifier: PositionDispatcher;
public readonly public: boolean;
public tags: string[]; public tags: string[];
public policyType: GameRoomPolicyTypes; public policyType: GameRoomPolicyTypes;
public readonly roomSlug: string;
public readonly worldSlug: string = "";
public readonly organizationSlug: string = "";
private versionNumber: number = 1; private versionNumber: number = 1;
private backConnection!: ClientReadableStream<BatchToPusherRoomMessage>; private backConnection!: ClientReadableStream<BatchToPusherRoomMessage>;
private isClosing: boolean = false; private isClosing: boolean = false;
private listeners: Set<ExSocketInterface> = new Set<ExSocketInterface>(); private listeners: Set<ExSocketInterface> = new Set<ExSocketInterface>();
public readonly variables = new Map<string, string>(); public readonly variables = new Map<string, string>();
constructor(public readonly roomId: string, private socketListener: ZoneEventListener) { constructor(public readonly roomUrl: string, private socketListener: ZoneEventListener) {
this.public = isRoomAnonymous(roomId);
this.tags = []; this.tags = [];
this.policyType = GameRoomPolicyTypes.ANONYMOUS_POLICY; this.policyType = GameRoomPolicyTypes.ANONYMOUS_POLICY;
@ -52,7 +54,7 @@ export class PusherRoom {
} }
// A zone is 10 sprites wide. // A zone is 10 sprites wide.
this.positionNotifier = new PositionDispatcher(this.roomId, 320, 320, this.socketListener); this.positionNotifier = new PositionDispatcher(this.roomUrl, 320, 320, this.socketListener);
} }
public setViewport(socket: ExSocketInterface, viewport: ViewportInterface): void { public setViewport(socket: ExSocketInterface, viewport: ViewportInterface): void {
@ -121,7 +123,7 @@ export class PusherRoom {
// Let's close all connections linked to that room // Let's close all connections linked to that room
for (const listener of this.listeners) { for (const listener of this.listeners) {
listener.disconnecting = true; listener.disconnecting = true;
listener.end(1011, "Connection error between pusher and back server") listener.end(1011, "Connection error between pusher and back server");
} }
} }
}); });
@ -132,7 +134,7 @@ export class PusherRoom {
// Let's close all connections linked to that room // Let's close all connections linked to that room
for (const listener of this.listeners) { for (const listener of this.listeners) {
listener.disconnecting = true; listener.disconnecting = true;
listener.end(1011, "Connection closed between pusher and back server") listener.end(1011, "Connection closed between pusher and back server");
} }
} }
}); });

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

@ -10,7 +10,6 @@ import {
SubMessage, SubMessage,
} from "../../Messages/generated/messages_pb"; } from "../../Messages/generated/messages_pb";
import { WebSocket } from "uWebSockets.js"; import { WebSocket } from "uWebSockets.js";
import { CharacterTexture } from "../../Services/AdminApi";
import { ClientDuplexStream } from "grpc"; import { ClientDuplexStream } from "grpc";
import { Zone } from "_Model/Zone"; import { Zone } from "_Model/Zone";

View File

@ -9,9 +9,9 @@ import {
SubMessage, SubMessage,
} from "../../Messages/generated/messages_pb"; } from "../../Messages/generated/messages_pb";
import { WebSocket } from "uWebSockets.js"; import { WebSocket } from "uWebSockets.js";
import { CharacterTexture } from "../../Services/AdminApi";
import { ClientDuplexStream } from "grpc"; import { ClientDuplexStream } from "grpc";
import { Zone } from "_Model/Zone"; import { Zone } from "_Model/Zone";
import { CharacterTexture } from "../../Services/AdminApi/CharacterTexture";
export type BackConnection = ClientDuplexStream<PusherToBackMessage, ServerToClientMessage>; export type BackConnection = ClientDuplexStream<PusherToBackMessage, ServerToClientMessage>;

View File

@ -1,11 +1,12 @@
import { ADMIN_API_TOKEN, ADMIN_API_URL } from "../Enum/EnvironmentVariable"; import { ADMIN_API_TOKEN, ADMIN_API_URL } from "../Enum/EnvironmentVariable";
import Axios from "axios"; import Axios from "axios";
import { GameRoomPolicyTypes } from "_Model/PusherRoom"; import { GameRoomPolicyTypes } from "_Model/PusherRoom";
import { CharacterTexture } from "./AdminApi/CharacterTexture";
import { MapDetailsData } from "./AdminApi/MapDetailsData";
import { RoomRedirect } from "./AdminApi/RoomRedirect";
export interface AdminApiData { export interface AdminApiData {
organizationSlug: string; roomUrl: string;
worldSlug: string;
roomSlug: string;
mapUrlStart: string; mapUrlStart: string;
tags: string[]; tags: string[];
policy_type: number; policy_type: number;
@ -14,25 +15,11 @@ export interface AdminApiData {
textures: CharacterTexture[]; textures: CharacterTexture[];
} }
export interface MapDetailsData {
roomSlug: string;
mapUrl: string;
policy_type: GameRoomPolicyTypes;
tags: string[];
}
export interface AdminBannedData { export interface AdminBannedData {
is_banned: boolean; is_banned: boolean;
message: string; message: string;
} }
export interface CharacterTexture {
id: number;
level: number;
url: string;
rights: string;
}
export interface FetchMemberDataByUuidResponse { export interface FetchMemberDataByUuidResponse {
uuid: string; uuid: string;
tags: string[]; tags: string[];
@ -43,24 +30,15 @@ export interface FetchMemberDataByUuidResponse {
} }
class AdminApi { class AdminApi {
async fetchMapDetails( async fetchMapDetails(playUri: string): Promise<MapDetailsData | RoomRedirect> {
organizationSlug: string,
worldSlug: string,
roomSlug: string | undefined
): Promise<MapDetailsData> {
if (!ADMIN_API_URL) { if (!ADMIN_API_URL) {
return Promise.reject(new Error("No admin backoffice set!")); return Promise.reject(new Error("No admin backoffice set!"));
} }
const params: { organizationSlug: string; worldSlug: string; roomSlug?: string } = { const params: { playUri: string } = {
organizationSlug, playUri,
worldSlug,
}; };
if (roomSlug) {
params.roomSlug = roomSlug;
}
const res = await Axios.get(ADMIN_API_URL + "/api/map", { const res = await Axios.get(ADMIN_API_URL + "/api/map", {
headers: { Authorization: `${ADMIN_API_TOKEN}` }, headers: { Authorization: `${ADMIN_API_TOKEN}` },
params, params,
@ -121,26 +99,20 @@ class AdminApi {
); );
} }
async verifyBanUser( async verifyBanUser(userUuid: string, ipAddress: string, roomUrl: string): Promise<AdminBannedData> {
organizationMemberToken: string,
ipAddress: string,
organization: string,
world: string
): Promise<AdminBannedData> {
if (!ADMIN_API_URL) { if (!ADMIN_API_URL) {
return Promise.reject(new Error("No admin backoffice set!")); return Promise.reject(new Error("No admin backoffice set!"));
} }
//todo: this call can fail if the corresponding world is not activated or if the token is invalid. Handle that case. //todo: this call can fail if the corresponding world is not activated or if the token is invalid. Handle that case.
return Axios.get( return Axios.get(
ADMIN_API_URL + ADMIN_API_URL +
"/api/check-moderate-user/" + "/api/ban" +
organization +
"/" +
world +
"?ipAddress=" + "?ipAddress=" +
ipAddress + encodeURIComponent(ipAddress) +
"&token=" + "&token=" +
organizationMemberToken, encodeURIComponent(userUuid) +
"&roomUrl=" +
encodeURIComponent(roomUrl),
{ headers: { Authorization: `${ADMIN_API_TOKEN}` } } { headers: { Authorization: `${ADMIN_API_TOKEN}` } }
).then((data) => { ).then((data) => {
return data.data; return data.data;

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,20 @@
import * as tg from "generic-type-guard";
import { GameRoomPolicyTypes } from "_Model/PusherRoom";
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({
roomSlug: tg.isOptional(tg.isString), // deprecated
mapUrl: tg.isString,
policy_type: isNumber, //isNumericEnum(GameRoomPolicyTypes),
tags: tg.isArray(tg.isString),
textures: tg.isArray(isCharacterTexture),
})
.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

@ -9,7 +9,7 @@ class JWTTokenManager {
return Jwt.sign({ userUuid: userUuid }, SECRET_KEY, { expiresIn: "200d" }); //todo: add a mechanic to refresh or recreate token return Jwt.sign({ userUuid: userUuid }, SECRET_KEY, { expiresIn: "200d" }); //todo: add a mechanic to refresh or recreate token
} }
public async getUserUuidFromToken(token: unknown, ipAddress?: string, room?: string): Promise<string> { public async getUserUuidFromToken(token: unknown, ipAddress?: string, roomUrl?: string): Promise<string> {
if (!token) { if (!token) {
throw new Error("An authentication error happened, a user tried to connect without a token."); throw new Error("An authentication error happened, a user tried to connect without a token.");
} }
@ -50,8 +50,8 @@ class JWTTokenManager {
if (ADMIN_API_URL) { if (ADMIN_API_URL) {
//verify user in admin //verify user in admin
let promise = new Promise((resolve) => resolve()); let promise = new Promise((resolve) => resolve());
if (ipAddress && room) { if (ipAddress && roomUrl) {
promise = this.verifyBanUser(tokenInterface.userUuid, ipAddress, room); promise = this.verifyBanUser(tokenInterface.userUuid, ipAddress, roomUrl);
} }
promise promise
.then(() => { .then(() => {
@ -79,19 +79,9 @@ class JWTTokenManager {
}); });
} }
private verifyBanUser(userUuid: string, ipAddress: string, room: string): Promise<AdminBannedData> { private verifyBanUser(userUuid: string, ipAddress: string, roomUrl: string): Promise<AdminBannedData> {
const parts = room.split("/");
if (parts.length < 3 || parts[0] !== "@") {
return Promise.resolve({
is_banned: false,
message: "",
});
}
const organization = parts[1];
const world = parts[2];
return adminApi return adminApi
.verifyBanUser(userUuid, ipAddress, organization, world) .verifyBanUser(userUuid, ipAddress, roomUrl)
.then((data: AdminBannedData) => { .then((data: AdminBannedData) => {
if (data && data.is_banned) { if (data && data.is_banned) {
throw new Error("User was banned"); throw new Error("User was banned");

View File

@ -33,8 +33,8 @@ import {
VariableMessage, VariableMessage,
} from "../Messages/generated/messages_pb"; } from "../Messages/generated/messages_pb";
import { ProtobufUtils } from "../Model/Websocket/ProtobufUtils"; import { ProtobufUtils } from "../Model/Websocket/ProtobufUtils";
import { JITSI_ISS, SECRET_JITSI_KEY } from "../Enum/EnvironmentVariable"; import { ADMIN_API_URL, JITSI_ISS, SECRET_JITSI_KEY } from "../Enum/EnvironmentVariable";
import { adminApi, CharacterTexture } from "./AdminApi"; import { adminApi } from "./AdminApi";
import { emitInBatch } from "./IoSocketHelpers"; import { emitInBatch } from "./IoSocketHelpers";
import Jwt from "jsonwebtoken"; import Jwt from "jsonwebtoken";
import { JITSI_URL } from "../Enum/EnvironmentVariable"; import { JITSI_URL } from "../Enum/EnvironmentVariable";
@ -45,6 +45,8 @@ import { GroupDescriptor, UserDescriptor, ZoneEventListener } from "_Model/Zone"
import Debug from "debug"; import Debug from "debug";
import { ExAdminSocketInterface } from "_Model/Websocket/ExAdminSocketInterface"; import { ExAdminSocketInterface } from "_Model/Websocket/ExAdminSocketInterface";
import { WebSocket } from "uWebSockets.js"; import { WebSocket } from "uWebSockets.js";
import { isRoomRedirect } from "./AdminApi/RoomRedirect";
import { CharacterTexture } from "./AdminApi/CharacterTexture";
const debug = Debug("socket"); const debug = Debug("socket");
@ -370,24 +372,30 @@ export class SocketManager implements ZoneEventListener {
} }
} }
async getOrCreateRoom(roomId: string): Promise<PusherRoom> { async getOrCreateRoom(roomUrl: string): Promise<PusherRoom> {
//check and create new world for a room //check and create new world for a room
let world = this.rooms.get(roomId); let room = this.rooms.get(roomUrl);
if (world === undefined) { if (room === undefined) {
world = new PusherRoom(roomId, this); room = new PusherRoom(roomUrl, this);
if (!world.public) { if (ADMIN_API_URL) {
await this.updateRoomWithAdminData(world); await this.updateRoomWithAdminData(room);
} }
await world.init(); await world.init();
this.rooms.set(roomId, world); this.rooms.set(roomUrl, room);
} }
return world; return room;
} }
public async updateRoomWithAdminData(world: PusherRoom): Promise<void> { public async updateRoomWithAdminData(room: PusherRoom): Promise<void> {
const data = await adminApi.fetchMapDetails(world.organizationSlug, world.worldSlug, world.roomSlug); const data = await adminApi.fetchMapDetails(room.roomUrl);
world.tags = data.tags;
world.policyType = Number(data.policy_type); if (isRoomRedirect(data)) {
// TODO: if the updated room data is actually a redirect, we need to take everybody on the map
// and redirect everybody to the new location (so we need to close the connection for everybody)
} else {
room.tags = data.tags;
room.policyType = Number(data.policy_type);
}
} }
emitPlayGlobalMessage(client: ExSocketInterface, playglobalmessage: PlayGlobalMessage) { emitPlayGlobalMessage(client: ExSocketInterface, playglobalmessage: PlayGlobalMessage) {

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');
});
})