diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e5d406cf..b3361333 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -10,6 +10,14 @@ We love to receive contributions from our community — you! There are many ways to contribute, from writing tutorials or blog posts, improving the documentation, submitting bug reports and feature requests or writing code which can be incorporated into WorkAdventure itself. +## Contributing external resources + +You can share your work on maps / articles / videos related to WorkAdventure on our [awesome-workadventure](https://github.com/workadventure/awesome-workadventure) list. + +## Developer documentation + +Documentation targeted at developers can be found in the [`/docs/dev`](docs/dev/) + ## Using the issue tracker First things first: **Do NOT report security vulnerabilities in public issues!**. diff --git a/README.md b/README.md index 427c514c..21871991 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,10 @@ In WorkAdventure you can move around your office and talk to your colleagues (us See more features for your virtual office: https://workadventu.re/virtual-office +## Community resources + +Check out resources developed by the WorkAdventure community at [awesome-workadventure](https://github.com/workadventure/awesome-workadventure) + ## Setting up a development environment Install Docker. diff --git a/back/src/Services/SocketManager.ts b/back/src/Services/SocketManager.ts index 5efae800..8989df75 100644 --- a/back/src/Services/SocketManager.ts +++ b/back/src/Services/SocketManager.ts @@ -97,6 +97,7 @@ export class SocketManager { } const roomJoinedMessage = new RoomJoinedMessage(); roomJoinedMessage.setTagList(joinRoomMessage.getTagList()); + roomJoinedMessage.setUserroomtoken(joinRoomMessage.getUserroomtoken()); for (const [itemId, item] of room.getItemsState().entries()) { const itemStateMessage = new ItemStateMessage(); diff --git a/back/src/Services/VariablesManager.ts b/back/src/Services/VariablesManager.ts index 00aac3dc..6ec1cc3a 100644 --- a/back/src/Services/VariablesManager.ts +++ b/back/src/Services/VariablesManager.ts @@ -1,12 +1,7 @@ /** * Handles variables shared between the scripting API and the server. */ -import { - ITiledMap, - ITiledMapLayer, - ITiledMapObject, - ITiledMapObjectLayer, -} from "@workadventure/tiled-map-type-guard/dist"; +import { ITiledMap, ITiledMapLayer, ITiledMapObject } from "@workadventure/tiled-map-type-guard/dist"; import { User } from "_Model/User"; import { variablesRepository } from "./Repository/VariablesRepository"; import { redisClient } from "./RedisClient"; diff --git a/docs/dev/README.md b/docs/dev/README.md new file mode 100644 index 00000000..d05c4884 --- /dev/null +++ b/docs/dev/README.md @@ -0,0 +1,16 @@ +# Developer documentation + +This (work in progress) documentation provides a number of "how-to" guides explaining how to work on the WorkAdventure +code. + +This documentation is targeted at developers looking to open Pull Requests on WorkAdventure. + +If you "only" want to design dynamic maps, please refer instead to the [scripting API documentation](https://workadventu.re/map-building/scripting.md). + +## Contributing + +Check out the [contributing guide](../../CONTRIBUTING.md) + +## Front documentation + +- [How to add new functions in the scripting API](contributing-to-scripting-api.md) diff --git a/docs/dev/contributing-to-scripting-api.md b/docs/dev/contributing-to-scripting-api.md new file mode 100644 index 00000000..8d716010 --- /dev/null +++ b/docs/dev/contributing-to-scripting-api.md @@ -0,0 +1,276 @@ +# How to add new functions in the scripting API + +This documentation is intended at contributors who want to participate in the development of WorkAdventure itself. +Before reading this, please be sure you are familiar with the [scripting API](https://workadventu.re/map-building/scripting.md). + +The [scripting API](https://workadventu.re/map-building/scripting.md) allows map developers to add dynamic features in their maps. + +## Why extend the scripting API? + +The philosophy behind WorkAdventure is to build a platform that is as open as possible. Part of this strategy is to +offer map developers the ability to turn a WorkAdventures map into something unexpected, using the API. For instance, +you could use it to develop games (we have seen a PacMan and a mine-sweeper on WorkAdventure!) + +We started working on the WorkAdventure scripting API with this in mind, but at some point, maybe you will find that +a feature is missing in the API. This article is here to explain to you how to add this feature. + +## How to extend the scripting API? + +Extending the scripting API means modifying the core of WorkAdventure. You can of course run these +modifications on your self-hosted instance. +But if you want to share it with the wider community, I strongly encourage you to start by [opening an issue](https://github.com/thecodingmachine/workadventure/issues) +on GitHub before starting the development. Check with the core maintainers that they are willing to merge your idea +before starting developing it. Once a new function makes it into the scripting API, it is very difficult to make it +evolve (or to deprecate), so the design of the function you add needs to be carefully considered. + +## How does it work? + +Scripts are executed in the browser, inside an iframe. + +![](images/scripting_1.svg) + +The iframe allows WorkAdventure to isolate the script in a sandbox. Because the iframe is sandbox (or on a different +domain than the WorkAdventure server), scripts cannot directly manipulate the DOM of WorkAdventure. They also cannot +directly access Phaser objects (Phaser is the game engine used in WorkAdventure). This is by-design. Since anyone +can contribute a map, we cannot allow anyone to run any code in the scope of the WorkAdventure server (that would be +a huge XSS security flaw). + +Instead, the only way the script can interact with WorkAdventure is by sending messages using the +[postMessage API](https://developer.mozilla.org/en-US/docs/Web/API/Window/postMessage). + +![](images/scripting_2.svg) + +We want to make life easy for map developers. So instead of asking them to directly send messages using the postMessage +API, we provide a nice library that does this work for them. This library is what we call the "Scripting API" (we sometimes +refer to it as the "Client API"). + +The scripting API provides the global `WA` object. + +## A simple example + +So let's take an example with a sample script: + +```typescript +WA.chat.sendChatMessage('Hello world!', 'John Doe'); +``` + +When this script is called, the scripting API is dispatching a JSON message to WorkAdventure. + +In our case, the `sendChatMessage` function looks like this: + +**src/Api/iframe/chat.ts** +```typescript + sendChatMessage(message: string, author: string) { + sendToWorkadventure({ + type: "chat", + data: { + message: message, + author: author, + }, + }); + } +``` + +The `sendToWorkadventure` function is a utility function that dispatches the message to the main frame. + +In WorkAdventure, the message is received in the [`IframeListener` listener class](http://github.com/thecodingmachine/workadventure/blob/1e6ce4dec8697340e2c91798864b94da9528b482/front/src/Api/IframeListener.ts#L200-L203). +This class is in charge of analyzing the JSON messages received and dispatching them to the right place in the WorkAdventure application. + +The message callback implemented in `IframeListener` is a giant (and disgusting) `if` statement branching to the correct +part of the code depending on the `type` property. + +**src/Api/IframeListener.ts** +```typescript +// ... + } else if (payload.type === "setProperty" && isSetPropertyEvent(payload.data)) { + this._setPropertyStream.next(payload.data); + } else if (payload.type === "chat" && isChatEvent(payload.data)) { + scriptUtils.sendAnonymousChat(payload.data); + } else if (payload.type === "openPopup" && isOpenPopupEvent(payload.data)) { + this._openPopupStream.next(payload.data); + } else if (payload.type === "closePopup" && isClosePopupEvent(payload.data)) { +// ... +``` + +In this particular case, we call `scriptUtils.sendAnonymousChat` that is doing the work of displaying the chat message. + +## Scripting API entry point + +The `WA` object originates from the scripting API. This script is hosted on the front server, at `https://[front_WA_server]/iframe_api.js.`. + +The entry point for this script is the file `front/src/iframe_api.ts`. +All the other files dedicated to the iframe API are located in the `src/Api/iframe` directory. + +## Utility functions to exchange messages + +In the example above, we already saw you can easily send a message from the iframe to WorkAdventure using the +[`sendToWorkadventure`](http://github.com/thecodingmachine/workadventure/blob/ab075ef6f4974766a3e2de12a230ac4df0954b58/front/src/Api/iframe/IframeApiContribution.ts#L11-L13) utility function. + +Of course, messaging can go the other way around and WorkAdventure can also send messages to the iframes. +We use the [`IFrameListener.postMessage`](http://github.com/thecodingmachine/workadventure/blob/ab075ef6f4974766a3e2de12a230ac4df0954b58/front/src/Api/IframeListener.ts#L455-L459) function for this. + +Finally, there is a last type of utility function (a quite powerful one). It is quite common to need to call a function +from the iframe in WorkAdventure, and to expect a response. For those use cases, the iframe API comes with a +[`queryWorkadventure`](http://github.com/thecodingmachine/workadventure/blob/ab075ef6f4974766a3e2de12a230ac4df0954b58/front/src/Api/iframe/IframeApiContribution.ts#L30-L49) utility function. + +## Types + +The JSON messages sent over the postMessage API are strictly defined using Typescript types. +Those types are not defined using classical Typescript interfaces. + +Indeed, Typescript interfaces only exist at compilation time but cannot be enforced on runtime. The postMessage API +is an entry point to WorkAdventure, and as with any entry point, data must be checked (otherwise, a hacker could +send specially crafted JSON packages to try to hack WA). + +In WorkAdventure, we use the [generic-type-guard](https://github.com/mscharley/generic-type-guard) package. This package +allows us to create interfaces AND custom type guards in one go. + +Let's go back at our example. Let's have a look at the JSON message sent when we want to send a chat message from the API: + +```typescript +sendToWorkadventure({ + type: "chat", + data: { + message: message, + author: author, + }, +}); +``` + +The "data" part of the message is defined in `front/src/Api/Events/ChatEvent.ts`: + +```typescript +import * as tg from "generic-type-guard"; + +export const isChatEvent = new tg.IsInterface() + .withProperties({ + message: tg.isString, + author: tg.isString, + }) + .get(); +/** + * A message sent from the iFrame to the game to add a message in the chat. + */ +export type ChatEvent = tg.GuardedType; +``` + +Using the generic-type-guard library, we start by writing a type guard function (`isChatEvent`). +From this type guard, the library can automatically generate the `ChatEvent` type that we can refer in our code. + +The advantage of this technique is that, **at runtime**, WorkAdventure can verify that the JSON message received +over the postMessage API is indeed correctly formatted. + +If you are not familiar with Typescript type guards, you can read [an introduction to type guards in the Typescript documentation](https://www.typescriptlang.org/docs/handbook/advanced-types.html#user-defined-type-guards). + +### Typing one way messages + +For "one-way" messages (from the iframe to WorkAdventure), the `sendToWorkadventure` method expects the passed +object to be of type `IframeEvent`. + +Note: I'd like here to thank @jonnytest1 for helping set up this type system. It rocks ;) + +The `IFrameEvent` type is defined in `front/src/Api/Events/IframeEvent.ts`: + +```typescript +export type IframeEventMap = { + loadPage: LoadPageEvent; + chat: ChatEvent; + openPopup: OpenPopupEvent; + closePopup: ClosePopupEvent; + openTab: OpenTabEvent; + // ... + // All the possible messages go here + // The key goes into the "type" JSON property + // ... +}; +export interface IframeEvent { + type: T; + data: IframeEventMap[T]; +} +``` + +Similarly, if you want to type messages from WorkAdventure to the iframe, there is a very similar `IframeResponseEvent`. + +```typescript +export interface IframeResponseEventMap { + userInputChat: UserInputChatEvent; + enterEvent: EnterLeaveEvent; + leaveEvent: EnterLeaveEvent; + // ... + // All the possible messages go here + // The key goes into the "type" JSON property + // ... +} +export interface IframeResponseEvent { + type: T; + data: IframeResponseEventMap[T]; +} +``` + +### Typing queries (messages with answers) + +If you want to add a new "query" (if you are using the `queryWorkadventure` utility function), you will need to +define the type of the query and the type of the response. + +The signature of `queryWorkadventure` is: + +```typescript +function queryWorkadventure( + content: IframeQuery +): Promise +``` + +Yes, that's a bit cryptic. Hopefully, all you need to know is that to add a new query, you need to edit the `iframeQueryMapTypeGuards` +array in `front/src/Api/Events/IframeEvent.ts`: + +```typescript +export const iframeQueryMapTypeGuards = { + openCoWebsite: { + query: isOpenCoWebsiteEvent, + answer: isCoWebsite, + }, + getCoWebsites: { + query: tg.isUndefined, + answer: tg.isArray(isCoWebsite), + }, + // ... + // the `query` key points to the type guard of the query + // the `answer` key points to the type guard of the response +}; +``` + +### Responding to a query on the WorkAdventure side + +In the WorkAdventure code, each possible query should be handled by what we call an "answerer". + +Registering an answerer happens using the `iframeListener.registerAnswerer()` method. + +Here is a sample: + +```typescript +iframeListener.registerAnswerer("openCoWebsite", (openCoWebsiteEvent, source) => { + // ... + + return /*...*/; +}); +``` + +The `registerAnswerer` callback is passed the event, and should return a response (or a promise to the response) in the expected format +(the one you defined in the `answer` key of `iframeQueryMapTypeGuards`). + +Important: + +- there can be only one answerer registered for a given query type. +- if the answerer is not valid any more, you need to unregister the answerer using `iframeListener.unregisterAnswerer`. + + +## sendToWorkadventure VS queryWorkadventure + +- `sendToWorkadventure` is used to send messages one way from the iframe to WorkAdventure. No response is expected. In particular + if an error happens in WorkAdventure, the iframe will not be notified. +- `queryWorkadventure` is used to send queries that expect an answer. If an error happens in WorkAdventure (i.e. if an + exception is raised), the exception will be propagated to the iframe. + +Because `queryWorkadventure` handles exceptions properly, it can be interesting to use `queryWorkadventure` instead +of `sendToWorkadventure`, even for "one-way" messages. The return message type is simply `undefined` in this case. + diff --git a/docs/dev/images/scripting_1.svg b/docs/dev/images/scripting_1.svg new file mode 100644 index 00000000..cae529f3 --- /dev/null +++ b/docs/dev/images/scripting_1.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/docs/dev/images/scripting_2.svg b/docs/dev/images/scripting_2.svg new file mode 100644 index 00000000..a07294f4 --- /dev/null +++ b/docs/dev/images/scripting_2.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/docs/maps/api-player.md b/docs/maps/api-player.md index 39a13d9e..9af0b1c2 100644 --- a/docs/maps/api-player.md +++ b/docs/maps/api-player.md @@ -58,6 +58,34 @@ WA.onInit().then(() => { }) ``` +### Get the user-room token of the player + +``` +WA.player.userRoomToken: string; +``` + +The user-room token is available from the `WA.player.userRoomToken` property. + +This token can be used by third party services to authenticate a player and prove that the player is in a given room. +The token is generated by the administration panel linked to WorkAdventure. The token is a string and is depending on your implementation of the administration panel. +In WorkAdventure SAAS version, the token is a JWT token that contains information such as the player's room ID and its associated membership ID. + +If you are using the self-hosted version of WorkAdventure and you developed your own administration panel, the token can be anything. +By default, self-hosted versions of WorkAdventure don't come with an administration panel, so the token string will be empty. + +{.alert.alert-info} +A typical use-case for the user-room token is providing logo upload capabilities in a map. +The token can be used as a way to authenticate a WorkAdventure player and ensure he is indeed in the map and authorized to upload a logo. + +{.alert.alert-info} +You need to wait for the end of the initialization before accessing `WA.player.userRoomToken` + +```typescript +WA.onInit().then(() => { + console.log('Token: ', WA.player.userRoomToken); +}) +``` + ### Listen to player movement ``` WA.player.onPlayerMove(callback: HasPlayerMovedEventCallback): void; diff --git a/docs/maps/camera.md b/docs/maps/camera.md new file mode 100644 index 00000000..ac25c843 --- /dev/null +++ b/docs/maps/camera.md @@ -0,0 +1,86 @@ +{.section-title.accent.text-primary} +# Working with camera + +## Focusable Zones + +It is possible to define special regions on the map that can make the camera zoom and center on themselves. We call them "Focusable Zones". When player gets inside, his camera view will be altered - focused, zoomed and locked on defined zone, like this: + +
+ +
+ +### Adding new **Focusable Zone**: + +1. Make sure you are editing an **Object Layer** + +
+ +
+ +2. Select **Insert Rectangle** tool + +
+ +
+ +3. Define new object wherever you want. For example, you can make your chilling room event cosier! + +
+ +
+ +4. Edit this new object and click on **Add Property**, like this: + +
+ +
+ +5. Add a **bool** property of name *focusable*: + +
+ +
+ +6. Make sure it's checked! :) + +
+ +
+ +All should be set up now and your new **Focusable Zone** should be working fine! + +### Defining custom zoom margin: + +If you want, you can add an additional property to control how much should the camera zoom onto focusable zone. + +1. Like before, click on **Add Property** + +
+ +
+ +2. Add a **float** property of name *zoom_margin*: + +
+ +
+ +2. Define how much (in percentage value) should the zoom be decreased: + +
+ +
+ + For example, if you define your zone as a 300x200 rectangle, setting this property to 0.5 *(50%)* means the camera will try to fit within the viewport the entire zone + margin of 50% of its dimensions, so 450x300. + + - No margin defined + +
+ +
+ + - Margin set to **0.35** + +
+ +
\ No newline at end of file diff --git a/docs/maps/images/camera/0_focusable_zone.png b/docs/maps/images/camera/0_focusable_zone.png new file mode 100644 index 00000000..8b54f11f Binary files /dev/null and b/docs/maps/images/camera/0_focusable_zone.png differ diff --git a/docs/maps/images/camera/1_object_layer.png b/docs/maps/images/camera/1_object_layer.png new file mode 100644 index 00000000..6f57d0ae Binary files /dev/null and b/docs/maps/images/camera/1_object_layer.png differ diff --git a/docs/maps/images/camera/2_rectangle_zone.png b/docs/maps/images/camera/2_rectangle_zone.png new file mode 100644 index 00000000..9b0b9cda Binary files /dev/null and b/docs/maps/images/camera/2_rectangle_zone.png differ diff --git a/docs/maps/images/camera/3_define_new_zone.png b/docs/maps/images/camera/3_define_new_zone.png new file mode 100644 index 00000000..226028eb Binary files /dev/null and b/docs/maps/images/camera/3_define_new_zone.png differ diff --git a/docs/maps/images/camera/4_click_add_property.png b/docs/maps/images/camera/4_click_add_property.png new file mode 100644 index 00000000..9aa96a2f Binary files /dev/null and b/docs/maps/images/camera/4_click_add_property.png differ diff --git a/docs/maps/images/camera/5_add_focusable_prop.png b/docs/maps/images/camera/5_add_focusable_prop.png new file mode 100644 index 00000000..3ba1b955 Binary files /dev/null and b/docs/maps/images/camera/5_add_focusable_prop.png differ diff --git a/docs/maps/images/camera/6_make_sure_checked.png b/docs/maps/images/camera/6_make_sure_checked.png new file mode 100644 index 00000000..7fbcdb89 Binary files /dev/null and b/docs/maps/images/camera/6_make_sure_checked.png differ diff --git a/docs/maps/images/camera/7_add_zoom_margin.png b/docs/maps/images/camera/7_add_zoom_margin.png new file mode 100644 index 00000000..8e3f5256 Binary files /dev/null and b/docs/maps/images/camera/7_add_zoom_margin.png differ diff --git a/docs/maps/images/camera/8_optional_zoom_margin_defined.png b/docs/maps/images/camera/8_optional_zoom_margin_defined.png new file mode 100644 index 00000000..8b41d7d0 Binary files /dev/null and b/docs/maps/images/camera/8_optional_zoom_margin_defined.png differ diff --git a/docs/maps/images/camera/no_margin.png b/docs/maps/images/camera/no_margin.png new file mode 100644 index 00000000..b8c9dd18 Binary files /dev/null and b/docs/maps/images/camera/no_margin.png differ diff --git a/docs/maps/images/camera/with_margin.png b/docs/maps/images/camera/with_margin.png new file mode 100644 index 00000000..ffd057ea Binary files /dev/null and b/docs/maps/images/camera/with_margin.png differ diff --git a/docs/maps/images/mapProperties.png b/docs/maps/images/mapProperties.png new file mode 100644 index 00000000..d4001da4 Binary files /dev/null and b/docs/maps/images/mapProperties.png differ diff --git a/docs/maps/menu.php b/docs/maps/menu.php index 0bf0a7f9..10a2f4c5 100644 --- a/docs/maps/menu.php +++ b/docs/maps/menu.php @@ -51,6 +51,12 @@ return [ 'markdown' => 'maps.website-in-map', 'editUrl' => 'https://github.com/thecodingmachine/workadventure/edit/develop/docs/maps/website-in-map.md', ], + [ + 'title' => 'Camera', + 'url' => '/map-building/camera.md', + 'markdown' => 'maps.camera', + 'editUrl' => 'https://github.com/thecodingmachine/workadventure/edit/develop/docs/maps/camera.md', + ], [ 'title' => 'Variables', 'url' => '/map-building/variables.md', diff --git a/docs/maps/wa-maps.md b/docs/maps/wa-maps.md index 36875719..70581a57 100644 --- a/docs/maps/wa-maps.md +++ b/docs/maps/wa-maps.md @@ -92,3 +92,19 @@ You can add properties either on individual tiles of a tileset OR on a complete If you put a property on a layer, it will be triggered if your Woka walks on any tile of the layer. The exception is the "collides" property that can only be set on tiles, but not on a complete layer. + +## Insert helpful information in your map + +By setting properties on the map itself, you can help visitors know more about the creators of the map. + +The following *map* properties are supported: +* `mapName` (string) +* `mapDescription` (string) +* `mapCopyright` (string) + +And *each tileset* can also have a property called `tilesetCopyright` (string). + +Resulting in a "credit" page in the menu looking like this: + +![](images/mapProperties.png){.document-img} + diff --git a/front/src/Api/Events/ChangeZoneEvent.ts b/front/src/Api/Events/ChangeZoneEvent.ts new file mode 100644 index 00000000..e7ca3668 --- /dev/null +++ b/front/src/Api/Events/ChangeZoneEvent.ts @@ -0,0 +1,11 @@ +import * as tg from "generic-type-guard"; + +export const isChangeZoneEvent = new tg.IsInterface() + .withProperties({ + name: tg.isString, + }) + .get(); +/** + * A message sent from the game to the iFrame when a user enters or leaves a zone. + */ +export type ChangeZoneEvent = tg.GuardedType; diff --git a/front/src/Api/Events/GameStateEvent.ts b/front/src/Api/Events/GameStateEvent.ts index 112c2880..1f0f36ed 100644 --- a/front/src/Api/Events/GameStateEvent.ts +++ b/front/src/Api/Events/GameStateEvent.ts @@ -9,6 +9,7 @@ export const isGameStateEvent = new tg.IsInterface() startLayerName: tg.isUnion(tg.isString, tg.isNull), tags: tg.isArray(tg.isString), variables: tg.isObject, + userRoomToken: tg.isUnion(tg.isString, tg.isUndefined), }) .get(); /** diff --git a/front/src/Api/Events/IframeEvent.ts b/front/src/Api/Events/IframeEvent.ts index 081008c4..c338ddbe 100644 --- a/front/src/Api/Events/IframeEvent.ts +++ b/front/src/Api/Events/IframeEvent.ts @@ -28,6 +28,7 @@ import type { MessageReferenceEvent } from "./ui/TriggerActionMessageEvent"; import { isMessageReferenceEvent, isTriggerActionMessageEvent } from "./ui/TriggerActionMessageEvent"; import type { MenuRegisterEvent, UnregisterMenuEvent } from "./ui/MenuRegisterEvent"; import type { ChangeLayerEvent } from "./ChangeLayerEvent"; +import type { ChangeZoneEvent } from "./ChangeZoneEvent"; export interface TypedMessageEvent extends MessageEvent { data: T; @@ -76,6 +77,8 @@ export interface IframeResponseEventMap { leaveEvent: EnterLeaveEvent; enterLayerEvent: ChangeLayerEvent; leaveLayerEvent: ChangeLayerEvent; + enterZoneEvent: ChangeZoneEvent; + leaveZoneEvent: ChangeZoneEvent; buttonClickedEvent: ButtonClickedEvent; hasPlayerMoved: HasPlayerMovedEvent; menuItemClicked: MenuItemClickedEvent; diff --git a/front/src/Api/IframeListener.ts b/front/src/Api/IframeListener.ts index 3db35984..67b49344 100644 --- a/front/src/Api/IframeListener.ts +++ b/front/src/Api/IframeListener.ts @@ -31,6 +31,7 @@ import type { SetVariableEvent } from "./Events/SetVariableEvent"; import { ModifyEmbeddedWebsiteEvent, isEmbeddedWebsiteEvent } from "./Events/EmbeddedWebsiteEvent"; import { handleMenuRegistrationEvent, handleMenuUnregisterEvent } from "../Stores/MenuStore"; import type { ChangeLayerEvent } from "./Events/ChangeLayerEvent"; +import type { ChangeZoneEvent } from "./Events/ChangeZoneEvent"; type AnswererCallback = ( query: IframeQueryMap[T]["query"], @@ -414,6 +415,24 @@ class IframeListener { }); } + sendEnterZoneEvent(zoneName: string) { + this.postMessage({ + type: "enterZoneEvent", + data: { + name: zoneName, + } as ChangeZoneEvent, + }); + } + + sendLeaveZoneEvent(zoneName: string) { + this.postMessage({ + type: "leaveZoneEvent", + data: { + name: zoneName, + } as ChangeZoneEvent, + }); + } + hasPlayerMoved(event: HasPlayerMovedEvent) { if (this.sendPlayerMove) { this.postMessage({ diff --git a/front/src/Api/iframe/player.ts b/front/src/Api/iframe/player.ts index 078a1926..c46f3fbc 100644 --- a/front/src/Api/iframe/player.ts +++ b/front/src/Api/iframe/player.ts @@ -20,6 +20,12 @@ export const setTags = (_tags: string[]) => { let uuid: string | undefined; +let userRoomToken: string | undefined; + +export const setUserRoomToken = (token: string | undefined) => { + userRoomToken = token; +}; + export const setUuid = (_uuid: string | undefined) => { uuid = _uuid; }; @@ -67,6 +73,15 @@ export class WorkadventurePlayerCommands extends IframeApiContribution { diff --git a/front/src/Connexion/Room.ts b/front/src/Connexion/Room.ts index 4b6d82a4..044d8d67 100644 --- a/front/src/Connexion/Room.ts +++ b/front/src/Connexion/Room.ts @@ -116,11 +116,12 @@ export class Room { this._contactPage = data.contactPage || CONTACT_URL; return new MapDetail(data.mapUrl, data.textures); } catch (e) { - console.error("Error => getMapDetail", e, e.response); - //TODO fix me and manage Error class - if (e.response?.data === "Token decrypted error") { + if (axios.isAxiosError(e) && e.response?.status == 401 && e.response?.data === "Token decrypted error") { + console.warn("JWT token sent could not be decrypted. Maybe it expired?"); localUserStore.setAuthToken(null); window.location.assign("/login"); + } else { + console.error("Error => getMapDetail", e, e.response); } throw e; } diff --git a/front/src/Connexion/RoomConnection.ts b/front/src/Connexion/RoomConnection.ts index 4a4eea6e..9e4025b1 100644 --- a/front/src/Connexion/RoomConnection.ts +++ b/front/src/Connexion/RoomConnection.ts @@ -68,6 +68,7 @@ export class RoomConnection implements RoomConnection { private static websocketFactory: null | ((url: string) => any) = null; // eslint-disable-line @typescript-eslint/no-explicit-any private closed: boolean = false; private tags: string[] = []; + private _userRoomToken: string | undefined; // eslint-disable-next-line @typescript-eslint/no-explicit-any public static setWebsocketFactory(websocketFactory: (url: string) => any): void { @@ -211,6 +212,7 @@ export class RoomConnection implements RoomConnection { this.userId = roomJoinedMessage.getCurrentuserid(); this.tags = roomJoinedMessage.getTagList(); + this._userRoomToken = roomJoinedMessage.getUserroomtoken(); this.dispatch(EventMessage.CONNECT, { connection: this, @@ -713,4 +715,8 @@ export class RoomConnection implements RoomConnection { public getAllTags(): string[] { return this.tags; } + + public get userRoomToken(): string | undefined { + return this._userRoomToken; + } } diff --git a/front/src/Phaser/Game/CameraManager.ts b/front/src/Phaser/Game/CameraManager.ts new file mode 100644 index 00000000..19c4821a --- /dev/null +++ b/front/src/Phaser/Game/CameraManager.ts @@ -0,0 +1,178 @@ +import { Easing } from "../../types"; +import { HtmlUtils } from "../../WebRtc/HtmlUtils"; +import type { Box } from "../../WebRtc/LayoutManager"; +import type { Player } from "../Player/Player"; +import type { WaScaleManager } from "../Services/WaScaleManager"; +import type { GameScene } from "./GameScene"; + +export enum CameraMode { + Free = "Free", + Follow = "Follow", + Focus = "Focus", +} + +export class CameraManager extends Phaser.Events.EventEmitter { + private scene: GameScene; + private camera: Phaser.Cameras.Scene2D.Camera; + private cameraBounds: { x: number; y: number }; + private waScaleManager: WaScaleManager; + + private cameraMode: CameraMode = CameraMode.Free; + + private restoreZoomTween?: Phaser.Tweens.Tween; + private startFollowTween?: Phaser.Tweens.Tween; + + private cameraFollowTarget?: { x: number; y: number }; + + constructor(scene: GameScene, cameraBounds: { x: number; y: number }, waScaleManager: WaScaleManager) { + super(); + this.scene = scene; + + this.camera = scene.cameras.main; + this.cameraBounds = cameraBounds; + + this.waScaleManager = waScaleManager; + + this.initCamera(); + + this.bindEventHandlers(); + } + + public destroy(): void { + this.scene.game.events.off("wa-scale-manager:refresh-focus-on-target"); + super.destroy(); + } + + public getCamera(): Phaser.Cameras.Scene2D.Camera { + return this.camera; + } + + public enterFocusMode( + focusOn: { x: number; y: number; width: number; height: number }, + margin: number = 0, + duration: number = 1000 + ): void { + this.setCameraMode(CameraMode.Focus); + this.waScaleManager.saveZoom(); + this.waScaleManager.setFocusTarget(focusOn); + + this.restoreZoomTween?.stop(); + this.startFollowTween?.stop(); + const marginMult = 1 + margin; + const targetZoomModifier = this.waScaleManager.getTargetZoomModifierFor( + focusOn.width * marginMult, + focusOn.height * marginMult + ); + const currentZoomModifier = this.waScaleManager.zoomModifier; + const zoomModifierChange = targetZoomModifier - currentZoomModifier; + this.camera.stopFollow(); + this.cameraFollowTarget = undefined; + this.camera.pan( + focusOn.x + focusOn.width * 0.5 * marginMult, + focusOn.y + focusOn.height * 0.5 * marginMult, + duration, + Easing.SineEaseOut, + true, + (camera, progress, x, y) => { + this.waScaleManager.zoomModifier = currentZoomModifier + progress * zoomModifierChange; + } + ); + } + + public leaveFocusMode(player: Player): void { + this.waScaleManager.setFocusTarget(); + this.startFollow(player, 1000); + this.restoreZoom(1000); + } + + public startFollow(target: object | Phaser.GameObjects.GameObject, duration: number = 0): void { + this.cameraFollowTarget = target as { x: number; y: number }; + this.setCameraMode(CameraMode.Follow); + if (duration === 0) { + this.camera.startFollow(target, true); + return; + } + const oldPos = { x: this.camera.scrollX, y: this.camera.scrollY }; + this.startFollowTween = this.scene.tweens.addCounter({ + from: 0, + to: 1, + duration, + ease: Easing.SineEaseOut, + onUpdate: (tween: Phaser.Tweens.Tween) => { + if (!this.cameraFollowTarget) { + return; + } + const shiftX = + (this.cameraFollowTarget.x - this.camera.worldView.width * 0.5 - oldPos.x) * tween.getValue(); + const shiftY = + (this.cameraFollowTarget.y - this.camera.worldView.height * 0.5 - oldPos.y) * tween.getValue(); + this.camera.setScroll(oldPos.x + shiftX, oldPos.y + shiftY); + }, + onComplete: () => { + this.camera.startFollow(target, true); + }, + }); + } + + /** + * Updates the offset of the character compared to the center of the screen according to the layout manager + * (tries to put the character in the center of the remaining space if there is a discussion going on. + */ + public updateCameraOffset(array: Box): void { + const xCenter = (array.xEnd - array.xStart) / 2 + array.xStart; + const yCenter = (array.yEnd - array.yStart) / 2 + array.yStart; + + const game = HtmlUtils.querySelectorOrFail("#game canvas"); + // Let's put this in Game coordinates by applying the zoom level: + + this.camera.setFollowOffset( + ((xCenter - game.offsetWidth / 2) * window.devicePixelRatio) / this.scene.scale.zoom, + ((yCenter - game.offsetHeight / 2) * window.devicePixelRatio) / this.scene.scale.zoom + ); + } + + public isCameraLocked(): boolean { + return this.cameraMode === CameraMode.Focus; + } + + private setCameraMode(mode: CameraMode): void { + if (this.cameraMode === mode) { + return; + } + this.cameraMode = mode; + } + + private restoreZoom(duration: number = 0): void { + if (duration === 0) { + this.waScaleManager.zoomModifier = this.waScaleManager.getSaveZoom(); + return; + } + this.restoreZoomTween?.stop(); + this.restoreZoomTween = this.scene.tweens.addCounter({ + from: this.waScaleManager.zoomModifier, + to: this.waScaleManager.getSaveZoom(), + duration, + ease: Easing.SineEaseOut, + onUpdate: (tween: Phaser.Tweens.Tween) => { + this.waScaleManager.zoomModifier = tween.getValue(); + }, + }); + } + + private initCamera() { + this.camera = this.scene.cameras.main; + this.camera.setBounds(0, 0, this.cameraBounds.x, this.cameraBounds.y); + } + + private bindEventHandlers(): void { + this.scene.game.events.on( + "wa-scale-manager:refresh-focus-on-target", + (focusOn: { x: number; y: number; width: number; height: number }) => { + if (!focusOn) { + return; + } + this.camera.centerOn(focusOn.x + focusOn.width * 0.5, focusOn.y + focusOn.height * 0.5); + } + ); + } +} diff --git a/front/src/Phaser/Game/GameMap.ts b/front/src/Phaser/Game/GameMap.ts index 8fe0e329..6688acb8 100644 --- a/front/src/Phaser/Game/GameMap.ts +++ b/front/src/Phaser/Game/GameMap.ts @@ -1,8 +1,15 @@ -import type { ITiledMap, ITiledMapLayer, ITiledMapProperty } from "../Map/ITiledMap"; +import type { + ITiledMap, + ITiledMapLayer, + ITiledMapObject, + ITiledMapObjectLayer, + ITiledMapProperty, +} from "../Map/ITiledMap"; import { flattenGroupLayersMap } from "../Map/LayersFlattener"; import TilemapLayer = Phaser.Tilemaps.TilemapLayer; import { DEPTH_OVERLAY_INDEX } from "./DepthIndexes"; import { GameMapProperties } from "./GameMapProperties"; +import { MathUtils } from "../../Utils/MathUtils"; export type PropertyChangeCallback = ( newValue: string | number | boolean | undefined, @@ -15,24 +22,48 @@ export type layerChangeCallback = ( allLayersOnNewPosition: Array ) => void; +export type zoneChangeCallback = ( + zonesChangedByAction: Array, + allZonesOnNewPosition: Array +) => void; + /** * A wrapper around a ITiledMap interface to provide additional capabilities. * It is used to handle layer properties. */ export class GameMap { - // oldKey is the index of the previous tile. + /** + * oldKey is the index of the previous tile. + */ private oldKey: number | undefined; - // key is the index of the current tile. + /** + * key is the index of the current tile. + */ private key: number | undefined; + /** + * oldPosition is the previous position of the player. + */ + private oldPosition: { x: number; y: number } | undefined; + /** + * position is the current position of the player. + */ + private position: { x: number; y: number } | undefined; + private lastProperties = new Map(); private propertiesChangeCallbacks = new Map>(); + private enterLayerCallbacks = Array(); private leaveLayerCallbacks = Array(); + private enterZoneCallbacks = Array(); + private leaveZoneCallbacks = Array(); + private tileNameMap = new Map(); private tileSetPropertyMap: { [tile_index: number]: Array } = {}; public readonly flatLayers: ITiledMapLayer[]; + public readonly tiledObjects: ITiledMapObject[]; public readonly phaserLayers: TilemapLayer[] = []; + public readonly zones: ITiledMapObject[] = []; public exitUrls: Array = []; @@ -44,6 +75,9 @@ export class GameMap { terrains: Array ) { this.flatLayers = flattenGroupLayersMap(map); + this.tiledObjects = this.getObjectsFromLayers(this.flatLayers); + this.zones = this.tiledObjects.filter((object) => object.type === "zone"); + let depth = -2; for (const layer of this.flatLayers) { if (layer.type === "tilelayer") { @@ -88,6 +122,10 @@ export class GameMap { * This will trigger events if properties are changing. */ public setPosition(x: number, y: number) { + this.oldPosition = this.position; + this.position = { x, y }; + this.triggerZonesChange(); + this.oldKey = this.key; const xMap = Math.floor(x / this.map.tilewidth); @@ -126,7 +164,7 @@ export class GameMap { } } - private triggerLayersChange() { + private triggerLayersChange(): void { const layersByOldKey = this.oldKey ? this.getLayersByKey(this.oldKey) : []; const layersByNewKey = this.key ? this.getLayersByKey(this.key) : []; @@ -155,6 +193,53 @@ export class GameMap { } } + /** + * We use Tiled Objects with type "zone" as zones with defined x, y, width and height for easier event triggering. + */ + private triggerZonesChange(): void { + const zonesByOldPosition = this.oldPosition + ? this.zones.filter((zone) => { + if (!this.oldPosition) { + return false; + } + return MathUtils.isOverlappingWithRectangle(this.oldPosition, zone); + }) + : []; + + const zonesByNewPosition = this.position + ? this.zones.filter((zone) => { + if (!this.position) { + return false; + } + return MathUtils.isOverlappingWithRectangle(this.position, zone); + }) + : []; + + const enterZones = new Set(zonesByNewPosition); + const leaveZones = new Set(zonesByOldPosition); + + enterZones.forEach((zone) => { + if (leaveZones.has(zone)) { + leaveZones.delete(zone); + enterZones.delete(zone); + } + }); + + if (enterZones.size > 0) { + const zonesArray = Array.from(enterZones); + for (const callback of this.enterZoneCallbacks) { + callback(zonesArray, zonesByNewPosition); + } + } + + if (leaveZones.size > 0) { + const zonesArray = Array.from(leaveZones); + for (const callback of this.leaveZoneCallbacks) { + callback(zonesArray, zonesByNewPosition); + } + } + } + public getCurrentProperties(): Map { return this.lastProperties; } @@ -251,6 +336,20 @@ export class GameMap { this.leaveLayerCallbacks.push(callback); } + /** + * Registers a callback called when the user moves inside another zone. + */ + public onEnterZone(callback: zoneChangeCallback) { + this.enterZoneCallbacks.push(callback); + } + + /** + * Registers a callback called when the user moves outside another zone. + */ + public onLeaveZone(callback: zoneChangeCallback) { + this.leaveZoneCallbacks.push(callback); + } + public findLayer(layerName: string): ITiledMapLayer | undefined { return this.flatLayers.find((layer) => layer.name === layerName); } @@ -362,4 +461,17 @@ export class GameMap { this.trigger(oldPropName, oldPropValue, undefined, emptyProps); } } + + private getObjectsFromLayers(layers: ITiledMapLayer[]): ITiledMapObject[] { + const objects: ITiledMapObject[] = []; + + const objectLayers = layers.filter((layer) => layer.type === "objectgroup"); + for (const objectLayer of objectLayers) { + if (objectLayer.type === "objectgroup") { + objects.push(...objectLayer.objects); + } + } + + return objects; + } } diff --git a/front/src/Phaser/Game/GameScene.ts b/front/src/Phaser/Game/GameScene.ts index 14a502ad..558b4d21 100644 --- a/front/src/Phaser/Game/GameScene.ts +++ b/front/src/Phaser/Game/GameScene.ts @@ -20,7 +20,7 @@ import { EmbeddedWebsiteManager } from "./EmbeddedWebsiteManager"; import { lazyLoadPlayerCharacterTextures, loadCustomTexture } from "../Entity/PlayerTexturesLoadingManager"; import { lazyLoadCompanionResource } from "../Companion/CompanionTexturesLoadingManager"; -import { Box, ON_ACTION_TRIGGER_BUTTON } from "../../WebRtc/LayoutManager"; +import { ON_ACTION_TRIGGER_BUTTON } from "../../WebRtc/LayoutManager"; import { iframeListener } from "../../Api/IframeListener"; import { DEBUG_MODE, JITSI_PRIVATE_MODE, MAX_PER_GROUP, POSITION_DELAY } from "../../Enum/EnvironmentVariable"; import { ProtobufClientUtils } from "../../Network/ProtobufClientUtils"; @@ -65,6 +65,7 @@ import type { ActionableItem } from "../Items/ActionableItem"; import type { ItemFactoryInterface } from "../Items/ItemFactoryInterface"; import type { ITiledMap, ITiledMapLayer, ITiledMapProperty, ITiledMapObject, ITiledTileSet } from "../Map/ITiledMap"; import type { AddPlayerInterface } from "./AddPlayerInterface"; +import { CameraManager } from "./CameraManager"; import type { HasPlayerMovedEvent } from "../../Api/Events/HasPlayerMovedEvent"; import type { Character } from "../Entity/Character"; @@ -196,6 +197,7 @@ export class GameScene extends DirtyScene { private pinchManager: PinchManager | undefined; private mapTransitioning: boolean = false; //used to prevent transitions happening at the same time. private emoteManager!: EmoteManager; + private cameraManager!: CameraManager; private preloading: boolean = true; private startPositionCalculator!: StartPositionCalculator; private sharedVariablesManager!: SharedVariablesManager; @@ -571,7 +573,13 @@ export class GameScene extends DirtyScene { this.createCurrentPlayer(); this.removeAllRemotePlayers(); //cleanup the list of remote players in case the scene was rebooted - this.initCamera(); + this.cameraManager = new CameraManager( + this, + { x: this.Map.widthInPixels, y: this.Map.heightInPixels }, + waScaleManager + ); + biggestAvailableAreaStore.recompute(); + this.cameraManager.startFollow(this.CurrentPlayer); this.animatedTiles.init(this.Map); this.events.on("tileanimationupdate", () => (this.dirty = true)); @@ -612,7 +620,7 @@ export class GameScene extends DirtyScene { // From now, this game scene will be notified of reposition events this.biggestAvailableAreaStoreUnsubscribe = biggestAvailableAreaStore.subscribe((box) => - this.updateCameraOffset(box) + this.cameraManager.updateCameraOffset(box) ); new GameMapPropertiesListener(this, this.gameMap).register(); @@ -665,7 +673,7 @@ export class GameScene extends DirtyScene { * Initializes the connection to Pusher. */ private connect(): void { - const camera = this.cameras.main; + const camera = this.cameraManager.getCamera(); connectionManager .connectToRoomSocket( @@ -799,6 +807,42 @@ export class GameScene extends DirtyScene { iframeListener.sendLeaveLayerEvent(layer.name); }); }); + + this.gameMap.onEnterZone((zones) => { + for (const zone of zones) { + const focusable = zone.properties?.find((property) => property.name === "focusable"); + if (focusable && focusable.value === true) { + const zoomMargin = zone.properties?.find((property) => property.name === "zoom_margin"); + this.cameraManager.enterFocusMode( + zone, + zoomMargin ? Math.max(0, Number(zoomMargin.value)) : undefined + ); + break; + } + } + zones.forEach((zone) => { + iframeListener.sendEnterZoneEvent(zone.name); + }); + }); + + this.gameMap.onLeaveZone((zones) => { + for (const zone of zones) { + const focusable = zone.properties?.find((property) => property.name === "focusable"); + if (focusable && focusable.value === true) { + this.cameraManager.leaveFocusMode(this.CurrentPlayer); + break; + } + } + zones.forEach((zone) => { + iframeListener.sendLeaveZoneEvent(zone.name); + }); + }); + + // this.gameMap.onLeaveLayer((layers) => { + // layers.forEach((layer) => { + // iframeListener.sendLeaveLayerEvent(layer.name); + // }); + // }); }); } @@ -1187,6 +1231,7 @@ ${escapedMessage} roomId: this.roomUrl, tags: this.connection ? this.connection.getAllTags() : [], variables: this.sharedVariablesManager.variables, + userRoomToken: this.connection ? this.connection.userRoomToken : "", }; }); this.iframeSubscriptionList.push( @@ -1389,6 +1434,7 @@ ${escapedMessage} this.userInputManager.destroy(); this.pinchManager?.destroy(); this.emoteManager.destroy(); + this.cameraManager.destroy(); this.peerStoreUnsubscribe(); this.emoteUnsubscribe(); this.emoteMenuUnsubscribe(); @@ -1478,13 +1524,6 @@ ${escapedMessage} } } - //todo: in a dedicated class/function? - initCamera() { - this.cameras.main.setBounds(0, 0, this.Map.widthInPixels, this.Map.heightInPixels); - this.cameras.main.startFollow(this.CurrentPlayer, true); - biggestAvailableAreaStore.recompute(); - } - createCollisionWithPlayer() { //add collision layer for (const phaserLayer of this.gameMap.phaserLayers) { @@ -1896,23 +1935,6 @@ ${escapedMessage} biggestAvailableAreaStore.recompute(); } - /** - * Updates the offset of the character compared to the center of the screen according to the layout manager - * (tries to put the character in the center of the remaining space if there is a discussion going on. - */ - private updateCameraOffset(array: Box): void { - const xCenter = (array.xEnd - array.xStart) / 2 + array.xStart; - const yCenter = (array.yEnd - array.yStart) / 2 + array.yStart; - - const game = HtmlUtils.querySelectorOrFail("#game canvas"); - // Let's put this in Game coordinates by applying the zoom level: - - this.cameras.main.setFollowOffset( - ((xCenter - game.offsetWidth / 2) * window.devicePixelRatio) / this.scale.zoom, - ((yCenter - game.offsetHeight / 2) * window.devicePixelRatio) / this.scale.zoom - ); - } - public startJitsi(roomName: string, jwt?: string): void { const allProps = this.gameMap.getCurrentProperties(); const jitsiConfig = this.safeParseJSONstring( @@ -1981,6 +2003,9 @@ ${escapedMessage} } zoomByFactor(zoomFactor: number) { + if (this.cameraManager.isCameraLocked()) { + return; + } waScaleManager.zoomModifier *= zoomFactor; biggestAvailableAreaStore.recompute(); } diff --git a/front/src/Phaser/Game/SharedVariablesManager.ts b/front/src/Phaser/Game/SharedVariablesManager.ts index 8f913765..5b5867dc 100644 --- a/front/src/Phaser/Game/SharedVariablesManager.ts +++ b/front/src/Phaser/Game/SharedVariablesManager.ts @@ -1,7 +1,7 @@ import type { RoomConnection } from "../../Connexion/RoomConnection"; import { iframeListener } from "../../Api/IframeListener"; import type { GameMap } from "./GameMap"; -import type { ITiledMapLayer, ITiledMapObject, ITiledMapObjectLayer } from "../Map/ITiledMap"; +import type { ITiledMapLayer, ITiledMapObject } from "../Map/ITiledMap"; import { GameMapProperties } from "./GameMapProperties"; interface Variable { diff --git a/front/src/Phaser/Items/Computer/computer.ts b/front/src/Phaser/Items/Computer/computer.ts index 4665c546..41fb6fc4 100644 --- a/front/src/Phaser/Items/Computer/computer.ts +++ b/front/src/Phaser/Items/Computer/computer.ts @@ -1,5 +1,4 @@ import * as Phaser from "phaser"; -import { Scene } from "phaser"; import Sprite = Phaser.GameObjects.Sprite; import type { ITiledMapObject } from "../../Map/ITiledMap"; import type { ItemFactoryInterface } from "../ItemFactoryInterface"; diff --git a/front/src/Phaser/Services/HdpiManager.ts b/front/src/Phaser/Services/HdpiManager.ts index 116f6816..9c4e9af4 100644 --- a/front/src/Phaser/Services/HdpiManager.ts +++ b/front/src/Phaser/Services/HdpiManager.ts @@ -94,7 +94,7 @@ export class HdpiManager { /** * We only accept integer but we make an exception for 1.5 */ - private getOptimalZoomLevel(realPixelNumber: number): number { + public getOptimalZoomLevel(realPixelNumber: number): number { const result = Math.sqrt(realPixelNumber / this.minRecommendedGamePixelsNumber); if (1.5 <= result && result < 2) { return 1.5; diff --git a/front/src/Phaser/Services/WaScaleManager.ts b/front/src/Phaser/Services/WaScaleManager.ts index 5ceaeb71..447b6a1f 100644 --- a/front/src/Phaser/Services/WaScaleManager.ts +++ b/front/src/Phaser/Services/WaScaleManager.ts @@ -5,13 +5,15 @@ import type { Game } from "../Game/Game"; import { ResizableScene } from "../Login/ResizableScene"; import { HtmlUtils } from "../../WebRtc/HtmlUtils"; -class WaScaleManager { +export class WaScaleManager { private hdpiManager: HdpiManager; private scaleManager!: ScaleManager; private game!: Game; private actualZoom: number = 1; private _saveZoom: number = 1; + private focusTarget?: { x: number; y: number; width: number; height: number }; + public constructor(private minGamePixelsNumber: number, private absoluteMinPixelNumber: number) { this.hdpiManager = new HdpiManager(minGamePixelsNumber, absoluteMinPixelNumber); } @@ -23,18 +25,14 @@ class WaScaleManager { public applyNewSize() { const { width, height } = coWebsiteManager.getGameSize(); - - let devicePixelRatio = 1; - if (window.devicePixelRatio) { - devicePixelRatio = window.devicePixelRatio; - } - + const devicePixelRatio = window.devicePixelRatio ?? 1; const { game: gameSize, real: realSize } = this.hdpiManager.getOptimalGameSize({ width: width * devicePixelRatio, height: height * devicePixelRatio, }); this.actualZoom = realSize.width / gameSize.width / devicePixelRatio; + this.scaleManager.setZoom(realSize.width / gameSize.width / devicePixelRatio); this.scaleManager.resize(gameSize.width, gameSize.height); @@ -59,6 +57,34 @@ class WaScaleManager { this.game.markDirty(); } + /** + * Use this in case of resizing while focusing on something + */ + public refreshFocusOnTarget(): void { + if (!this.focusTarget) { + return; + } + this.zoomModifier = this.getTargetZoomModifierFor(this.focusTarget.width, this.focusTarget.height); + this.game.events.emit("wa-scale-manager:refresh-focus-on-target", this.focusTarget); + } + + public setFocusTarget(targetDimensions?: { x: number; y: number; width: number; height: number }): void { + this.focusTarget = targetDimensions; + } + + public getTargetZoomModifierFor(viewportWidth: number, viewportHeight: number) { + const { width: gameWidth, height: gameHeight } = coWebsiteManager.getGameSize(); + const devicePixelRatio = window.devicePixelRatio ?? 1; + + const { game: gameSize, real: realSize } = this.hdpiManager.getOptimalGameSize({ + width: gameWidth * devicePixelRatio, + height: gameHeight * devicePixelRatio, + }); + const desiredZoom = Math.min(realSize.width / viewportWidth, realSize.height / viewportHeight); + const realPixelNumber = gameWidth * devicePixelRatio * gameHeight * devicePixelRatio; + return desiredZoom / (this.hdpiManager.getOptimalZoomLevel(realPixelNumber) || 1); + } + public get zoomModifier(): number { return this.hdpiManager.zoomModifier; } @@ -72,6 +98,10 @@ class WaScaleManager { this._saveZoom = this.hdpiManager.zoomModifier; } + public getSaveZoom(): number { + return this._saveZoom; + } + public restoreZoom(): void { this.hdpiManager.zoomModifier = this._saveZoom; this.applyNewSize(); diff --git a/front/src/Utils/MathUtils.ts b/front/src/Utils/MathUtils.ts new file mode 100644 index 00000000..aea3bb11 --- /dev/null +++ b/front/src/Utils/MathUtils.ts @@ -0,0 +1,25 @@ +export class MathUtils { + /** + * + * @param p Position to check. + * @param r Rectangle to check the overlap against. + * @returns true is overlapping + */ + public static isOverlappingWithRectangle( + p: { x: number; y: number }, + r: { x: number; y: number; width: number; height: number } + ): boolean { + return this.isBetween(p.x, r.x, r.x + r.width) && this.isBetween(p.y, r.y, r.y + r.height); + } + + /** + * + * @param value Value to check + * @param min inclusive min value + * @param max inclusive max value + * @returns true if value is in + */ + public static isBetween(value: number, min: number, max: number): boolean { + return value >= min && value <= max; + } +} diff --git a/front/src/WebRtc/CoWebsiteManager.ts b/front/src/WebRtc/CoWebsiteManager.ts index 09de4b41..7a003604 100644 --- a/front/src/WebRtc/CoWebsiteManager.ts +++ b/front/src/WebRtc/CoWebsiteManager.ts @@ -642,6 +642,7 @@ class CoWebsiteManager { private fire(): void { this._onResize.next(); waScaleManager.applyNewSize(); + waScaleManager.refreshFocusOnTarget(); } private fullscreen(): void { diff --git a/front/src/iframe_api.ts b/front/src/iframe_api.ts index dcd10fdc..93415b0d 100644 --- a/front/src/iframe_api.ts +++ b/front/src/iframe_api.ts @@ -15,11 +15,11 @@ import ui from "./Api/iframe/ui"; import sound from "./Api/iframe/sound"; import room, { setMapURL, setRoomId } from "./Api/iframe/room"; import state, { initVariables } from "./Api/iframe/state"; -import player, { setPlayerName, setTags, setUuid } from "./Api/iframe/player"; +import player, { setPlayerName, setTags, setUserRoomToken, setUuid } from "./Api/iframe/player"; import type { ButtonDescriptor } from "./Api/iframe/Ui/ButtonDescriptor"; import type { Popup } from "./Api/iframe/Ui/Popup"; import type { Sound } from "./Api/iframe/Sound/Sound"; -import { answerPromises, queryWorkadventure, sendToWorkadventure } from "./Api/iframe/IframeApiContribution"; +import { answerPromises, queryWorkadventure } from "./Api/iframe/IframeApiContribution"; // Notify WorkAdventure that we are ready to receive data const initPromise = queryWorkadventure({ @@ -32,6 +32,7 @@ const initPromise = queryWorkadventure({ setTags(state.tags); setUuid(state.uuid); initVariables(state.variables as Map); + setUserRoomToken(state.userRoomToken); }); const wa = { diff --git a/front/src/index.ts b/front/src/index.ts index 3cb8d048..a2064cd8 100644 --- a/front/src/index.ts +++ b/front/src/index.ts @@ -144,10 +144,12 @@ window.addEventListener("resize", function (event) { coWebsiteManager.resetStyleMain(); waScaleManager.applyNewSize(); + waScaleManager.refreshFocusOnTarget(); }); coWebsiteManager.onResize.subscribe(() => { waScaleManager.applyNewSize(); + waScaleManager.refreshFocusOnTarget(); }); iframeListener.init(); diff --git a/front/src/types.ts b/front/src/types.ts index d957a2c2..d1ff3475 100644 --- a/front/src/types.ts +++ b/front/src/types.ts @@ -21,3 +21,34 @@ export interface IVirtualJoystick extends Phaser.GameObjects.GameObject { visible: boolean; createCursorKeys: () => CursorKeys; } + +export enum Easing { + Linear = "Linear", + QuadEaseIn = "Quad.easeIn", + CubicEaseIn = "Cubic.easeIn", + QuartEaseIn = "Quart.easeIn", + QuintEaseIn = "Quint.easeIn", + SineEaseIn = "Sine.easeIn", + ExpoEaseIn = "Expo.easeIn", + CircEaseIn = "Circ.easeIn", + BackEaseIn = "Back.easeIn", + BounceEaseIn = "Bounce.easeIn", + QuadEaseOut = "Quad.easeOut", + CubicEaseOut = "Cubic.easeOut", + QuartEaseOut = "Quart.easeOut", + QuintEaseOut = "Quint.easeOut", + SineEaseOut = "Sine.easeOut", + ExpoEaseOut = "Expo.easeOut", + CircEaseOut = "Circ.easeOut", + BackEaseOut = "Back.easeOut", + BounceEaseOut = "Bounce.easeOut", + QuadEaseInOut = "Quad.easeInOut", + CubicEaseInOut = "Cubic.easeInOut", + QuartEaseInOut = "Quart.easeInOut", + QuintEaseInOut = "Quint.easeInOut", + SineEaseInOut = "Sine.easeInOut", + ExpoEaseInOut = "Expo.easeInOut", + CircEaseInOut = "Circ.easeInOut", + BackEaseInOut = "Back.easeInOut", + BounceEaseInOut = "Bounce.easeInOut", +} diff --git a/maps/tests/Metadata/getCurrentRoom.js b/maps/tests/Metadata/getCurrentRoom.js index df3a995c..fa8e0226 100644 --- a/maps/tests/Metadata/getCurrentRoom.js +++ b/maps/tests/Metadata/getCurrentRoom.js @@ -4,6 +4,7 @@ WA.onInit().then(() => { console.log('Player name: ', WA.player.name); console.log('Player id: ', WA.player.id); console.log('Player tags: ', WA.player.tags); + console.log('Player token: ', WA.player.userRoomToken); }); WA.room.getTiledMap().then((data) => { diff --git a/maps/tests/focusable_zone_map.json b/maps/tests/focusable_zone_map.json new file mode 100644 index 00000000..8a9aa6af --- /dev/null +++ b/maps/tests/focusable_zone_map.json @@ -0,0 +1,410 @@ +{ "compressionlevel":-1, + "height":17, + "infinite":false, + "layers":[ + { + "data":[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 444, 444, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 444, 444, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 444, 444, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + "height":17, + "id":6, + "name":"start", + "opacity":1, + "type":"tilelayer", + "visible":true, + "width":31, + "x":0, + "y":0 + }, + { + "data":[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 443, 443, 443, 443, 443, 443, 443, 443, 443, 443, 443, 443, 443, 443, 443, 443, 443, 443, 443, 443, 443, 443, 443, 443, 443, 443, 443, 443, 443, 443, 443, 443, 0, 0, 0, 0, 0, 0, 443, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 443, 0, 0, 443, 443, 443, 0, 0, 443, 443, 0, 0, 0, 0, 0, 0, 443, 0, 0, 0, 443, 443, 0, 0, 0, 0, 443, 443, 0, 0, 0, 443, 0, 0, 0, 0, 0, 0, 0, 443, 443, 0, 0, 0, 0, 0, 0, 443, 0, 0, 0, 443, 443, 0, 0, 0, 0, 443, 443, 0, 0, 0, 443, 0, 0, 0, 0, 0, 0, 0, 443, 443, 0, 0, 0, 0, 0, 0, 443, 0, 0, 0, 443, 443, 0, 0, 0, 0, 443, 443, 0, 0, 0, 443, 0, 0, 0, 0, 0, 0, 0, 443, 443, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 443, 443, 0, 0, 0, 0, 443, 443, 0, 0, 0, 0, 0, 0, 0, 443, 443, 0, 0, 443, 443, 443, 443, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 443, 443, 0, 0, 443, 443, 443, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 443, 443, 0, 0, 443, 443, 443, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 443, 443, 0, 0, 443, 443, 443, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 443, 443, 443, 443, 443, 443, 0, 0, 0, 0, 443, 0, 0, 0, 0, 0, 0, 0, 443, 443, 443, 443, 0, 0, 0, 0, 0, 0, 0, 0, 0, 443, 443, 443, 443, 443, 443, 0, 0, 0, 0, 443, 0, 0, 0, 0, 0, 0, 0, 443, 443, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 443, 443, 443, 0, 0, 0, 0, 0, 0, 0, 443, 443, 443, 443, 443, 443, 443, 443, 443, 443, 443, 443, 443, 443, 443, 443, 443, 443, 443, 443, 443, 443, 443, 443, 443, 443, 443, 443, 443, 443, 443, 443, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + "height":17, + "id":7, + "name":"collisions", + "opacity":1, + "type":"tilelayer", + "visible":true, + "width":31, + "x":0, + "y":0 + }, + { + "data":[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 454, 454, 454, 454, 454, 454, 454, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 454, 454, 454, 454, 454, 454, 454, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 454, 454, 454, 454, 454, 454, 454, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 454, 454, 454, 454, 454, 454, 454, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 454, 454, 454, 454, 454, 454, 454, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 454, 454, 454, 454, 454, 454, 454, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 454, 454, 454, 454, 454, 454, 454, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 454, 454, 454, 454, 454, 454, 454, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 454, 454, 454, 454, 454, 454, 454, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 454, 454, 454, 454, 454, 454, 454, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 454, 454, 454, 454, 454, 454, 454, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + "height":17, + "id":29, + "name":"jitsiMeetingRoom", + "opacity":1, + "properties":[ + { + "name":"jitsiRoom", + "type":"string", + "value":"MeetingRoom" + }], + "type":"tilelayer", + "visible":true, + "width":31, + "x":0, + "y":0 + }, + { + "data":[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 454, 454, 454, 454, 454, 454, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 454, 454, 454, 454, 454, 454, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 454, 454, 454, 454, 454, 454, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 454, 454, 454, 454, 454, 454, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + "height":17, + "id":38, + "name":"jitsiChillzone", + "opacity":1, + "properties":[ + { + "name":"jitsiRoom", + "type":"string", + "value":"ChillZone" + }, + { + "name":"jitsiTrigger", + "type":"string", + "value":"onaction" + }], + "type":"tilelayer", + "visible":true, + "width":31, + "x":0, + "y":0 + }, + { + "data":[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 446, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + "height":17, + "id":23, + "name":"clockZone", + "opacity":1, + "properties":[ + { + "name":"zone", + "type":"string", + "value":"clock" + }], + "type":"tilelayer", + "visible":true, + "width":31, + "x":0, + "y":0 + }, + { + "data":[201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 223, 223, 223, 223, 223, 223, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 223, 223, 223, 223, 223, 223, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 223, 223, 223, 223, 223, 223, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 223, 223, 223, 223, 223, 223, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201, 201], + "height":17, + "id":4, + "name":"floor", + "opacity":1, + "type":"tilelayer", + "visible":true, + "width":31, + "x":0, + "y":0 + }, + { + "data":[49, 58, 58, 58, 58, 58, 58, 42, 58, 58, 58, 58, 58, 58, 58, 58, 58, 58, 58, 58, 58, 58, 42, 57, 57, 57, 57, 57, 57, 57, 50, 45, 63, 63, 63, 63, 63, 63, 45, 63, 63, 63, 63, 63, 63, 63, 63, 63, 63, 63, 63, 63, 63, 45, 63, 63, 63, 63, 63, 63, 63, 45, 45, 73, 73, 73, 73, 73, 73, 45, 73, 73, 73, 73, 73, 73, 73, 73, 73, 73, 73, 73, 73, 73, 45, 73, 73, 73, 73, 73, 73, 73, 45, 45, 0, 0, 0, 0, 0, 0, 45, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 45, 0, 0, 0, 0, 0, 0, 0, 45, 45, 0, 0, 0, 0, 0, 0, 56, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 56, 0, 0, 0, 0, 0, 0, 0, 45, 45, 0, 0, 0, 0, 0, 0, 63, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 63, 0, 0, 0, 0, 0, 0, 0, 45, 45, 0, 0, 0, 0, 0, 0, 73, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 73, 0, 0, 0, 0, 0, 0, 0, 45, 45, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 45, 45, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 45, 45, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 45, 45, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 45, 45, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 46, 0, 0, 0, 0, 0, 0, 0, 45, 45, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 45, 0, 0, 0, 0, 0, 0, 0, 45, 45, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 45, 0, 0, 0, 0, 0, 0, 0, 45, 59, 58, 58, 58, 58, 58, 58, 58, 58, 58, 58, 58, 58, 58, 58, 58, 58, 58, 58, 58, 58, 58, 32, 58, 58, 58, 58, 58, 58, 58, 60, 83, 63, 63, 63, 63, 63, 63, 63, 63, 63, 63, 63, 63, 63, 63, 63, 63, 63, 63, 63, 63, 63, 63, 63, 63, 63, 63, 63, 63, 63, 84, 93, 63, 63, 63, 63, 63, 63, 63, 63, 63, 63, 63, 63, 63, 63, 63, 63, 63, 63, 63, 63, 63, 63, 63, 63, 63, 63, 63, 63, 63, 94], + "height":17, + "id":9, + "name":"walls", + "opacity":1, + "type":"tilelayer", + "visible":true, + "width":31, + "x":0, + "y":0 + }, + { + "data":[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 293, 0, 0, 0, 0, 293, 0, 107, 0, 0, 0, 0, 0, 128, 0, 0, 0, 0, 0, 0, 107, 0, 0, 128, 1, 2, 3, 0, 0, 0, 0, 304, 296, 297, 296, 297, 304, 0, 117, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 117, 0, 0, 0, 11, 12, 13, 0, 0, 0, 0, 315, 307, 308, 307, 308, 315, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 21, 22, 23, 0, 0, 0, 0, 243, 0, 0, 0, 0, 2147483943, 0, 0, 0, 325, 340, 340, 326, 0, 0, 325, 340, 340, 326, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 244, 0, 283, 283, 0, 2147483954, 0, 0, 0, 0, 340, 340, 0, 0, 0, 0, 340, 340, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 294, 294, 0, 0, 0, 0, 0, 325, 340, 340, 326, 0, 0, 325, 340, 340, 326, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 351, 351, 0, 0, 0, 0, 351, 351, 0, 0, 0, 0, 0, 0, 325, 273, 275, 326, 0, 0, 0, 394, 395, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 325, 2147483923, 275, 326, 0, 0, 0, 405, 406, 0, 0, 0, 0, 0, 0, 0, 0, 0, 333, 334, 333, 334, 333, 334, 0, 0, 0, 0, 0, 0, 0, 325, 2147483923, 275, 326, 0, 0, 0, 416, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 344, 345, 344, 345, 344, 345, 0, 0, 0, 0, 0, 0, 0, 325, 2147483923, 275, 326, 0, 0, 0, 427, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 217, 220, 220, 220, 220, 218, 0, 0, 0, 0, 0, 0, 0, 0, 284, 286, 0, 0, 0, 0, 438, 439, 0, 0, 0, 0, 0, 0, 0, 0, 0, 335, 336, 335, 336, 335, 336, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 282, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 346, 347, 346, 347, 346, 347, 0, 2147483811, 2147483810, 2147483809, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + "height":17, + "id":1, + "name":"furniture", + "opacity":1, + "type":"tilelayer", + "visible":true, + "width":31, + "x":0, + "y":0 + }, + { + "data":[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 232, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2147483909, 261, 0, 0, 0, 0, 2147483909, 261, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2147483909, 261, 0, 0, 0, 0, 2147483909, 261, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 166, 180, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 180, 0, 0, 0, 0, 0, 176, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 180, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 228, 231, 231, 231, 231, 229, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 282, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + "height":17, + "id":33, + "name":"aboveFurniture", + "opacity":1, + "type":"tilelayer", + "visible":true, + "width":31, + "x":0, + "y":0 + }, + { + "draworder":"topdown", + "id":2, + "name":"floorLayer", + "objects":[ + { + "height":64, + "id":4, + "name":"clockPopup", + "rotation":0, + "type":"", + "visible":true, + "width":128, + "x":512, + "y":0 + }, + { + "height":146.081567555252, + "id":9, + "name":"chillZone", + "properties":[ + { + "name":"focusable", + "type":"bool", + "value":true + }, + { + "name":"zoom_margin", + "type":"float", + "value":3 + }], + "rotation":0, + "type":"zone", + "visible":true, + "width":192, + "x":32, + "y":77.9184324447482 + }, + { + "height":416, + "id":11, + "name":"meetingZone", + "properties":[ + { + "name":"display_name", + "type":"string", + "value":"Brainstorm Zone!" + }, + { + "name":"focusable", + "type":"bool", + "value":true + }, + { + "name":"zoom_margin", + "type":"float", + "value":0.35 + }], + "rotation":0, + "type":"zone", + "visible":true, + "width":224, + "x":736, + "y":32 + }, + { + "height":66.6667, + "id":13, + "name":"", + "rotation":0, + "text": + { + "fontfamily":"Sans Serif", + "halign":"center", + "pixelsize":11, + "text":"Camera should show the whole zone. Zoom in before entering", + "valign":"center", + "wrap":true + }, + "type":"", + "visible":true, + "width":155.104, + "x":770.473518341308, + "y":126.688522863978 + }], + "opacity":1, + "type":"objectgroup", + "visible":true, + "x":0, + "y":0 + }, + { + "data":[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 329, 329, 0, 0, 0, 0, 329, 329, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 233, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 262, 263, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 206, 209, 209, 209, 209, 207, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 428, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2147483801, 2147483800, 2147483799, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + "height":17, + "id":3, + "name":"abovePlayer1", + "opacity":1, + "type":"tilelayer", + "visible":true, + "width":31, + "x":0, + "y":0 + }, + { + "data":[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 399, 400, 399, 400, 399, 400, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 410, 411, 410, 411, 410, 411, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + "height":17, + "id":27, + "name":"abovePlayer2", + "opacity":1, + "type":"tilelayer", + "visible":true, + "width":31, + "x":0, + "y":0 + }, + { + "data":[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 401, 402, 401, 402, 401, 402, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 412, 413, 412, 413, 412, 413, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + "height":17, + "id":28, + "name":"abovePlayer3", + "opacity":1, + "type":"tilelayer", + "visible":true, + "width":31, + "x":0, + "y":0 + }], + "nextlayerid":39, + "nextobjectid":18, + "orientation":"orthogonal", + "properties":[ + { + "name":"mapCopyright", + "type":"string", + "value":"Credits: Valdo Romao https:\/\/www.linkedin.com\/in\/valdo-romao\/ \nLicense: CC-BY-SA 3.0 (http:\/\/creativecommons.org\/licenses\/by-sa\/3.0\/)" + }, + { + "name":"mapDescription", + "type":"string", + "value":"A perfect virtual office to get started with WorkAdventure!" + }, + { + "name":"mapImage", + "type":"string", + "value":"map.png" + }, + { + "name":"mapLink", + "type":"string", + "value":"https:\/\/thecodingmachine.github.io\/workadventure-map-starter-kit\/map.json" + }, + { + "name":"mapName", + "type":"string", + "value":"Starter kit" + }, + { + "name":"script", + "type":"string", + "value":"..\/dist\/script.js" + }], + "renderorder":"right-down", + "tiledversion":"1.7.2", + "tileheight":32, + "tilesets":[ + { + "columns":10, + "firstgid":1, + "image":"..\/assets\/tileset5_export.png", + "imageheight":320, + "imagewidth":320, + "margin":0, + "name":"tileset5_export", + "properties":[ + { + "name":"tilesetCopyright", + "type":"string", + "value":"\u00a9 2021 WorkAdventure \nLicence: WORKADVENTURE SPECIFIC RESOURCES LICENSE (see LICENSE.assets file)" + }], + "spacing":0, + "tilecount":100, + "tileheight":32, + "tilewidth":32 + }, + { + "columns":10, + "firstgid":101, + "image":"..\/assets\/tileset6_export.png", + "imageheight":320, + "imagewidth":320, + "margin":0, + "name":"tileset6_export", + "properties":[ + { + "name":"tilesetCopyright", + "type":"string", + "value":"\u00a9 2021 WorkAdventure \nLicence: WORKADVENTURE SPECIFIC RESOURCES LICENSE (see LICENSE.assets file)" + }], + "spacing":0, + "tilecount":100, + "tileheight":32, + "tilewidth":32 + }, + { + "columns":11, + "firstgid":201, + "image":"..\/assets\/tileset1.png", + "imageheight":352, + "imagewidth":352, + "margin":0, + "name":"tileset1", + "properties":[ + { + "name":"tilesetCopyright", + "type":"string", + "value":"\u00a9 2021 WorkAdventure \nLicence: WORKADVENTURE SPECIFIC RESOURCES LICENSE (see LICENSE.assets file)" + }], + "spacing":0, + "tilecount":121, + "tileheight":32, + "tilewidth":32 + }, + { + "columns":11, + "firstgid":322, + "image":"..\/assets\/tileset1-repositioning.png", + "imageheight":352, + "imagewidth":352, + "margin":0, + "name":"tileset1-repositioning", + "properties":[ + { + "name":"tilesetCopyright", + "type":"string", + "value":"\u00a9 2021 WorkAdventure \nLicence: WORKADVENTURE SPECIFIC RESOURCES LICENSE (see LICENSE.assets file)" + }], + "spacing":0, + "tilecount":121, + "tileheight":32, + "tilewidth":32 + }, + { + "columns":6, + "firstgid":443, + "image":"..\/assets\/Special_Zones.png", + "imageheight":64, + "imagewidth":192, + "margin":0, + "name":"Special_Zones", + "properties":[ + { + "name":"tilesetCopyright", + "type":"string", + "value":"\u00a9 2021 WorkAdventure \nLicence: WORKADVENTURE SPECIFIC RESOURCES LICENSE (see LICENSE.assets file)" + }], + "spacing":0, + "tilecount":12, + "tileheight":32, + "tiles":[ + { + "id":0, + "properties":[ + { + "name":"collides", + "type":"bool", + "value":true + }] + }], + "tilewidth":32 + }], + "tilewidth":32, + "type":"map", + "version":"1.6", + "width":31 +} \ No newline at end of file diff --git a/maps/tests/index.html b/maps/tests/index.html index 068136ed..c920c876 100644 --- a/maps/tests/index.html +++ b/maps/tests/index.html @@ -104,6 +104,14 @@ Testing Emoji + + + Success Failure Pending + + + Focusable Zones + +

Iframe API

diff --git a/messages/protos/messages.proto b/messages/protos/messages.proto index 5b319feb..117ab582 100644 --- a/messages/protos/messages.proto +++ b/messages/protos/messages.proto @@ -198,6 +198,7 @@ message RoomJoinedMessage { int32 currentUserId = 4; repeated string tag = 5; repeated VariableMessage variable = 6; + string userRoomToken = 7; } message WebRtcStartMessage { @@ -259,6 +260,9 @@ message BanUserMessage{ string message = 2; } +/** + * Messages going from back and pusher to the front + */ message ServerToClientMessage { oneof message { BatchMessage batchMessage = 1; @@ -297,6 +301,7 @@ message JoinRoomMessage { string IPAddress = 7; CompanionMessage companion = 8; string visitCardUrl = 9; + string userRoomToken = 10; } message UserJoinedZoneMessage { diff --git a/pusher/package.json b/pusher/package.json index dacbfd72..bbef80fa 100644 --- a/pusher/package.json +++ b/pusher/package.json @@ -52,7 +52,7 @@ "openid-client": "^4.7.4", "prom-client": "^12.0.0", "query-string": "^6.13.3", - "uWebSockets.js": "uNetworking/uWebSockets.js#v18.5.0", + "uWebSockets.js": "uNetworking/uWebSockets.js#v20.4.0", "uuidv4": "^6.0.7" }, "devDependencies": { @@ -71,8 +71,8 @@ "jasmine": "^3.5.0", "lint-staged": "^11.0.0", "prettier": "^2.3.1", - "ts-node-dev": "^1.0.0-pre.44", - "typescript": "^3.8.3" + "ts-node-dev": "^1.1.8", + "typescript": "^4.5.2" }, "lint-staged": { "*.ts": [ diff --git a/pusher/src/Controller/AdminController.ts b/pusher/src/Controller/AdminController.ts index ec1bd067..bf514fa0 100644 --- a/pusher/src/Controller/AdminController.ts +++ b/pusher/src/Controller/AdminController.ts @@ -44,7 +44,7 @@ export class AdminController extends BaseController { const roomId: string = body.roomId; await apiClientRepository.getClient(roomId).then((roomClient) => { - return new Promise((res, rej) => { + return new Promise((res, rej) => { const roomMessage = new RefreshRoomPromptMessage(); roomMessage.setRoomid(roomId); @@ -101,7 +101,7 @@ export class AdminController extends BaseController { await Promise.all( targets.map((roomId) => { return apiClientRepository.getClient(roomId).then((roomClient) => { - return new Promise((res, rej) => { + return new Promise((res, rej) => { if (type === "message") { const roomMessage = new AdminRoomMessage(); roomMessage.setMessage(text); diff --git a/pusher/src/Controller/AuthenticateController.ts b/pusher/src/Controller/AuthenticateController.ts index 47d35fab..8f09cc1a 100644 --- a/pusher/src/Controller/AuthenticateController.ts +++ b/pusher/src/Controller/AuthenticateController.ts @@ -287,6 +287,7 @@ export class AuthenticateController extends BaseController { messages: [], visitCardUrl: null, textures: [], + userRoomToken: undefined, }; try { data = await adminApi.fetchMemberDataByUuid(email, playUri, IPAddress); diff --git a/pusher/src/Controller/InvalidTokenError.ts b/pusher/src/Controller/InvalidTokenError.ts new file mode 100644 index 00000000..2d0c066d --- /dev/null +++ b/pusher/src/Controller/InvalidTokenError.ts @@ -0,0 +1,9 @@ +/** + * Errors related to variable handling. + */ +export class InvalidTokenError extends Error { + constructor(message: string) { + super(message); + Object.setPrototypeOf(this, InvalidTokenError.prototype); + } +} diff --git a/pusher/src/Controller/IoSocketController.ts b/pusher/src/Controller/IoSocketController.ts index b84d320a..df29db57 100644 --- a/pusher/src/Controller/IoSocketController.ts +++ b/pusher/src/Controller/IoSocketController.ts @@ -22,7 +22,7 @@ import { import { UserMovesMessage } from "../Messages/generated/messages_pb"; import { TemplatedApp } from "uWebSockets.js"; import { parse } from "query-string"; -import { jwtTokenManager, tokenInvalidException } from "../Services/JWTTokenManager"; +import { AdminSocketTokenData, jwtTokenManager, tokenInvalidException } from "../Services/JWTTokenManager"; import { adminApi, FetchMemberDataByUuidResponse } from "../Services/AdminApi"; import { SocketManager, socketManager } from "../Services/SocketManager"; import { emitInBatch } from "../Services/IoSocketHelpers"; @@ -30,6 +30,9 @@ import { ADMIN_API_URL, DISABLE_ANONYMOUS, SOCKET_IDLE_TIMER } from "../Enum/Env import { Zone } from "_Model/Zone"; import { ExAdminSocketInterface } from "_Model/Websocket/ExAdminSocketInterface"; import { CharacterTexture } from "../Services/AdminApi/CharacterTexture"; +import { isAdminMessageInterface } from "../Model/Websocket/Admin/AdminMessages"; +import Axios from "axios"; +import { InvalidTokenError } from "../Controller/InvalidTokenError"; export class IoSocketController { private nextUserId: number = 1; @@ -42,59 +45,108 @@ export class IoSocketController { adminRoomSocket() { this.app.ws("/admin/rooms", { upgrade: (res, req, context) => { - const query = parse(req.getQuery()); const websocketKey = req.getHeader("sec-websocket-key"); const websocketProtocol = req.getHeader("sec-websocket-protocol"); const websocketExtensions = req.getHeader("sec-websocket-extensions"); - const token = query.token; - let authorizedRoomIds: string[]; - try { - const data = jwtTokenManager.verifyAdminSocketToken(token as string); - authorizedRoomIds = data.authorizedRoomIds; - } catch (e) { - console.error("Admin access refused for token: " + token); - res.writeStatus("401 Unauthorized").end("Incorrect token"); - return; - } - const roomId = query.roomId; - if (typeof roomId !== "string" || !authorizedRoomIds.includes(roomId)) { - console.error("Invalid room id"); - res.writeStatus("403 Bad Request").end("Invalid room id"); - return; - } - res.upgrade({ roomId }, websocketKey, websocketProtocol, websocketExtensions, context); + res.upgrade({}, websocketKey, websocketProtocol, websocketExtensions, context); }, open: (ws) => { - console.log("Admin socket connect for room: " + ws.roomId); + console.log("Admin socket connect to client on " + Buffer.from(ws.getRemoteAddressAsText()).toString()); ws.disconnecting = false; - - socketManager.handleAdminRoom(ws as ExAdminSocketInterface, ws.roomId as string); }, message: (ws, arrayBuffer, isBinary): void => { try { - //TODO refactor message type and data - const message: { event: string; message: { type: string; message: unknown; userUuid: string } } = - JSON.parse(new TextDecoder("utf-8").decode(new Uint8Array(arrayBuffer))); + const message = JSON.parse(new TextDecoder("utf-8").decode(new Uint8Array(arrayBuffer))); - if (message.event === "user-message") { - const messageToEmit = message.message as { message: string; type: string; userUuid: string }; - if (messageToEmit.type === "banned") { - socketManager.emitBan( - messageToEmit.userUuid, - messageToEmit.message, - messageToEmit.type, - ws.roomId as string + if (!isAdminMessageInterface(message)) { + console.error("Invalid message received.", message); + ws.send( + JSON.stringify({ + type: "Error", + data: { + message: "Invalid message received! The connection has been closed.", + }, + }) + ); + ws.close(); + return; + } + + const token = message.jwt; + + let data: AdminSocketTokenData; + + try { + data = jwtTokenManager.verifyAdminSocketToken(token); + } catch (e) { + console.error("Admin socket access refused for token: " + token, e); + ws.send( + JSON.stringify({ + type: "Error", + data: { + message: "Admin socket access refused! The connection has been closed.", + }, + }) + ); + ws.close(); + return; + } + + const authorizedRoomIds = data.authorizedRoomIds; + + if (message.event === "listen") { + const notAuthorizedRoom = message.roomIds.filter( + (roomId) => !authorizedRoomIds.includes(roomId) + ); + + if (notAuthorizedRoom.length > 0) { + const errorMessage = `Admin socket refused for client on ${Buffer.from( + ws.getRemoteAddressAsText() + ).toString()} listening of : \n${JSON.stringify(notAuthorizedRoom)}`; + console.error(); + ws.send( + JSON.stringify({ + type: "Error", + data: { + message: errorMessage, + }, + }) ); + ws.close(); + return; } - if (messageToEmit.type === "ban") { - socketManager.emitSendUserMessage( - messageToEmit.userUuid, - messageToEmit.message, - messageToEmit.type, - ws.roomId as string - ); + + for (const roomId of message.roomIds) { + socketManager + .handleAdminRoom(ws as ExAdminSocketInterface, roomId) + .catch((e) => console.error(e)); } + } else if (message.event === "user-message") { + const messageToEmit = message.message; + // Get roomIds of the world where we want broadcast the message + const roomIds = authorizedRoomIds.filter( + (authorizeRoomId) => authorizeRoomId.split("/")[5] === message.world + ); + + for (const roomId of roomIds) { + if (messageToEmit.type === "banned") { + socketManager + .emitBan(messageToEmit.userUuid, messageToEmit.message, messageToEmit.type, roomId) + .catch((error) => console.error(error)); + } else if (messageToEmit.type === "ban") { + socketManager + .emitSendUserMessage( + messageToEmit.userUuid, + messageToEmit.message, + messageToEmit.type, + roomId + ) + .catch((error) => console.error(error)); + } + } + } else { + const tmp: never = message.event; } } catch (err) { console.error(err); @@ -186,6 +238,7 @@ export class IoSocketController { let memberTags: string[] = []; let memberVisitCardUrl: string | null = null; let memberMessages: unknown; + let memberUserRoomToken: string | undefined; let memberTextures: CharacterTexture[] = []; const room = await socketManager.getOrCreateRoom(roomId); let userData: FetchMemberDataByUuidResponse = { @@ -196,34 +249,37 @@ export class IoSocketController { textures: [], messages: [], anonymous: true, + userRoomToken: undefined, }; if (ADMIN_API_URL) { try { try { userData = await adminApi.fetchMemberDataByUuid(userIdentifier, roomId, IPAddress); } catch (err) { - if (err?.response?.status == 404) { - // If we get an HTTP 404, the token is invalid. Let's perform an anonymous login! + if (Axios.isAxiosError(err)) { + if (err?.response?.status == 404) { + // If we get an HTTP 404, the token is invalid. Let's perform an anonymous login! - console.warn( - 'Cannot find user with email "' + - (userIdentifier || "anonymous") + - '". Performing an anonymous login instead.' - ); - } else if (err?.response?.status == 403) { - // If we get an HTTP 403, the world is full. We need to broadcast a special error to the client. - // we finish immediately the upgrade then we will close the socket as soon as it starts opening. - return res.upgrade( - { - rejected: true, - message: err?.response?.data.message, - status: err?.response?.status, - }, - websocketKey, - websocketProtocol, - websocketExtensions, - context - ); + console.warn( + 'Cannot find user with email "' + + (userIdentifier || "anonymous") + + '". Performing an anonymous login instead.' + ); + } else if (err?.response?.status == 403) { + // If we get an HTTP 403, the world is full. We need to broadcast a special error to the client. + // we finish immediately the upgrade then we will close the socket as soon as it starts opening. + return res.upgrade( + { + rejected: true, + message: err?.response?.data.message, + status: err?.response?.status, + }, + websocketKey, + websocketProtocol, + websocketExtensions, + context + ); + } } else { throw err; } @@ -232,6 +288,8 @@ export class IoSocketController { memberTags = userData.tags; memberVisitCardUrl = userData.visitCardUrl; memberTextures = userData.textures; + memberUserRoomToken = userData.userRoomToken; + if ( room.policyType === GameRoomPolicyTypes.USE_TAGS_POLICY && (userData.anonymous === true || !room.canAccess(memberTags)) @@ -281,6 +339,7 @@ export class IoSocketController { messages: memberMessages, tags: memberTags, visitCardUrl: memberVisitCardUrl, + userRoomToken: memberUserRoomToken, textures: memberTextures, position: { x: x, @@ -302,17 +361,31 @@ export class IoSocketController { context ); } catch (e) { - res.upgrade( - { - rejected: true, - reason: e.reason || null, - message: e.message ? e.message : "500 Internal Server Error", - }, - websocketKey, - websocketProtocol, - websocketExtensions, - context - ); + if (e instanceof Error) { + res.upgrade( + { + rejected: true, + reason: e instanceof InvalidTokenError ? tokenInvalidException : null, + message: e.message, + }, + websocketKey, + websocketProtocol, + websocketExtensions, + context + ); + } else { + res.upgrade( + { + rejected: true, + reason: null, + message: "500 Internal Server Error", + }, + websocketKey, + websocketProtocol, + websocketExtensions, + context + ); + } } })(); }, diff --git a/pusher/src/Controller/MapController.ts b/pusher/src/Controller/MapController.ts index 7f76ff9e..23eef566 100644 --- a/pusher/src/Controller/MapController.ts +++ b/pusher/src/Controller/MapController.ts @@ -8,6 +8,7 @@ import { isMapDetailsData, MapDetailsData } from "../Services/AdminApi/MapDetail import { socketManager } from "../Services/SocketManager"; import { AuthTokenData, jwtTokenManager } from "../Services/JWTTokenManager"; import { v4 } from "uuid"; +import { InvalidTokenError } from "./InvalidTokenError"; export class MapController extends BaseController { constructor(private App: TemplatedApp) { @@ -85,11 +86,15 @@ export class MapController extends BaseController { userId = authTokenData.identifier; console.info("JWT expire, but decoded", userId); } catch (e) { - // The token was not good, redirect user on login page - res.writeStatus("500"); - res.writeHeader("Access-Control-Allow-Origin", FRONT_URL); - res.end("Token decrypted error"); - return; + if (e instanceof InvalidTokenError) { + // The token was not good, redirect user on login page + res.writeStatus("401 Unauthorized"); + res.writeHeader("Access-Control-Allow-Origin", FRONT_URL); + res.end("Token decrypted error"); + return; + } else { + return this.errorToResponse(e, res); + } } } } diff --git a/pusher/src/Model/Websocket/Admin/AdminMessages.ts b/pusher/src/Model/Websocket/Admin/AdminMessages.ts new file mode 100644 index 00000000..1d64899a --- /dev/null +++ b/pusher/src/Model/Websocket/Admin/AdminMessages.ts @@ -0,0 +1,30 @@ +import * as tg from "generic-type-guard"; + +export const isBanBannedAdminMessageInterface = new tg.IsInterface() + .withProperties({ + type: tg.isSingletonStringUnion("ban", "banned"), + message: tg.isString, + userUuid: tg.isString, + }) + .get(); + +export const isUserMessageAdminMessageInterface = new tg.IsInterface() + .withProperties({ + event: tg.isSingletonString("user-message"), + message: isBanBannedAdminMessageInterface, + world: tg.isString, + jwt: tg.isString, + }) + .get(); + +export const isListenRoomsMessageInterface = new tg.IsInterface() + .withProperties({ + event: tg.isSingletonString("listen"), + roomIds: tg.isArray(tg.isString), + jwt: tg.isString, + }) + .get(); + +export const isAdminMessageInterface = tg.isUnion(isUserMessageAdminMessageInterface, isListenRoomsMessageInterface); + +export type AdminMessageInterface = tg.GuardedType; diff --git a/pusher/src/Model/Websocket/ExSocketInterface.ts b/pusher/src/Model/Websocket/ExSocketInterface.ts index ff5ed211..411d88fa 100644 --- a/pusher/src/Model/Websocket/ExSocketInterface.ts +++ b/pusher/src/Model/Websocket/ExSocketInterface.ts @@ -44,4 +44,5 @@ export interface ExSocketInterface extends WebSocket, Identificable { textures: CharacterTexture[]; backConnection: BackConnection; listenedZones: Set; + userRoomToken: string | undefined; } diff --git a/pusher/src/Services/AdminApi.ts b/pusher/src/Services/AdminApi.ts index 416b9cb6..bc3b2172 100644 --- a/pusher/src/Services/AdminApi.ts +++ b/pusher/src/Services/AdminApi.ts @@ -29,6 +29,7 @@ export interface FetchMemberDataByUuidResponse { textures: CharacterTexture[]; messages: unknown[]; anonymous?: boolean; + userRoomToken: string | undefined; } class AdminApi { diff --git a/pusher/src/Services/JWTTokenManager.ts b/pusher/src/Services/JWTTokenManager.ts index 3d580c1e..4c094072 100644 --- a/pusher/src/Services/JWTTokenManager.ts +++ b/pusher/src/Services/JWTTokenManager.ts @@ -1,5 +1,6 @@ import { ADMIN_SOCKETS_TOKEN, SECRET_KEY } from "../Enum/EnvironmentVariable"; import Jwt from "jsonwebtoken"; +import { InvalidTokenError } from "../Controller/InvalidTokenError"; export interface AuthTokenData { identifier: string; //will be a email if logged in or an uuid if anonymous @@ -23,7 +24,12 @@ class JWTTokenManager { try { return Jwt.verify(token, SECRET_KEY, { ignoreExpiration }) as AuthTokenData; } catch (e) { - throw { reason: tokenInvalidException, message: e.message }; + if (e instanceof Error) { + // FIXME: we are loosing the stacktrace here. + throw new InvalidTokenError(e.message); + } else { + throw e; + } } } } diff --git a/pusher/src/Services/SocketManager.ts b/pusher/src/Services/SocketManager.ts index 1761f1bd..4f4b086f 100644 --- a/pusher/src/Services/SocketManager.ts +++ b/pusher/src/Services/SocketManager.ts @@ -132,6 +132,12 @@ export class SocketManager implements ZoneEventListener { const message = new AdminPusherToBackMessage(); message.setSubscribetoroom(roomId); + console.log( + `Admin socket handle room ${roomId} connections for a client on ${Buffer.from( + client.getRemoteAddressAsText() + ).toString()}` + ); + adminRoomStream.write(message); } @@ -151,6 +157,11 @@ export class SocketManager implements ZoneEventListener { joinRoomMessage.setName(client.name); joinRoomMessage.setPositionmessage(ProtobufUtils.toPositionMessage(client.position)); joinRoomMessage.setTagList(client.tags); + + if (client.userRoomToken) { + joinRoomMessage.setUserroomtoken(client.userRoomToken); + } + if (client.visitCardUrl) { joinRoomMessage.setVisitcardurl(client.visitCardUrl); } diff --git a/pusher/yarn.lock b/pusher/yarn.lock index 820575aa..ead3a9cc 100644 --- a/pusher/yarn.lock +++ b/pusher/yarn.lock @@ -2270,7 +2270,7 @@ tree-kill@^1.2.2: resolved "https://registry.yarnpkg.com/tree-kill/-/tree-kill-1.2.2.tgz#4ca09a9092c88b73a7cdc5e8a01b507b0790a0cc" integrity sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A== -ts-node-dev@^1.0.0-pre.44: +ts-node-dev@^1.1.8: version "1.1.8" resolved "https://registry.yarnpkg.com/ts-node-dev/-/ts-node-dev-1.1.8.tgz#95520d8ab9d45fffa854d6668e2f8f9286241066" integrity sha512-Q/m3vEwzYwLZKmV6/0VlFxcZzVV/xcgOt+Tx/VjaaRHyiBcFlV0541yrT09QjzzCxlDZ34OzKjrFAynlmtflEg== @@ -2337,14 +2337,14 @@ type-fest@^0.8.1: resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.8.1.tgz#09e249ebde851d3b1e48d27c105444667f17b83d" integrity sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA== -typescript@^3.8.3: - version "3.9.10" - resolved "https://registry.yarnpkg.com/typescript/-/typescript-3.9.10.tgz#70f3910ac7a51ed6bef79da7800690b19bf778b8" - integrity sha512-w6fIxVE/H1PkLKcCPsFqKE7Kv7QUwhU8qQY2MueZXWx5cPZdwFupLgKK3vntcK98BtNHZtAF4LA/yl2a7k8R6Q== +typescript@^4.5.2: + version "4.5.2" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.5.2.tgz#8ac1fba9f52256fdb06fb89e4122fa6a346c2998" + integrity sha512-5BlMof9H1yGt0P8/WF+wPNw6GfctgGjXp5hkblpyT+8rkASSmkUKMXrxR0Xg8ThVCi/JnHQiKXeBaEwCeQwMFw== -uWebSockets.js@uNetworking/uWebSockets.js#v18.5.0: - version "18.5.0" - resolved "https://codeload.github.com/uNetworking/uWebSockets.js/tar.gz/9b1605d2db82981cafe69dbe356e10ce412f5805" +uWebSockets.js@uNetworking/uWebSockets.js#v20.4.0: + version "20.4.0" + resolved "https://codeload.github.com/uNetworking/uWebSockets.js/tar.gz/65f39bdff763be3883e6cf18e433dd4fec155845" uri-js@^4.2.2: version "4.4.1"