diff --git a/docs/maps/api-room.md b/docs/maps/api-room.md index 579b3f58..96b62fd2 100644 --- a/docs/maps/api-room.md +++ b/docs/maps/api-room.md @@ -176,4 +176,102 @@ You can create a tileset file in Tile Editor. WA.room.loadTileset("Assets/Tileset.json").then((firstId) => { WA.room.setTiles([{x: 4, y: 4, tile: firstId, layer: 'bottom'}]); }) -``` \ No newline at end of file +``` + + +## Embedding websites in a map + +You can use the scripting API to embed websites in a map, or to edit websites that are already embedded (using the ["website" objects](website-in-map.md)). + +### Getting an instance of a website already embedded in the map + +``` +WA.room.website.get(objectName: string): Promise +``` + +You can get an instance of an embedded website by using the `WA.room.website.get()` method. +It returns a promise of an `EmbeddedWebsite` instance. + +```javascript +// Get an existing website object where 'my_website' is the name of the object (on any layer object of the map) +const website = await WA.room.website.get('my_website'); +website.url = 'https://example.com'; +website.visible = true; +``` + + +### Adding a new website in a map + +``` +WA.room.website.create(website: CreateEmbeddedWebsiteEvent): EmbeddedWebsite + +interface CreateEmbeddedWebsiteEvent { + name: string; // A unique name for this iframe + url: string; // The URL the iframe points to. + position: { + x: number, // In pixels, relative to the map coordinates + y: number, // In pixels, relative to the map coordinates + width: number, // In pixels, sensitive to zoom level + height: number, // In pixels, sensitive to zoom level + }, + visible?: boolean, // Whether to display the iframe or not + allowApi?: boolean, // Whether the scripting API should be available to the iframe + allow?: string, // The list of feature policies allowed +} +``` + +You can create an instance of an embedded website by using the `WA.room.website.create()` method. +It returns an `EmbeddedWebsite` instance. + +```javascript +// Create a new website object +const website = WA.room.website.create({ + name: "my_website", + url: "https://example.com", + position: { + x: 64, + y: 128, + width: 320, + height: 240, + }, + visible: true, + allowApi: true, + allow: "fullscreen", +}); +``` + +### Deleting a website from a map + +``` +WA.room.website.delete(name: string): Promise +``` + +Use `WA.room.website.delete` to completely remove an embedded website from your map. + + +### The EmbeddedWebsite class + +Instances of the `EmbeddedWebsite` class represent the website displayed on the map. + +```typescript +class EmbeddedWebsite { + readonly name: string; + url: string; + visible: boolean; + allow: string; + allowApi: boolean; + x: number; // In pixels, relative to the map coordinates + y: number; // In pixels, relative to the map coordinates + width: number; // In pixels, sensitive to zoom level + height: number; // In pixels, sensitive to zoom level +} +``` + +When you modify a property of an `EmbeddedWebsite` instance, the iframe is automatically modified in the map. + + +{.alert.alert-warning} +The websites you add/edit/delete via the scripting API are only shown locally. If you want them +to be displayed for every player, you can use [variables](api-start.md) to share a common state +between all users. + diff --git a/docs/maps/api-ui.md b/docs/maps/api-ui.md index 286f2ac7..89d46932 100644 --- a/docs/maps/api-ui.md +++ b/docs/maps/api-ui.md @@ -86,4 +86,51 @@ WA.ui.registerMenuCommand("test", () => {
-
\ No newline at end of file + + + + +### Awaiting User Confirmation (with space bar) + +``` +WA.ui.displayActionMessage({ + message: string, + callback: () => void, + type?: "message"|"warning", +}): ActionMessage +``` + +Displays a message at the bottom of the screen (that will disappear when space bar is pressed). + +
+ +
+ +Example: + +```javascript +const triggerMessage = WA.ui.displayActionMessage({ + message: "press 'space' to confirm", + callback: () => { + WA.chat.sendChatMessage("confirmed", "trigger message logic") + } +}); + +setTimeout(() => { + // later + triggerMessage.remove(); +}, 1000) +``` + +Please note that `displayActionMessage` returns an object of the `ActionMessage` class. + +The `ActionMessage` class contains a single method: `remove(): Promise`. This will obviously remove the message when called. + +```javascript +class ActionMessage { + /** + * Hides the message + */ + remove() {}; +} +``` diff --git a/docs/maps/website-in-map.md b/docs/maps/website-in-map.md new file mode 100644 index 00000000..7c7f8025 --- /dev/null +++ b/docs/maps/website-in-map.md @@ -0,0 +1,40 @@ +{.section-title.accent.text-primary} +# Putting a website inside a map + +You can inject a website directly into your map, at a given position. + +To do this in Tiled: + +- Select an object layer +- Create a rectangular object, at the position where you want your website to appear +- Add a `url` property to your object pointing to the URL you want to open + +
+
+ +
A "website" object
+
+
+ +The `url` can be absolute, or relative to your map. + +{.alert.alert-info} +Internally, WorkAdventure will create an "iFrame" to load the website. +Some websites forbid being opened by iframes using the [`X-Frame-Options](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Frame-Options) +HTTP header. + +{.alert.alert-warning} +Please note that the website always appears **on top** of the tiles (even if you put the object layer that +contains the "website" object under the tiles). + +## Allowing the scripting API in your iframe + +If you are planning to use the WorkAdventure scripting API inside your iframe, you need +to explicitly allow it, by setting an additional `allowApi` property to `true`. + +
+
+ +
A "website" object that can communicate using the Iframe API
+
+
diff --git a/front/.eslintrc.json b/front/.eslintrc.json index 037fddae..45b44456 100644 --- a/front/.eslintrc.json +++ b/front/.eslintrc.json @@ -26,7 +26,6 @@ "rules": { "no-unused-vars": "off", "@typescript-eslint/no-explicit-any": "error", - // TODO: remove those ignored rules and write a stronger code! "@typescript-eslint/no-floating-promises": "off", "@typescript-eslint/no-unsafe-call": "off", diff --git a/front/src/Api/Events/EmbeddedWebsiteEvent.ts b/front/src/Api/Events/EmbeddedWebsiteEvent.ts new file mode 100644 index 00000000..42630be1 --- /dev/null +++ b/front/src/Api/Events/EmbeddedWebsiteEvent.ts @@ -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; + +export type CreateEmbeddedWebsiteEvent = tg.GuardedType; +// TODO: make a variation that is all optional (except for the name) +export type Rectangle = tg.GuardedType; diff --git a/front/src/Api/Events/IframeEvent.ts b/front/src/Api/Events/IframeEvent.ts index 0590939b..ed723241 100644 --- a/front/src/Api/Events/IframeEvent.ts +++ b/front/src/Api/Events/IframeEvent.ts @@ -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 extends MessageEvent { data: T; @@ -55,6 +65,7 @@ export type IframeEventMap = { loadTileset: LoadTilesetEvent; registerMenuCommand: MenuItemRegisterEvent; setTiles: SetTilesEvent; + modifyEmbeddedWebsite: Partial; // Note: name should be compulsory in fact }; export interface IframeEvent { type: T; @@ -73,6 +84,7 @@ export interface IframeResponseEventMap { hasPlayerMoved: HasPlayerMovedEvent; menuItemClicked: MenuItemClickedEvent; setVariable: SetVariableEvent; + messageTriggered: MessageReferenceEvent; } export interface IframeResponseEvent { 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 extends (x: unknown) => x is infer T ? T : never; @@ -141,7 +173,12 @@ export const isIframeQuery = (event: any): event is IframeQuery; + +export const isTriggerActionMessageEvent = new tg.IsInterface() + .withProperties({ + message: tg.isString, + uuid: tg.isString, + type: isActionMessageType, + }) + .get(); + +export type TriggerActionMessageEvent = tg.GuardedType; + +export const isMessageReferenceEvent = new tg.IsInterface() + .withProperties({ + uuid: tg.isString, + }) + .get(); + +export type MessageReferenceEvent = tg.GuardedType; diff --git a/front/src/Api/Events/ui/TriggerMessageEventHandler.ts b/front/src/Api/Events/ui/TriggerMessageEventHandler.ts new file mode 100644 index 00000000..f7da0ad2 --- /dev/null +++ b/front/src/Api/Events/ui/TriggerMessageEventHandler.ts @@ -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); diff --git a/front/src/Api/IframeListener.ts b/front/src/Api/IframeListener.ts index d9286ef0..4dde1b7d 100644 --- a/front/src/Api/IframeListener.ts +++ b/front/src/Api/IframeListener.ts @@ -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 = ( query: IframeQueryMap[T]["query"], @@ -108,6 +111,9 @@ class IframeListener { private readonly _setTilesStream: Subject = new Subject(); public readonly setTilesStream = this._setTilesStream.asObservable(); + private readonly _modifyEmbeddedWebsiteStream: Subject = new Subject(); + public readonly modifyEmbeddedWebsiteStream = this._modifyEmbeddedWebsiteStream.asObservable(); + private readonly iframes = new Set(); private readonly iframeCloseCallbacks = new Map void)[]>(); private readonly scripts = new Map(); @@ -121,7 +127,7 @@ class IframeListener { init() { window.addEventListener( "message", - (message: TypedMessageEvent>) => { + (message: MessageEvent) => { // 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). @@ -263,6 +269,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); } } }, @@ -416,6 +424,15 @@ class IframeListener { }); } + sendActionMessageTriggered(uuid: string): void { + this.postMessage({ + type: "messageTriggered", + data: { + uuid, + }, + }); + } + /** * Sends the message... to all allowed iframes. */ diff --git a/front/src/Api/iframe/IframeApiContribution.ts b/front/src/Api/iframe/IframeApiContribution.ts index e4ba089e..96548d5e 100644 --- a/front/src/Api/iframe/IframeApiContribution.ts +++ b/front/src/Api/iframe/IframeApiContribution.ts @@ -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) { - window.parent.postMessage(content, "*") + window.parent.postMessage(content, "*"); } let queryNumber = 0; -export const answerPromises = new Map)) => 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 + ) => void; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + reject: (reason?: any) => void; + } +>(); -export function queryWorkadventure(content: IframeQuery): Promise { - return new Promise((resolve, reject) => { - window.parent.postMessage({ - id: queryNumber, - query: content - } as IframeQueryWrapper, "*"); +export function queryWorkadventure( + content: IframeQuery +): Promise { + return new Promise((resolve, reject) => { + window.parent.postMessage( + { + id: queryNumber, + query: content, + } as IframeQueryWrapper, + "*" + ); answerPromises.set(queryNumber, { resolve, - reject + reject, }); queryNumber++; }); } -type GuardedType> = Guard extends tg.TypeGuard ? T : never +type GuardedType> = Guard extends tg.TypeGuard ? T : never; -export interface IframeCallback> { - - typeChecker: Guard, - callback: (payloadData: T) => void +export interface IframeCallback< + Key extends keyof IframeResponseEventMap, + T = IframeResponseEventMap[Key], + Guard = tg.TypeGuard +> { + typeChecker: Guard; + callback: (payloadData: T) => void; } export interface IframeCallbackContribution extends IframeCallback { - - type: Key + type: Key; } /** @@ -54,9 +69,10 @@ export interface IframeCallbackContribution>, -}> { - - abstract callbacks: T["callbacks"] +export abstract class IframeApiContribution< + T extends { + callbacks: Array>; + } +> { + abstract callbacks: T["callbacks"]; } diff --git a/front/src/Api/iframe/Room/EmbeddedWebsite.ts b/front/src/Api/iframe/Room/EmbeddedWebsite.ts new file mode 100644 index 00000000..7b16890e --- /dev/null +++ b/front/src/Api/iframe/Room/EmbeddedWebsite.ts @@ -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, + }, + }); + } +} diff --git a/front/src/Api/iframe/Ui/ActionMessage.ts b/front/src/Api/iframe/Ui/ActionMessage.ts new file mode 100644 index 00000000..912603b9 --- /dev/null +++ b/front/src/Api/iframe/Ui/ActionMessage.ts @@ -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(); + } +} diff --git a/front/src/Api/iframe/room.ts b/front/src/Api/iframe/room.ts index 9c0be9be..22df49c9 100644 --- a/front/src/Api/iframe/room.ts +++ b/front/src/Api/iframe/room.ts @@ -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> = new Map>(); const leaveStreams: Map> = new Map>(); @@ -105,6 +107,7 @@ export class WorkadventureRoomCommands extends IframeApiContribution { return await queryWorkadventure({ type: "loadTileset", @@ -113,6 +116,10 @@ export class WorkadventureRoomCommands extends IframeApiContribution = new Map(); @@ -14,6 +15,7 @@ const popupCallbacks: Map> = new Map< >(); const menuCallbacks: Map void> = new Map(); +const actionMessages = new Map(); interface ZonedPopupOptions { zone: string; @@ -23,6 +25,12 @@ interface ZonedPopupOptions { popupOptions: Array; } +export interface ActionMessageOptions { + message: string; + type?: "message" | "warning"; + callback: () => void; +} + export class WorkAdventureUiCommands extends IframeApiContribution { callbacks = [ apiCallback({ @@ -49,6 +57,16 @@ export class WorkAdventureUiCommands extends IframeApiContribution { + const actionMessage = actionMessages.get(event.uuid); + if (actionMessage) { + actionMessage.triggerCallback(); + } + }, + }), ]; openPopup(targetObject: string, message: string, buttons: ButtonDescriptor[]): Popup { @@ -103,6 +121,14 @@ export class WorkAdventureUiCommands extends IframeApiContribution { + actionMessages.delete(actionMessage.uuid); + }); + actionMessages.set(actionMessage.uuid, actionMessage); + return actionMessage; + } } export default new WorkAdventureUiCommands(); diff --git a/front/src/Api/iframe/website.ts b/front/src/Api/iframe/website.ts new file mode 100644 index 00000000..28abb19a --- /dev/null +++ b/front/src/Api/iframe/website.ts @@ -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 { + callbacks = []; + + async get(objectName: string): Promise { + 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 { + return await queryWorkadventure({ + type: "deleteEmbeddedWebsite", + data: objectName, + }); + } +} + +export default new WorkadventureRoomWebsiteCommands(); diff --git a/front/src/Components/App.svelte b/front/src/Components/App.svelte index 56f20e9a..d65f699e 100644 --- a/front/src/Components/App.svelte +++ b/front/src/Components/App.svelte @@ -33,6 +33,10 @@ import {textMessageVisibleStore} from "../Stores/TypeMessageStore/TextMessageStore"; import {warningContainerStore} from "../Stores/MenuStore"; import WarningContainer from "./WarningContainer/WarningContainer.svelte"; + import {layoutManagerVisibilityStore} from "../Stores/LayoutManagerStore"; + import LayoutManager from "./LayoutManager/LayoutManager.svelte"; + import {audioManagerVisibilityStore} from "../Stores/AudioManagerStore"; + import AudioManager from "./AudioManager/AudioManager.svelte" export let game: Game; @@ -79,6 +83,16 @@ {/if} + {#if $audioManagerVisibilityStore} +
+ +
+ {/if} + {#if $layoutManagerVisibilityStore} +
+ +
+ {/if} {#if $gameOverlayVisibilityStore}
diff --git a/front/src/Components/AudioManager/AudioManager.svelte b/front/src/Components/AudioManager/AudioManager.svelte new file mode 100644 index 00000000..a78b4bde --- /dev/null +++ b/front/src/Components/AudioManager/AudioManager.svelte @@ -0,0 +1,119 @@ + + + +
+
+ player volume + +
+
+ +
+ +
+
+
+ + + diff --git a/front/src/Components/LayoutManager/LayoutManager.svelte b/front/src/Components/LayoutManager/LayoutManager.svelte new file mode 100644 index 00000000..5bc6e097 --- /dev/null +++ b/front/src/Components/LayoutManager/LayoutManager.svelte @@ -0,0 +1,57 @@ + + + +
+ {#each $layoutManagerActionStore as action} +
onClick(action.callback)}> +

{action.message}

+
+ {/each} +
+ + + diff --git a/front/src/Components/images/audio-mute.svg b/front/src/Components/images/audio-mute.svg new file mode 100644 index 00000000..c2ad1eca --- /dev/null +++ b/front/src/Components/images/audio-mute.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/front/src/Components/images/audio.svg b/front/src/Components/images/audio.svg new file mode 100644 index 00000000..190f7612 --- /dev/null +++ b/front/src/Components/images/audio.svg @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/front/src/Phaser/Components/TextInput.ts b/front/src/Phaser/Components/TextInput.ts deleted file mode 100644 index a8ea772f..00000000 --- a/front/src/Phaser/Components/TextInput.ts +++ /dev/null @@ -1,90 +0,0 @@ - -const IGNORED_KEYS = new Set([ - 'Esc', - 'Escape', - 'Alt', - 'Meta', - 'Control', - 'Ctrl', - 'Space', - 'Backspace' -]) - -export class TextInput extends Phaser.GameObjects.BitmapText { - private minUnderLineLength = 4; - private underLine: Phaser.GameObjects.Text; - private domInput = document.createElement('input'); - - constructor(scene: Phaser.Scene, x: number, y: number, maxLength: number, text: string, - onChange: (text: string) => void) { - super(scene, x, y, 'main_font', text, 32); - this.setOrigin(0.5).setCenterAlign(); - this.scene.add.existing(this); - - const style = {fontFamily: 'Arial', fontSize: "32px", color: '#ffffff'}; - this.underLine = this.scene.add.text(x, y+1, this.getUnderLineBody(text.length), style); - this.underLine.setOrigin(0.5); - - this.domInput.maxLength = maxLength; - this.domInput.style.opacity = "0"; - if (text) { - this.domInput.value = text; - } - - this.domInput.addEventListener('keydown', event => { - if (IGNORED_KEYS.has(event.key)) { - return; - } - - if (!/[a-zA-Z0-9:.!&?()+-]/.exec(event.key)) { - event.preventDefault(); - } - }); - - this.domInput.addEventListener('input', (event) => { - if (event.defaultPrevented) { - return; - } - this.text = this.domInput.value; - this.underLine.text = this.getUnderLineBody(this.text.length); - onChange(this.text); - }); - - document.body.append(this.domInput); - this.focus(); - } - - private getUnderLineBody(textLength:number): string { - if (textLength < this.minUnderLineLength) textLength = this.minUnderLineLength; - let text = '_______'; - for (let i = this.minUnderLineLength; i < textLength; i++) { - text += '__'; - } - return text; - } - - getText(): string { - return this.text; - } - - setX(x: number): this { - super.setX(x); - this.underLine.x = x; - return this; - } - - setY(y: number): this { - super.setY(y); - this.underLine.y = y+1; - return this; - } - - focus() { - this.domInput.focus(); - } - - destroy(): void { - super.destroy(); - this.domInput.remove(); - } -} diff --git a/front/src/Phaser/Game/EmbeddedWebsiteManager.ts b/front/src/Phaser/Game/EmbeddedWebsiteManager.ts new file mode 100644 index 00000000..21a38ee5 --- /dev/null +++ b/front/src/Phaser/Game/EmbeddedWebsiteManager.ts @@ -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(); + 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"); + } +} diff --git a/front/src/Phaser/Game/GameScene.ts b/front/src/Phaser/Game/GameScene.ts index 0346cf6b..ce947224 100644 --- a/front/src/Phaser/Game/GameScene.ts +++ b/front/src/Phaser/Game/GameScene.ts @@ -20,7 +20,6 @@ import { AUDIO_VOLUME_PROPERTY, Box, JITSI_MESSAGE_PROPERTIES, - layoutManager, ON_ACTION_TRIGGER_BUTTON, TRIGGER_JITSI_PROPERTIES, TRIGGER_WEBSITE_PROPERTIES, @@ -33,7 +32,6 @@ import type { RoomConnection } from "../../Connexion/RoomConnection"; import { Room } from "../../Connexion/Room"; import { jitsiFactory } from "../../WebRtc/JitsiFactory"; import { urlManager } from "../../Url/UrlManager"; -import { audioManager } from "../../WebRtc/AudioManager"; import { TextureError } from "../../Exception/TextureError"; import { localUserStore } from "../../Connexion/LocalUserStore"; import { HtmlUtils } from "../../WebRtc/HtmlUtils"; @@ -85,8 +83,17 @@ import { biggestAvailableAreaStore } from "../../Stores/BiggestAvailableAreaStor import { SharedVariablesManager } from "./SharedVariablesManager"; import { playersStore } from "../../Stores/PlayersStore"; import { chatVisibilityStore } from "../../Stores/ChatStore"; +import { + audioManagerFileStore, + audioManagerVisibilityStore, + audioManagerVolumeStore, +} from "../../Stores/AudioManagerStore"; +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 +204,8 @@ export class GameScene extends DirtyScene { private preloading: boolean = true; private startPositionCalculator!: StartPositionCalculator; private sharedVariablesManager!: SharedVariablesManager; + private objectsByType = new Map(); + private embeddedWebsiteManager!: EmbeddedWebsiteManager; constructor(private room: Room, MapUrlFile: string, customKey?: string | undefined) { super({ @@ -336,27 +345,27 @@ export class GameScene extends DirtyScene { }); // Scan the object layers for objects to load and load them. - const objects = new Map(); + this.objectsByType = new Map(); 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(); } 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 +465,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 +487,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, + "" + ); + } } } } @@ -698,12 +731,12 @@ export class GameScene extends DirtyScene { this.simplePeer.registerPeerConnectionListener({ onConnect(peer) { //self.openChatIcon.setVisible(true); - audioManager.decreaseVolume(); + audioManagerVolumeStore.setTalking(true); }, onDisconnect(userId: number) { if (self.simplePeer.getNbConnections() === 0) { //self.openChatIcon.setVisible(false); - audioManager.restoreVolume(); + audioManagerVolumeStore.setTalking(false); } }, }); @@ -791,7 +824,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 +834,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 +843,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 +857,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 +870,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 +879,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(); } @@ -871,14 +902,16 @@ export class GameScene extends DirtyScene { const volume = allProps.get(AUDIO_VOLUME_PROPERTY) as number | undefined; const loop = allProps.get(AUDIO_LOOP_PROPERTY) as boolean | undefined; newValue === undefined - ? audioManager.unloadAudio() - : audioManager.playAudio(newValue, this.getMapDirUrl(), volume, loop); + ? audioManagerFileStore.unloadAudio() + : audioManagerFileStore.playAudio(newValue, this.getMapDirUrl(), volume, loop); + audioManagerVisibilityStore.set(!(newValue === undefined)); }); // TODO: This legacy property should be removed at some point this.gameMap.onPropertyChange("playAudioLoop", (newValue, oldValue) => { newValue === undefined - ? audioManager.unloadAudio() - : audioManager.playAudio(newValue, this.getMapDirUrl(), undefined, true); + ? audioManagerFileStore.unloadAudio() + : audioManagerFileStore.playAudio(newValue, this.getMapDirUrl(), undefined, true); + audioManagerVisibilityStore.set(!(newValue === undefined)); }); this.gameMap.onPropertyChange("zone", (newValue, oldValue) => { @@ -910,7 +943,7 @@ export class GameScene extends DirtyScene { let html = `