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

This commit is contained in:
_Bastler
2021-08-05 20:46:42 +02:00
33 changed files with 1580 additions and 190 deletions
@@ -0,0 +1,48 @@
import * as tg from "generic-type-guard";
export const isRectangle = new tg.IsInterface()
.withProperties({
x: tg.isNumber,
y: tg.isNumber,
width: tg.isNumber,
height: tg.isNumber,
})
.get();
export const isEmbeddedWebsiteEvent = new tg.IsInterface()
.withProperties({
name: tg.isString,
})
.withOptionalProperties({
url: tg.isString,
visible: tg.isBoolean,
allowApi: tg.isBoolean,
allow: tg.isString,
x: tg.isNumber,
y: tg.isNumber,
width: tg.isNumber,
height: tg.isNumber,
})
.get();
export const isCreateEmbeddedWebsiteEvent = new tg.IsInterface()
.withProperties({
name: tg.isString,
url: tg.isString,
position: isRectangle,
})
.withOptionalProperties({
visible: tg.isBoolean,
allowApi: tg.isBoolean,
allow: tg.isString,
})
.get();
/**
* A message sent from the iFrame to the game to modify an embedded website
*/
export type ModifyEmbeddedWebsiteEvent = tg.GuardedType<typeof isEmbeddedWebsiteEvent>;
export type CreateEmbeddedWebsiteEvent = tg.GuardedType<typeof isCreateEmbeddedWebsiteEvent>;
// TODO: make a variation that is all optional (except for the name)
export type Rectangle = tg.GuardedType<typeof isRectangle>;
+38 -1
View File
@@ -9,6 +9,7 @@ import type { OpenCoWebSiteEvent } from "./OpenCoWebSiteEvent";
import type { OpenPopupEvent } from "./OpenPopupEvent";
import type { OpenTabEvent } from "./OpenTabEvent";
import type { UserInputChatEvent } from "./UserInputChatEvent";
import type { MapDataEvent } from "./MapDataEvent";
import type { LayerEvent } from "./LayerEvent";
import type { SetPropertyEvent } from "./setPropertyEvent";
import type { LoadSoundEvent } from "./LoadSoundEvent";
@@ -21,8 +22,17 @@ import type { SetVariableEvent } from "./SetVariableEvent";
import { isGameStateEvent } from "./GameStateEvent";
import { isMapDataEvent } from "./MapDataEvent";
import { isSetVariableEvent } from "./SetVariableEvent";
import type { EmbeddedWebsite } from "../iframe/Room/EmbeddedWebsite";
import { isCreateEmbeddedWebsiteEvent } from "./EmbeddedWebsiteEvent";
import type { LoadTilesetEvent } from "./LoadTilesetEvent";
import { isLoadTilesetEvent } from "./LoadTilesetEvent";
import type {
MessageReferenceEvent,
removeActionMessage,
triggerActionMessage,
TriggerActionMessageEvent,
} from "./ui/TriggerActionMessageEvent";
import { isMessageReferenceEvent, isTriggerActionMessageEvent } from "./ui/TriggerActionMessageEvent";
export interface TypedMessageEvent<T> extends MessageEvent {
data: T;
@@ -55,6 +65,7 @@ export type IframeEventMap = {
loadTileset: LoadTilesetEvent;
registerMenuCommand: MenuItemRegisterEvent;
setTiles: SetTilesEvent;
modifyEmbeddedWebsite: Partial<EmbeddedWebsite>; // Note: name should be compulsory in fact
};
export interface IframeEvent<T extends keyof IframeEventMap> {
type: T;
@@ -73,6 +84,7 @@ export interface IframeResponseEventMap {
hasPlayerMoved: HasPlayerMovedEvent;
menuItemClicked: MenuItemClickedEvent;
setVariable: SetVariableEvent;
messageTriggered: MessageReferenceEvent;
}
export interface IframeResponseEvent<T extends keyof IframeResponseEventMap> {
type: T;
@@ -105,6 +117,26 @@ export const iframeQueryMapTypeGuards = {
query: isLoadTilesetEvent,
answer: tg.isNumber,
},
triggerActionMessage: {
query: isTriggerActionMessageEvent,
answer: tg.isUndefined,
},
removeActionMessage: {
query: isMessageReferenceEvent,
answer: tg.isUndefined,
},
getEmbeddedWebsite: {
query: tg.isString,
answer: isCreateEmbeddedWebsiteEvent,
},
deleteEmbeddedWebsite: {
query: tg.isString,
answer: tg.isUndefined,
},
createEmbeddedWebsite: {
query: isCreateEmbeddedWebsiteEvent,
answer: tg.isUndefined,
},
};
type GuardedType<T> = T extends (x: unknown) => x is infer T ? T : never;
@@ -141,7 +173,12 @@ export const isIframeQuery = (event: any): event is IframeQuery<keyof IframeQuer
if (!isIframeQueryKey(type)) {
return false;
}
return iframeQueryMapTypeGuards[type].query(event.data);
const result = iframeQueryMapTypeGuards[type].query(event.data);
if (!result) {
console.warn('Received a query with type "' + type + '" but the payload is invalid.');
}
return result;
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -0,0 +1,26 @@
import * as tg from "generic-type-guard";
export const triggerActionMessage = "triggerActionMessage";
export const removeActionMessage = "removeActionMessage";
export const isActionMessageType = tg.isSingletonStringUnion("message", "warning");
export type ActionMessageType = tg.GuardedType<typeof isActionMessageType>;
export const isTriggerActionMessageEvent = new tg.IsInterface()
.withProperties({
message: tg.isString,
uuid: tg.isString,
type: isActionMessageType,
})
.get();
export type TriggerActionMessageEvent = tg.GuardedType<typeof isTriggerActionMessageEvent>;
export const isMessageReferenceEvent = new tg.IsInterface()
.withProperties({
uuid: tg.isString,
})
.get();
export type MessageReferenceEvent = tg.GuardedType<typeof isMessageReferenceEvent>;
@@ -0,0 +1,24 @@
import {
isMessageReferenceEvent,
isTriggerActionMessageEvent,
removeActionMessage,
triggerActionMessage,
} from './TriggerActionMessageEvent';
import * as tg from 'generic-type-guard';
const isTriggerMessageEventObject = new tg.IsInterface()
.withProperties({
type: tg.isSingletonString(triggerActionMessage),
data: isTriggerActionMessageEvent,
})
.get();
const isTriggerMessageRemoveEventObject = new tg.IsInterface()
.withProperties({
type: tg.isSingletonString(removeActionMessage),
data: isMessageReferenceEvent,
})
.get();
export const isTriggerMessageHandlerEvent = tg.isUnion(isTriggerMessageEventObject, isTriggerMessageRemoveEventObject);
+18 -1
View File
@@ -1,4 +1,5 @@
import { Subject } from "rxjs";
import type * as tg from "generic-type-guard";
import { ChatEvent, isChatEvent } from "./Events/ChatEvent";
import { HtmlUtils } from "../WebRtc/HtmlUtils";
import type { EnterLeaveEvent } from "./Events/EnterLeaveEvent";
@@ -31,6 +32,8 @@ import { isLoadPageEvent } from "./Events/LoadPageEvent";
import { handleMenuItemRegistrationEvent, isMenuItemRegisterIframeEvent } from "./Events/ui/MenuItemRegisterEvent";
import { SetTilesEvent, isSetTilesEvent } from "./Events/SetTilesEvent";
import type { SetVariableEvent } from "./Events/SetVariableEvent";
import { ModifyEmbeddedWebsiteEvent, isEmbeddedWebsiteEvent } from "./Events/EmbeddedWebsiteEvent";
import { EmbeddedWebsite } from "./iframe/Room/EmbeddedWebsite";
type AnswererCallback<T extends keyof IframeQueryMap> = (
query: IframeQueryMap[T]["query"],
@@ -111,6 +114,9 @@ class IframeListener {
private readonly _setTilesStream: Subject<SetTilesEvent> = new Subject();
public readonly setTilesStream = this._setTilesStream.asObservable();
private readonly _modifyEmbeddedWebsiteStream: Subject<ModifyEmbeddedWebsiteEvent> = new Subject();
public readonly modifyEmbeddedWebsiteStream = this._modifyEmbeddedWebsiteStream.asObservable();
private readonly iframes = new Set<HTMLIFrameElement>();
private readonly iframeCloseCallbacks = new Map<HTMLIFrameElement, (() => void)[]>();
private readonly scripts = new Map<string, HTMLIFrameElement>();
@@ -124,7 +130,7 @@ class IframeListener {
init() {
window.addEventListener(
"message",
(message: TypedMessageEvent<IframeEvent<keyof IframeEventMap>>) => {
(message: MessageEvent<unknown>) => {
// Do we trust the sender of this message?
// Let's only accept messages from the iframe that are allowed.
// Note: maybe we could restrict on the domain too for additional security (in case the iframe goes to another domain).
@@ -266,6 +272,8 @@ class IframeListener {
handleMenuItemRegistrationEvent(payload.data);
} else if (payload.type == "setTiles" && isSetTilesEvent(payload.data)) {
this._setTilesStream.next(payload.data);
} else if (payload.type == "modifyEmbeddedWebsite" && isEmbeddedWebsiteEvent(payload.data)) {
this._modifyEmbeddedWebsiteStream.next(payload.data);
}
}
},
@@ -423,6 +431,15 @@ class IframeListener {
});
}
sendActionMessageTriggered(uuid: string): void {
this.postMessage({
type: "messageTriggered",
data: {
uuid,
},
});
}
/**
* Sends the message... to all allowed iframes.
*/
+45 -29
View File
@@ -1,51 +1,66 @@
import type * as tg from "generic-type-guard";
import type {
IframeEvent,
IframeEventMap, IframeQuery,
IframeEventMap,
IframeQuery,
IframeQueryMap,
IframeResponseEventMap
} from '../Events/IframeEvent';
import type {IframeQueryWrapper} from "../Events/IframeEvent";
IframeResponseEventMap,
} from "../Events/IframeEvent";
import type { IframeQueryWrapper } from "../Events/IframeEvent";
export function sendToWorkadventure(content: IframeEvent<keyof IframeEventMap>) {
window.parent.postMessage(content, "*")
window.parent.postMessage(content, "*");
}
let queryNumber = 0;
export const answerPromises = new Map<number, {
resolve: (value: (IframeQueryMap[keyof IframeQueryMap]['answer'] | PromiseLike<IframeQueryMap[keyof IframeQueryMap]['answer']>)) => void,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
reject: (reason?: any) => void
}>();
export const answerPromises = new Map<
number,
{
resolve: (
value:
| IframeQueryMap[keyof IframeQueryMap]["answer"]
| PromiseLike<IframeQueryMap[keyof IframeQueryMap]["answer"]>
) => void;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
reject: (reason?: any) => void;
}
>();
export function queryWorkadventure<T extends keyof IframeQueryMap>(content: IframeQuery<T>): Promise<IframeQueryMap[T]['answer']> {
return new Promise<IframeQueryMap[T]['answer']>((resolve, reject) => {
window.parent.postMessage({
id: queryNumber,
query: content
} as IframeQueryWrapper<T>, "*");
export function queryWorkadventure<T extends keyof IframeQueryMap>(
content: IframeQuery<T>
): Promise<IframeQueryMap[T]["answer"]> {
return new Promise<IframeQueryMap[T]["answer"]>((resolve, reject) => {
window.parent.postMessage(
{
id: queryNumber,
query: content,
} as IframeQueryWrapper<T>,
"*"
);
answerPromises.set(queryNumber, {
resolve,
reject
reject,
});
queryNumber++;
});
}
type GuardedType<Guard extends tg.TypeGuard<unknown>> = Guard extends tg.TypeGuard<infer T> ? T : never
type GuardedType<Guard extends tg.TypeGuard<unknown>> = Guard extends tg.TypeGuard<infer T> ? T : never;
export interface IframeCallback<Key extends keyof IframeResponseEventMap, T = IframeResponseEventMap[Key], Guard = tg.TypeGuard<T>> {
typeChecker: Guard,
callback: (payloadData: T) => void
export interface IframeCallback<
Key extends keyof IframeResponseEventMap,
T = IframeResponseEventMap[Key],
Guard = tg.TypeGuard<T>
> {
typeChecker: Guard;
callback: (payloadData: T) => void;
}
export interface IframeCallbackContribution<Key extends keyof IframeResponseEventMap> extends IframeCallback<Key> {
type: Key
type: Key;
}
/**
@@ -54,9 +69,10 @@ export interface IframeCallbackContribution<Key extends keyof IframeResponseEven
*
*/
export abstract class IframeApiContribution<T extends {
callbacks: Array<IframeCallbackContribution<keyof IframeResponseEventMap>>,
}> {
abstract callbacks: T["callbacks"]
export abstract class IframeApiContribution<
T extends {
callbacks: Array<IframeCallbackContribution<keyof IframeResponseEventMap>>;
}
> {
abstract callbacks: T["callbacks"];
}
@@ -0,0 +1,90 @@
import { sendToWorkadventure } from "../IframeApiContribution";
import type {
CreateEmbeddedWebsiteEvent,
ModifyEmbeddedWebsiteEvent,
Rectangle,
} from "../../Events/EmbeddedWebsiteEvent";
export class EmbeddedWebsite {
public readonly name: string;
private _url: string;
private _visible: boolean;
private _allow: string;
private _allowApi: boolean;
private _position: Rectangle;
constructor(private config: CreateEmbeddedWebsiteEvent) {
this.name = config.name;
this._url = config.url;
this._visible = config.visible ?? true;
this._allow = config.allow ?? "";
this._allowApi = config.allowApi ?? false;
this._position = config.position;
}
public set url(url: string) {
this._url = url;
sendToWorkadventure({
type: "modifyEmbeddedWebsite",
data: {
name: this.name,
url: this._url,
},
});
}
public set visible(visible: boolean) {
this._visible = visible;
sendToWorkadventure({
type: "modifyEmbeddedWebsite",
data: {
name: this.name,
visible: this._visible,
},
});
}
public set x(x: number) {
this._position.x = x;
sendToWorkadventure({
type: "modifyEmbeddedWebsite",
data: {
name: this.name,
x: this._position.x,
},
});
}
public set y(y: number) {
this._position.y = y;
sendToWorkadventure({
type: "modifyEmbeddedWebsite",
data: {
name: this.name,
y: this._position.y,
},
});
}
public set width(width: number) {
this._position.width = width;
sendToWorkadventure({
type: "modifyEmbeddedWebsite",
data: {
name: this.name,
width: this._position.width,
},
});
}
public set height(height: number) {
this._position.height = height;
sendToWorkadventure({
type: "modifyEmbeddedWebsite",
data: {
name: this.name,
height: this._position.height,
},
});
}
}
+56
View File
@@ -0,0 +1,56 @@
import {
ActionMessageType,
MessageReferenceEvent,
removeActionMessage,
triggerActionMessage,
TriggerActionMessageEvent,
} from "../../Events/ui/TriggerActionMessageEvent";
import { queryWorkadventure } from "../IframeApiContribution";
import type { ActionMessageOptions } from "../ui";
function uuidv4() {
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
const r = (Math.random() * 16) | 0,
v = c === "x" ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
}
export class ActionMessage {
public readonly uuid: string;
private readonly type: ActionMessageType;
private readonly message: string;
private readonly callback: () => void;
constructor(actionMessageOptions: ActionMessageOptions, private onRemove: () => void) {
this.uuid = uuidv4();
this.message = actionMessageOptions.message;
this.type = actionMessageOptions.type ?? "message";
this.callback = actionMessageOptions.callback;
this.create();
}
private async create() {
await queryWorkadventure({
type: triggerActionMessage,
data: {
message: this.message,
type: this.type,
uuid: this.uuid,
} as TriggerActionMessageEvent,
});
}
async remove() {
await queryWorkadventure({
type: removeActionMessage,
data: {
uuid: this.uuid,
} as MessageReferenceEvent,
});
this.onRemove();
}
triggerCallback() {
this.callback();
}
}
+7
View File
@@ -6,6 +6,8 @@ import { IframeApiContribution, queryWorkadventure, sendToWorkadventure } from "
import { apiCallback } from "./registeredCallbacks";
import type { ITiledMap } from "../../Phaser/Map/ITiledMap";
import type { WorkadventureRoomWebsiteCommands } from "./website";
import website from "./website";
const enterStreams: Map<string, Subject<EnterLeaveEvent>> = new Map<string, Subject<EnterLeaveEvent>>();
const leaveStreams: Map<string, Subject<EnterLeaveEvent>> = new Map<string, Subject<EnterLeaveEvent>>();
@@ -105,6 +107,7 @@ export class WorkadventureRoomCommands extends IframeApiContribution<Workadventu
}
return mapURL;
}
async loadTileset(url: string): Promise<number> {
return await queryWorkadventure({
type: "loadTileset",
@@ -113,6 +116,10 @@ export class WorkadventureRoomCommands extends IframeApiContribution<Workadventu
},
});
}
get website(): WorkadventureRoomWebsiteCommands {
return website;
}
}
export default new WorkadventureRoomCommands();
+27 -1
View File
@@ -1,10 +1,11 @@
import { isButtonClickedEvent } from "../Events/ButtonClickedEvent";
import { isMenuItemClickedEvent } from "../Events/ui/MenuItemClickedEvent";
import type { MenuItemRegisterEvent } from "../Events/ui/MenuItemRegisterEvent";
import { IframeApiContribution, sendToWorkadventure } from "./IframeApiContribution";
import { apiCallback } from "./registeredCallbacks";
import type { ButtonClickedCallback, ButtonDescriptor } from "./Ui/ButtonDescriptor";
import { Popup } from "./Ui/Popup";
import { ActionMessage } from "./Ui/ActionMessage";
import { isMessageReferenceEvent } from "../Events/ui/TriggerActionMessageEvent";
let popupId = 0;
const popups: Map<number, Popup> = new Map<number, Popup>();
@@ -14,6 +15,7 @@ const popupCallbacks: Map<number, Map<number, ButtonClickedCallback>> = new Map<
>();
const menuCallbacks: Map<string, (command: string) => void> = new Map();
const actionMessages = new Map<string, ActionMessage>();
interface ZonedPopupOptions {
zone: string;
@@ -23,6 +25,12 @@ interface ZonedPopupOptions {
popupOptions: Array<ButtonDescriptor>;
}
export interface ActionMessageOptions {
message: string;
type?: "message" | "warning";
callback: () => void;
}
export class WorkAdventureUiCommands extends IframeApiContribution<WorkAdventureUiCommands> {
callbacks = [
apiCallback({
@@ -50,6 +58,16 @@ export class WorkAdventureUiCommands extends IframeApiContribution<WorkAdventure
}
},
}),
apiCallback({
type: "messageTriggered",
typeChecker: isMessageReferenceEvent,
callback: (event) => {
const actionMessage = actionMessages.get(event.uuid);
if (actionMessage) {
actionMessage.triggerCallback();
}
},
}),
];
openPopup(targetObject: string, message: string, buttons: ButtonDescriptor[], input: boolean = false): Popup {
@@ -105,6 +123,14 @@ export class WorkAdventureUiCommands extends IframeApiContribution<WorkAdventure
removeBubble(): void {
sendToWorkadventure({ type: "removeBubble", data: null });
}
displayActionMessage(actionMessageOptions: ActionMessageOptions): ActionMessage {
const actionMessage = new ActionMessage(actionMessageOptions, () => {
actionMessages.delete(actionMessage.uuid);
});
actionMessages.set(actionMessage.uuid, actionMessage);
return actionMessage;
}
}
export default new WorkAdventureUiCommands();
+38
View File
@@ -0,0 +1,38 @@
import type { LoadSoundEvent } from "../Events/LoadSoundEvent";
import type { PlaySoundEvent } from "../Events/PlaySoundEvent";
import type { StopSoundEvent } from "../Events/StopSoundEvent";
import { IframeApiContribution, queryWorkadventure, sendToWorkadventure } from "./IframeApiContribution";
import { Sound } from "./Sound/Sound";
import { EmbeddedWebsite } from "./Room/EmbeddedWebsite";
import type { CreateEmbeddedWebsiteEvent } from "../Events/EmbeddedWebsiteEvent";
export class WorkadventureRoomWebsiteCommands extends IframeApiContribution<WorkadventureRoomWebsiteCommands> {
callbacks = [];
async get(objectName: string): Promise<EmbeddedWebsite> {
const websiteEvent = await queryWorkadventure({
type: "getEmbeddedWebsite",
data: objectName,
});
return new EmbeddedWebsite(websiteEvent);
}
create(createEmbeddedWebsiteEvent: CreateEmbeddedWebsiteEvent): EmbeddedWebsite {
queryWorkadventure({
type: "createEmbeddedWebsite",
data: createEmbeddedWebsiteEvent,
}).catch((e) => {
console.error(e);
});
return new EmbeddedWebsite(createEmbeddedWebsiteEvent);
}
async delete(objectName: string): Promise<void> {
return await queryWorkadventure({
type: "deleteEmbeddedWebsite",
data: objectName,
});
}
}
export default new WorkadventureRoomWebsiteCommands();
+7
View File
@@ -33,6 +33,8 @@
import {textMessageVisibleStore} from "../Stores/TypeMessageStore/TextMessageStore";
import {warningContainerStore} from "../Stores/MenuStore";
import WarningContainer from "./WarningContainer/WarningContainer.svelte";
import {layoutManagerVisibilityStore} from "../Stores/LayoutManagerStore";
import LayoutManager from "./LayoutManager/LayoutManager.svelte";
export let game: Game;
@@ -79,6 +81,11 @@
<AudioPlaying url={$soundPlayingStore} />
</div>
{/if}
{#if $layoutManagerVisibilityStore}
<div>
<LayoutManager></LayoutManager>
</div>
{/if}
{#if $gameOverlayVisibilityStore}
<div>
<VideoOverlay></VideoOverlay>
@@ -0,0 +1,57 @@
<script lang="ts">
import { layoutManagerActionStore } from "../../Stores/LayoutManagerStore";
function onClick(callback: () => void) {
callback();
}
</script>
<div class="layout-manager-list">
{#each $layoutManagerActionStore as action}
<div class="nes-container is-rounded {action.type}" on:click={() => onClick(action.callback)}>
<p>{action.message}</p>
</div>
{/each}
</div>
<style lang="scss">
div.layout-manager-list {
pointer-events: auto;
position: absolute;
left: 0;
right: 0;
bottom: 40px;
margin: 0 auto;
padding: 0;
width: clamp(200px, 20vw, 20vw);
display: flex;
flex-direction: column;
animation: moveMessage .5s;
animation-iteration-count: infinite;
animation-timing-function: ease-in-out;
}
div.nes-container.is-rounded {
padding: 8px 4px;
text-align: center;
font-family: Lato;
color: whitesmoke;
background-color: rgb(0,0,0,0.5);
&.warning {
background-color: #ff9800eb;
color: #000;
}
}
@keyframes moveMessage {
0% {bottom: 40px;}
50% {bottom: 30px;}
100% {bottom: 40px;}
}
</style>
@@ -0,0 +1,198 @@
import type { GameScene } from "./GameScene";
import { iframeListener } from "../../Api/IframeListener";
import type { Subscription } from "rxjs";
import type { CreateEmbeddedWebsiteEvent, ModifyEmbeddedWebsiteEvent } from "../../Api/Events/EmbeddedWebsiteEvent";
import DOMElement = Phaser.GameObjects.DOMElement;
type EmbeddedWebsite = CreateEmbeddedWebsiteEvent & { iframe: HTMLIFrameElement; phaserObject: DOMElement };
export class EmbeddedWebsiteManager {
private readonly embeddedWebsites = new Map<string, EmbeddedWebsite>();
private readonly subscription: Subscription;
constructor(private gameScene: GameScene) {
iframeListener.registerAnswerer("getEmbeddedWebsite", (name: string) => {
const website = this.embeddedWebsites.get(name);
if (website === undefined) {
throw new Error('Cannot find embedded website with name "' + name + '"');
}
const rect = website.iframe.getBoundingClientRect();
return {
url: website.url,
name: website.name,
visible: website.visible,
allowApi: website.allowApi,
allow: website.allow,
position: {
x: website.phaserObject.x,
y: website.phaserObject.y,
width: rect["width"],
height: rect["height"],
},
};
});
iframeListener.registerAnswerer("deleteEmbeddedWebsite", (name: string) => {
const website = this.embeddedWebsites.get(name);
if (!website) {
throw new Error('Could not find website to delete with the name "' + name + '" in your map');
}
website.iframe.remove();
website.phaserObject.destroy();
this.embeddedWebsites.delete(name);
});
iframeListener.registerAnswerer(
"createEmbeddedWebsite",
(createEmbeddedWebsiteEvent: CreateEmbeddedWebsiteEvent) => {
if (this.embeddedWebsites.has(createEmbeddedWebsiteEvent.name)) {
throw new Error('An embedded website with the name "' + name + '" already exists in your map');
}
this.createEmbeddedWebsite(
createEmbeddedWebsiteEvent.name,
createEmbeddedWebsiteEvent.url,
createEmbeddedWebsiteEvent.position.x,
createEmbeddedWebsiteEvent.position.y,
createEmbeddedWebsiteEvent.position.width,
createEmbeddedWebsiteEvent.position.height,
createEmbeddedWebsiteEvent.visible ?? true,
createEmbeddedWebsiteEvent.allowApi ?? false,
createEmbeddedWebsiteEvent.allow ?? ""
);
}
);
this.subscription = iframeListener.modifyEmbeddedWebsiteStream.subscribe(
(embeddedWebsiteEvent: ModifyEmbeddedWebsiteEvent) => {
const website = this.embeddedWebsites.get(embeddedWebsiteEvent.name);
if (!website) {
throw new Error(
'Could not find website with the name "' + embeddedWebsiteEvent.name + '" in your map'
);
}
gameScene.markDirty();
if (embeddedWebsiteEvent.url !== undefined) {
website.url = embeddedWebsiteEvent.url;
const absoluteUrl = new URL(embeddedWebsiteEvent.url, this.gameScene.MapUrlFile).toString();
website.iframe.src = absoluteUrl;
}
if (embeddedWebsiteEvent.visible !== undefined) {
website.visible = embeddedWebsiteEvent.visible;
website.phaserObject.visible = embeddedWebsiteEvent.visible;
}
if (embeddedWebsiteEvent.allowApi !== undefined) {
website.allowApi = embeddedWebsiteEvent.allowApi;
if (embeddedWebsiteEvent.allowApi) {
iframeListener.registerIframe(website.iframe);
} else {
iframeListener.unregisterIframe(website.iframe);
}
}
if (embeddedWebsiteEvent.allow !== undefined) {
website.allow = embeddedWebsiteEvent.allow;
website.iframe.allow = embeddedWebsiteEvent.allow;
}
if (embeddedWebsiteEvent?.x !== undefined) {
website.phaserObject.x = embeddedWebsiteEvent.x;
}
if (embeddedWebsiteEvent?.y !== undefined) {
website.phaserObject.y = embeddedWebsiteEvent.y;
}
if (embeddedWebsiteEvent?.width !== undefined) {
website.iframe.style.width = embeddedWebsiteEvent.width + "px";
}
if (embeddedWebsiteEvent?.height !== undefined) {
website.iframe.style.height = embeddedWebsiteEvent.height + "px";
}
}
);
}
public createEmbeddedWebsite(
name: string,
url: string,
x: number,
y: number,
width: number,
height: number,
visible: boolean,
allowApi: boolean,
allow: string
): void {
if (this.embeddedWebsites.has(name)) {
throw new Error('An embedded website with the name "' + name + '" already exists in your map');
}
const embeddedWebsiteEvent: CreateEmbeddedWebsiteEvent = {
name,
url,
/*x,
y,
width,
height,*/
allow,
allowApi,
visible,
position: {
x,
y,
width,
height,
},
};
const embeddedWebsite = this.doCreateEmbeddedWebsite(embeddedWebsiteEvent, visible);
this.embeddedWebsites.set(name, embeddedWebsite);
}
private doCreateEmbeddedWebsite(
embeddedWebsiteEvent: CreateEmbeddedWebsiteEvent,
visible: boolean
): EmbeddedWebsite {
const absoluteUrl = new URL(embeddedWebsiteEvent.url, this.gameScene.MapUrlFile).toString();
const iframe = document.createElement("iframe");
iframe.src = absoluteUrl;
iframe.style.width = embeddedWebsiteEvent.position.width + "px";
iframe.style.height = embeddedWebsiteEvent.position.height + "px";
iframe.style.margin = "0";
iframe.style.padding = "0";
iframe.style.border = "none";
const embeddedWebsite = {
...embeddedWebsiteEvent,
phaserObject: this.gameScene.add
.dom(embeddedWebsiteEvent.position.x, embeddedWebsiteEvent.position.y, iframe)
.setVisible(visible)
.setOrigin(0, 0),
iframe: iframe,
};
if (embeddedWebsiteEvent.allowApi) {
iframeListener.registerIframe(iframe);
}
return embeddedWebsite;
}
close(): void {
for (const [key, website] of this.embeddedWebsites) {
if (website.allowApi) {
iframeListener.unregisterIframe(website.iframe);
}
}
this.subscription.unsubscribe();
iframeListener.unregisterAnswerer("getEmbeddedWebsite");
iframeListener.unregisterAnswerer("deleteEmbeddedWebsite");
iframeListener.unregisterAnswerer("createEmbeddedWebsite");
}
}
+98 -28
View File
@@ -20,7 +20,6 @@ import {
AUDIO_VOLUME_PROPERTY,
Box,
JITSI_MESSAGE_PROPERTIES,
layoutManager,
ON_ACTION_TRIGGER_BUTTON,
TRIGGER_JITSI_PROPERTIES,
TRIGGER_WEBSITE_PROPERTIES,
@@ -85,8 +84,12 @@ import { biggestAvailableAreaStore } from "../../Stores/BiggestAvailableAreaStor
import { SharedVariablesManager } from "./SharedVariablesManager";
import { playersStore } from "../../Stores/PlayersStore";
import { chatVisibilityStore } from "../../Stores/ChatStore";
import { PropertyUtils } from "../Map/PropertyUtils";
import Tileset = Phaser.Tilemaps.Tileset;
import { userIsAdminStore } from "../../Stores/GameStore";
import { layoutManagerActionStore } from "../../Stores/LayoutManagerStore";
import { get } from "svelte/store";
import { EmbeddedWebsiteManager } from "./EmbeddedWebsiteManager";
export interface GameSceneInitInterface {
initPosition: PointInterface | null;
@@ -197,6 +200,8 @@ export class GameScene extends DirtyScene {
private preloading: boolean = true;
private startPositionCalculator!: StartPositionCalculator;
private sharedVariablesManager!: SharedVariablesManager;
private objectsByType = new Map<string, ITiledMapObject[]>();
private embeddedWebsiteManager!: EmbeddedWebsiteManager;
constructor(private room: Room, MapUrlFile: string, customKey?: string | undefined) {
super({
@@ -336,27 +341,27 @@ export class GameScene extends DirtyScene {
});
// Scan the object layers for objects to load and load them.
const objects = new Map<string, ITiledMapObject[]>();
this.objectsByType = new Map<string, ITiledMapObject[]>();
for (const layer of this.mapFile.layers) {
if (layer.type === "objectgroup") {
for (const object of layer.objects) {
let objectsOfType: ITiledMapObject[] | undefined;
if (!objects.has(object.type)) {
if (!this.objectsByType.has(object.type)) {
objectsOfType = new Array<ITiledMapObject>();
} else {
objectsOfType = objects.get(object.type);
objectsOfType = this.objectsByType.get(object.type);
if (objectsOfType === undefined) {
throw new Error("Unexpected object type not found");
}
}
objectsOfType.push(object);
objects.set(object.type, objectsOfType);
this.objectsByType.set(object.type, objectsOfType);
}
}
}
for (const [itemType, objectsOfType] of objects) {
for (const [itemType, objectsOfType] of this.objectsByType) {
// FIXME: we would ideally need for the loader to WAIT for the import to be performed, which means writing our own loader plugin.
let itemFactory: ItemFactoryInterface;
@@ -456,6 +461,8 @@ export class GameScene extends DirtyScene {
//permit to set bound collision
this.physics.world.setBounds(0, 0, this.Map.widthInPixels, this.Map.heightInPixels);
this.embeddedWebsiteManager = new EmbeddedWebsiteManager(this);
//add layer on map
this.gameMap = new GameMap(this.mapFile, this.Map, this.Terrains);
for (const layer of this.gameMap.flatLayers) {
@@ -476,6 +483,28 @@ export class GameScene extends DirtyScene {
if (object.text) {
TextUtils.createTextFromITiledMapObject(this, object);
}
if (object.type === "website") {
// Let's load iframes in the map
const url = PropertyUtils.mustFindStringProperty(
"url",
object.properties,
'in the "' + object.name + '" object of type "website"'
);
const allowApi = PropertyUtils.findBooleanProperty("allowApi", object.properties);
// TODO: add a "allow" property to iframe
this.embeddedWebsiteManager.createEmbeddedWebsite(
object.name,
url,
object.x,
object.y,
object.width,
object.height,
object.visible,
allowApi ?? false,
""
);
}
}
}
}
@@ -791,7 +820,7 @@ export class GameScene extends DirtyScene {
});
this.gameMap.onPropertyChange("openWebsite", (newValue, oldValue, allProps) => {
if (newValue === undefined) {
layoutManager.removeActionButton("openWebsite", this.userInputManager);
layoutManagerActionStore.removeAction("openWebsite");
coWebsiteManager.closeCoWebsite();
} else {
const openWebsiteFunction = () => {
@@ -801,7 +830,7 @@ export class GameScene extends DirtyScene {
allProps.get("openWebsiteAllowApi") as boolean | undefined,
allProps.get("openWebsitePolicy") as string | undefined
);
layoutManager.removeActionButton("openWebsite", this.userInputManager);
layoutManagerActionStore.removeAction("openWebsite");
};
const openWebsiteTriggerValue = allProps.get(TRIGGER_WEBSITE_PROPERTIES);
@@ -810,14 +839,13 @@ export class GameScene extends DirtyScene {
if (message === undefined) {
message = "Press SPACE or touch here to open web site";
}
layoutManager.addActionButton(
"openWebsite",
message.toString(),
() => {
openWebsiteFunction();
},
this.userInputManager
);
layoutManagerActionStore.addAction({
uuid: "openWebsite",
type: "message",
message: message,
callback: () => openWebsiteFunction(),
userInputManager: this.userInputManager,
});
} else {
openWebsiteFunction();
}
@@ -825,7 +853,7 @@ export class GameScene extends DirtyScene {
});
this.gameMap.onPropertyChange("jitsiRoom", (newValue, oldValue, allProps) => {
if (newValue === undefined) {
layoutManager.removeActionButton("jitsiRoom", this.userInputManager);
layoutManagerActionStore.removeAction("jitsi");
this.stopJitsi();
} else {
const openJitsiRoomFunction = () => {
@@ -838,7 +866,7 @@ export class GameScene extends DirtyScene {
} else {
this.startJitsi(roomName, undefined);
}
layoutManager.removeActionButton("jitsiRoom", this.userInputManager);
layoutManagerActionStore.removeAction("jitsi");
};
const jitsiTriggerValue = allProps.get(TRIGGER_JITSI_PROPERTIES);
@@ -847,14 +875,13 @@ export class GameScene extends DirtyScene {
if (message === undefined) {
message = "Press SPACE or touch here to enter Jitsi Meet room";
}
layoutManager.addActionButton(
"jitsiRoom",
message.toString(),
() => {
openJitsiRoomFunction();
},
this.userInputManager
);
layoutManagerActionStore.addAction({
uuid: "jitsi",
type: "message",
message: message,
callback: () => openJitsiRoomFunction(),
userInputManager: this.userInputManager,
});
} else {
openJitsiRoomFunction();
}
@@ -1183,6 +1210,44 @@ export class GameScene extends DirtyScene {
});
});
});
iframeListener.registerAnswerer("triggerActionMessage", (message) =>
layoutManagerActionStore.addAction({
uuid: message.uuid,
type: "message",
message: message.message,
callback: () => {
layoutManagerActionStore.removeAction(message.uuid);
iframeListener.sendActionMessageTriggered(message.uuid);
},
userInputManager: this.userInputManager,
})
);
iframeListener.registerAnswerer("removeActionMessage", (message) => {
layoutManagerActionStore.removeAction(message.uuid);
});
this.iframeSubscriptionList.push(
iframeListener.modifyEmbeddedWebsiteStream.subscribe((embeddedWebsite) => {
// TODO
// TODO
// TODO
// TODO
// TODO
// TODO
// TODO
// TODO
// TODO
// TODO
// TODO
// TODO
// TODO
// TODO
// TODO
// TODO
})
);
}
private setPropertyLayer(
@@ -1250,7 +1315,7 @@ export class GameScene extends DirtyScene {
let targetRoom: Room;
try {
targetRoom = await Room.createRoom(roomUrl);
} catch (e) {
} catch (e /*: unknown*/) {
console.error('Error while fetching new room "' + roomUrl.toString() + '"', e);
this.mapTransitioning = false;
return;
@@ -1305,7 +1370,12 @@ export class GameScene extends DirtyScene {
this.biggestAvailableAreaStoreUnsubscribe();
iframeListener.unregisterAnswerer("getState");
iframeListener.unregisterAnswerer("loadTileset");
iframeListener.unregisterAnswerer("getMapData");
iframeListener.unregisterAnswerer("getState");
iframeListener.unregisterAnswerer("triggerActionMessage");
iframeListener.unregisterAnswerer("removeActionMessage");
this.sharedVariablesManager?.close();
this.embeddedWebsiteManager?.close();
mediaManager.hideGameOverlay();
@@ -1377,7 +1447,7 @@ export class GameScene extends DirtyScene {
try {
const room = await Room.createRoom(exitRoomPath);
return gameManager.loadMap(room, this.scene);
} catch (e) {
} catch (e /*: unknown*/) {
console.warn('Error while pre-loading exit room "' + exitRoomPath.toString() + '"', e);
}
}
+53
View File
@@ -0,0 +1,53 @@
import type { ITiledMapProperty } from "./ITiledMap";
export class PropertyUtils {
public static findProperty(
name: string,
properties: ITiledMapProperty[] | undefined
): string | boolean | number | undefined {
return properties?.find((property) => property.name === name)?.value;
}
public static findBooleanProperty(
name: string,
properties: ITiledMapProperty[] | undefined,
context?: string
): boolean | undefined {
const property = PropertyUtils.findProperty(name, properties);
if (property === undefined) {
return undefined;
}
if (typeof property !== "boolean") {
throw new Error(
'Expected property "' + name + '" to be a boolean. ' + (context ? " (" + context + ")" : "")
);
}
return property;
}
public static mustFindProperty(
name: string,
properties: ITiledMapProperty[] | undefined,
context?: string
): string | boolean | number {
const property = PropertyUtils.findProperty(name, properties);
if (property === undefined) {
throw new Error('Could not find property "' + name + '"' + (context ? " (" + context + ")" : ""));
}
return property;
}
public static mustFindStringProperty(
name: string,
properties: ITiledMapProperty[] | undefined,
context?: string
): string {
const property = PropertyUtils.mustFindProperty(name, properties, context);
if (typeof property !== "string") {
throw new Error(
'Expected property "' + name + '" to be a string. ' + (context ? " (" + context + ")" : "")
);
}
return property;
}
}
+56
View File
@@ -0,0 +1,56 @@
import { derived, writable } from "svelte/store";
import type { UserInputManager } from "../Phaser/UserInput/UserInputManager";
export interface LayoutManagerAction {
uuid: string;
type: "warning" | "message";
message: string | number | boolean | undefined;
callback: () => void;
userInputManager: UserInputManager | undefined;
}
function createLayoutManagerAction() {
const { subscribe, set, update } = writable<LayoutManagerAction[]>([]);
return {
subscribe,
addAction: (newAction: LayoutManagerAction): void => {
update((list: LayoutManagerAction[]) => {
let found = false;
for (const action of list) {
if (action.uuid === newAction.uuid) {
found = true;
}
}
if (!found) {
list.push(newAction);
newAction.userInputManager?.addSpaceEventListner(newAction.callback);
}
return list;
});
},
removeAction: (uuid: string): void => {
update((list: LayoutManagerAction[]) => {
const index = list.findIndex((action) => action.uuid === uuid);
if (index !== -1) {
list[index].userInputManager?.removeSpaceEventListner(list[index].callback);
list.splice(index, 1);
}
return list;
});
},
clearActions: (): void => {
set([]);
},
};
}
export const layoutManagerActionStore = createLayoutManagerAction();
export const layoutManagerVisibilityStore = derived(layoutManagerActionStore, ($layoutManagerActionStore) => {
return !!$layoutManagerActionStore.length;
});
-88
View File
@@ -1,7 +1,3 @@
import type { UserInputManager } from "../Phaser/UserInput/UserInputManager";
import { HtmlUtils } from "./HtmlUtils";
const sanitizeHtml = require('sanitize-html');
export enum LayoutMode {
// All videos are displayed on the right side of the screen. If there is a screen sharing, it is displayed in the middle.
Presentation = "Presentation",
@@ -28,87 +24,3 @@ export const AUDIO_VOLUME_PROPERTY = "audioVolume";
export const AUDIO_LOOP_PROPERTY = "audioLoop";
export type Box = { xStart: number; yStart: number; xEnd: number; yEnd: number };
class LayoutManager {
private actionButtonTrigger: Map<string, Function> = new Map<string, Function>();
private actionButtonInformation: Map<string, HTMLDivElement> = new Map<string, HTMLDivElement>();
public addActionButton(id: string, text: string, callBack: Function, userInputManager: UserInputManager) {
//delete previous element
this.removeActionButton(id, userInputManager);
//create div and text html component
const p = document.createElement('p');
p.classList.add('action-body');
p.classList.add('nes-btn');
p.classList.add('is-dark');
p.innerHTML = sanitizeHtml(text);
const div = document.createElement("div");
div.classList.add("action");
div.id = id;
div.appendChild(p);
this.actionButtonInformation.set(id, div);
const mainContainer = HtmlUtils.getElementByIdOrFail<HTMLDivElement>("main-container");
mainContainer.appendChild(div);
//add trigger action
div.onpointerdown = () => callBack();
this.actionButtonTrigger.set(id, callBack);
userInputManager.addSpaceEventListner(callBack);
}
public removeActionButton(id: string, userInputManager?: UserInputManager) {
//delete previous element
const previousDiv = this.actionButtonInformation.get(id);
if (previousDiv) {
previousDiv.remove();
this.actionButtonInformation.delete(id);
}
const previousEventCallback = this.actionButtonTrigger.get(id);
if (previousEventCallback && userInputManager) {
userInputManager.removeSpaceEventListner(previousEventCallback);
}
}
public addInformation(id: string, text: string, callBack?: Function, userInputManager?: UserInputManager) {
//delete previous element
for (const [key, value] of this.actionButtonInformation) {
this.removeActionButton(key, userInputManager);
}
//create div and text html component
const p = document.createElement("p");
p.classList.add("action-body");
p.innerText = text;
const div = document.createElement("div");
div.classList.add("action");
div.classList.add(id);
div.id = id;
div.appendChild(p);
this.actionButtonInformation.set(id, div);
const mainContainer = HtmlUtils.getElementByIdOrFail<HTMLDivElement>("main-container");
mainContainer.appendChild(div);
//add trigger action
if (callBack) {
div.onpointerdown = () => {
callBack();
this.removeActionButton(id, userInputManager);
};
}
//remove it after 10 sec
setTimeout(() => {
this.removeActionButton(id, userInputManager);
}, 10000);
}
}
const layoutManager = new LayoutManager();
export { layoutManager };
+24 -13
View File
@@ -1,4 +1,3 @@
import { layoutManager } from "./LayoutManager";
import { HtmlUtils } from "./HtmlUtils";
import type { UserInputManager } from "../Phaser/UserInput/UserInputManager";
import { localStreamStore } from "../Stores/MediaStore";
@@ -10,6 +9,8 @@ export type StopScreenSharingCallback = (media: MediaStream) => void;
import { cowebsiteCloseButtonId } from "./CoWebsiteManager";
import { gameOverlayVisibilityStore } from "../Stores/GameOverlayStoreVisibility";
import { layoutManagerActionStore, layoutManagerVisibilityStore } from "../Stores/LayoutManagerStore";
import { get } from "svelte/store";
export class MediaManager {
startScreenSharingCallBacks: Set<StartScreenSharingCallback> = new Set<StartScreenSharingCallback>();
@@ -23,14 +24,19 @@ export class MediaManager {
localStreamStore.subscribe((result) => {
if (result.type === "error") {
console.error(result.error);
layoutManager.addInformation(
"warning",
"Camera access denied. Click here and check your browser permissions.",
() => {
layoutManagerActionStore.addAction({
uuid: "cameraAccessDenied",
type: "warning",
message: "Camera access denied. Click here and check your browser permissions.",
callback: () => {
helpCameraSettingsVisibleStore.set(true);
},
this.userInputManager
);
userInputManager: this.userInputManager,
});
//remove it after 10 sec
setTimeout(() => {
layoutManagerActionStore.removeAction("cameraAccessDenied");
}, 10000);
return;
}
});
@@ -38,14 +44,19 @@ export class MediaManager {
screenSharingLocalStreamStore.subscribe((result) => {
if (result.type === "error") {
console.error(result.error);
layoutManager.addInformation(
"warning",
"Screen sharing denied. Click here and check your browser permissions.",
() => {
layoutManagerActionStore.addAction({
uuid: "screenSharingAccessDenied",
type: "warning",
message: "Screen sharing denied. Click here and check your browser permissions.",
callback: () => {
helpCameraSettingsVisibleStore.set(true);
},
this.userInputManager
);
userInputManager: this.userInputManager,
});
//remove it after 10 sec
setTimeout(() => {
layoutManagerActionStore.removeAction("screenSharingAccessDenied");
}, 10000);
return;
}
});