Merge pull request #1525 from thecodingmachine/player-local-storage

Allows to read and write "Player properties" from LocalStorage & Adds a camera API
This commit is contained in:
David Négrier 2022-01-03 15:24:14 +01:00 committed by GitHub
commit a28d86c16d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
22 changed files with 564 additions and 189 deletions

24
docs/maps/api-camera.md Normal file
View file

@ -0,0 +1,24 @@
{.section-title.accent.text-primary}
# API Camera functions Reference
### Listen to camera updates
```
WA.camera.onCameraUpdate(): Subscription
```
Listens to updates of the camera viewport. It will trigger for every update of the camera's properties (position or scale for instance). An event will be sent.
The event has the following attributes :
* **x (number):** coordinate X of the camera's world view (the area looked at by the camera).
* **y (number):** coordinate Y of the camera's world view.
* **width (number):** the width of the camera's world view.
* **height (number):** the height of the camera's world view.
**callback:** the function that will be called when the camera is updated.
Example :
```javascript
const subscription = WA.camera.onCameraUpdate().subscribe((worldView) => console.log(worldView));
//later...
subscription.unsubscribe();

View file

@ -86,6 +86,27 @@ WA.onInit().then(() => {
})
```
### Get the position of the player
```
WA.player.getPosition(): Promise<Position>
```
The player's current position is available using the `WA.player.getPosition()` function.
`Position` has the following attributes :
* **x (number) :** The coordinate x of the current player's position.
* **y (number) :** The coordinate y of the current player's position.
{.alert.alert-info}
You need to wait for the end of the initialization before calling `WA.player.getPosition()`
```typescript
WA.onInit().then(async () => {
console.log('Position: ', await WA.player.getPosition());
})
```
### Listen to player movement
```
WA.player.onPlayerMove(callback: HasPlayerMovedEventCallback): void;
@ -107,6 +128,30 @@ Example :
WA.player.onPlayerMove(console.log);
```
## Player specific variables
Similarly to maps (see [API state related functions](api-state.md)), it is possible to store data **related to a specific player** in a "state". Such data will be stored using the local storage from the user's browser. Any value that is serializable in JSON can be stored.
{.alert.alert-info}
In the future, player-related variables will be stored on the WorkAdventure server if the current player is logged.
Any value that is serializable in JSON can be stored.
### Setting a property
A player property can be set simply by assigning a value.
Example:
```javascript
WA.player.state.toto = "value" //will set the "toto" key to "value"
```
### Reading a variable
A player variable can be read by calling its key from the player's state.
Example:
```javascript
WA.player.state.toto //will retrieve the variable
```
### Set the outline color of the player
```
WA.player.setOutlineColor(red: number, green: number, blue: number): Promise<void>;

View file

@ -10,5 +10,6 @@
- [UI functions](api-ui.md)
- [Sound functions](api-sound.md)
- [Controls functions](api-controls.md)
- [Camera functions](api-camera.md)
- [List of deprecated functions](api-deprecated.md)

View file

@ -1,8 +1,11 @@
{.section-title.accent.text-primary}
# API Room functions Reference
### Working with group layers
If you use group layers in your map, to reference a layer in a group you will need to use a `/` to join layer names together.
If you use group layers in your map, to reference a layer in a group you will need to use a `/` to join layer names
together.
Example :
<div class="row">
@ -12,6 +15,7 @@ Example :
</div>
The name of the layers of this map are :
* `entries/start`
* `bottom/ground/under`
* `bottom/build/carpet`
@ -26,29 +30,32 @@ WA.room.onLeaveLayer(name: string): Subscription
Listens to the position of the current user. The event is triggered when the user enters or leaves a given layer.
* **name**: the name of the layer who as defined in Tiled.
* **name**: the name of the layer who as defined in Tiled.
Example:
```javascript
WA.room.onEnterLayer('myLayer').subscribe(() => {
WA.chat.sendChatMessage("Hello!", 'Mr Robot');
WA.chat.sendChatMessage("Hello!", 'Mr Robot');
});
WA.room.onLeaveLayer('myLayer').subscribe(() => {
WA.chat.sendChatMessage("Goodbye!", 'Mr Robot');
WA.chat.sendChatMessage("Goodbye!", 'Mr Robot');
});
```
### Show / Hide a layer
```
WA.room.showLayer(layerName : string): void
WA.room.hideLayer(layerName : string) : void
```
These 2 methods can be used to show and hide a layer.
if `layerName` is the name of a group layer, show/hide all the layer in that group layer.
These 2 methods can be used to show and hide a layer. if `layerName` is the name of a group layer, show/hide all the
layer in that group layer.
Example :
```javascript
WA.room.showLayer('bottom');
//...
@ -61,12 +68,14 @@ WA.room.hideLayer('bottom');
WA.room.setProperty(layerName : string, propertyName : string, propertyValue : string | number | boolean | undefined) : void;
```
Set the value of the `propertyName` property of the layer `layerName` at `propertyValue`. If the property doesn't exist, create the property `propertyName` and set the value of the property at `propertyValue`.
Set the value of the `propertyName` property of the layer `layerName` at `propertyValue`. If the property doesn't exist,
create the property `propertyName` and set the value of the property at `propertyValue`.
Note :
To unset a property from a layer, use `setProperty` with `propertyValue` set to `undefined`.
Example :
```javascript
WA.room.setProperty('wikiLayer', 'openWebsite', 'https://www.wikipedia.org/');
```
@ -79,13 +88,12 @@ WA.room.id: string;
The ID of the current room is available from the `WA.room.id` property.
{.alert.alert-info}
You need to wait for the end of the initialization before accessing `WA.room.id`
{.alert.alert-info} You need to wait for the end of the initialization before accessing `WA.room.id`
```typescript
WA.onInit().then(() => {
console.log('Room id: ', WA.room.id);
// Will output something like: 'https://play.workadventu.re/@/myorg/myworld/myroom', or 'https://play.workadventu.re/_/global/mymap.org/map.json"
console.log('Room id: ', WA.room.id);
// Will output something like: 'https://play.workadventu.re/@/myorg/myworld/myroom', or 'https://play.workadventu.re/_/global/mymap.org/map.json"
})
```
@ -97,19 +105,17 @@ WA.room.mapURL: string;
The URL of the map is available from the `WA.room.mapURL` property.
{.alert.alert-info}
You need to wait for the end of the initialization before accessing `WA.room.mapURL`
{.alert.alert-info} You need to wait for the end of the initialization before accessing `WA.room.mapURL`
```typescript
WA.onInit().then(() => {
console.log('Map URL: ', WA.room.mapURL);
// Will output something like: 'https://mymap.org/map.json"
console.log('Map URL: ', WA.room.mapURL);
// Will output something like: 'https://mymap.org/map.json"
})
```
### Getting map data
```
WA.room.getTiledMap(): Promise<ITiledMap>
```
@ -121,12 +127,16 @@ const map = await WA.room.getTiledMap();
console.log("Map generated with Tiled version ", map.tiledversion);
```
Check the [Tiled documentation to learn more about the format of the JSON map](https://doc.mapeditor.org/en/stable/reference/json-map-format/).
Check
the [Tiled documentation to learn more about the format of the JSON map](https://doc.mapeditor.org/en/stable/reference/json-map-format/)
.
### Changing tiles
```
WA.room.setTiles(tiles: TileDescriptor[]): void
```
Replace the tile at the `x` and `y` coordinates in the layer named `layer` by the tile with the id `tile`.
If `tile` is a string, it's not the id of the tile but the value of the property `name`.
@ -137,43 +147,48 @@ If `tile` is a string, it's not the id of the tile but the value of the property
</div>
`TileDescriptor` has the following attributes :
* **x (number) :** The coordinate x of the tile that you want to replace.
* **y (number) :** The coordinate y of the tile that you want to replace.
* **tile (number | string) :** The id of the tile that will be placed in the map.
* **layer (string) :** The name of the layer where the tile will be placed.
**Important !** : If you use `tile` as a number, be sure to add the `firstgid` of the tileset of the tile that you want to the id of the tile in Tiled Editor.
**Important !** : If you use `tile` as a number, be sure to add the `firstgid` of the tileset of the tile that you want
to the id of the tile in Tiled Editor.
Note: If you want to unset a tile, use `setTiles` with `tile` set to `null`.
Example :
```javascript
WA.room.setTiles([
{x: 6, y: 4, tile: 'blue', layer: 'setTiles'},
{x: 7, y: 4, tile: 109, layer: 'setTiles'},
{x: 8, y: 4, tile: 109, layer: 'setTiles'},
{x: 9, y: 4, tile: 'blue', layer: 'setTiles'}
]);
{ x: 6, y: 4, tile: 'blue', layer: 'setTiles' },
{ x: 7, y: 4, tile: 109, layer: 'setTiles' },
{ x: 8, y: 4, tile: 109, layer: 'setTiles' },
{ x: 9, y: 4, tile: 'blue', layer: 'setTiles' }
]);
```
### Loading a tileset
```
WA.room.loadTileset(url: string): Promise<number>
```
Load a tileset in JSON format from an url and return the id of the first tile of the loaded tileset.
You can create a tileset file in Tile Editor.
```javascript
WA.room.loadTileset("Assets/Tileset.json").then((firstId) => {
WA.room.setTiles([{x: 4, y: 4, tile: firstId, layer: 'bottom'}]);
WA.room.setTiles([{ x: 4, y: 4, tile: firstId, layer: 'bottom' }]);
})
```
## Embedding websites in a map
You can use the scripting API to embed websites in a map, or to edit websites that are already embedded (using the ["website" objects](website-in-map.md)).
You can use the scripting API to embed websites in a map, or to edit websites that are already embedded (using
the ["website" objects](website-in-map.md)).
### Getting an instance of a website already embedded in the map
@ -181,8 +196,8 @@ You can use the scripting API to embed websites in a map, or to edit websites th
WA.room.website.get(objectName: string): Promise<EmbeddedWebsite>
```
You can get an instance of an embedded website by using the `WA.room.website.get()` method.
It returns a promise of an `EmbeddedWebsite` instance.
You can get an instance of an embedded website by using the `WA.room.website.get()` method. It returns a promise of
an `EmbeddedWebsite` instance.
```javascript
// Get an existing website object where 'my_website' is the name of the object (on any layer object of the map)
@ -191,7 +206,6 @@ website.url = 'https://example.com';
website.visible = true;
```
### Adding a new website in a map
```
@ -201,34 +215,38 @@ interface CreateEmbeddedWebsiteEvent {
name: string; // A unique name for this iframe
url: string; // The URL the iframe points to.
position: {
x: number, // In pixels, relative to the map coordinates
y: number, // In pixels, relative to the map coordinates
width: number, // In pixels, sensitive to zoom level
height: number, // In pixels, sensitive to zoom level
x: number, // In "game" pixels, relative to the map or player coordinates, depending on origin
y: number, // In "game" pixels, relative to the map or player coordinates, depending on origin
width: number, // In "game" pixels
height: number, // In "game" pixels
},
visible?: boolean, // Whether to display the iframe or not
allowApi?: boolean, // Whether the scripting API should be available to the iframe
allow?: string, // The list of feature policies allowed
origin: "player" | "map" // The origin used to place the x and y coordinates of the iframe's top-left corner, defaults to "map"
scale: number, // A ratio used to resize the iframe
}
```
You can create an instance of an embedded website by using the `WA.room.website.create()` method.
It returns an `EmbeddedWebsite` instance.
You can create an instance of an embedded website by using the `WA.room.website.create()` method. It returns
an `EmbeddedWebsite` instance.
```javascript
// Create a new website object
const website = WA.room.website.create({
name: "my_website",
url: "https://example.com",
position: {
x: 64,
y: 128,
width: 320,
height: 240,
},
visible: true,
allowApi: true,
allow: "fullscreen",
name: "my_website",
url: "https://example.com",
position: {
x: 64,
y: 128,
width: 320,
height: 240,
},
visible: true,
allowApi: true,
allow: "fullscreen",
origin: "map",
scale: 1,
});
```
@ -240,30 +258,28 @@ WA.room.website.delete(name: string): Promise<void>
Use `WA.room.website.delete` to completely remove an embedded website from your map.
### The EmbeddedWebsite class
Instances of the `EmbeddedWebsite` class represent the website displayed on the map.
```typescript
class EmbeddedWebsite {
readonly name: string;
url: string;
visible: boolean;
allow: string;
allowApi: boolean;
x: number; // In pixels, relative to the map coordinates
y: number; // In pixels, relative to the map coordinates
width: number; // In pixels, sensitive to zoom level
height: number; // In pixels, sensitive to zoom level
readonly name: string;
url: string;
visible: boolean;
allow: string;
allowApi: boolean;
x: number; // In "game" pixels, relative to the map or player coordinates, depending on origin
y: number; // In "game" pixels, relative to the map or player coordinates, depending on origin
width: number; // In "game" pixels
height: number; // In "game" pixels
origin: "player" | "map";
scale: number;
}
```
When you modify a property of an `EmbeddedWebsite` instance, the iframe is automatically modified in the map.
{.alert.alert-warning}
The websites you add/edit/delete via the scripting API are only shown locally. If you want them
to be displayed for every player, you can use [variables](api-start.md) to share a common state
between all users.
{.alert.alert-warning} The websites you add/edit/delete via the scripting API are only shown locally. If you want them
to be displayed for every player, you can use [variables](api-start.md) to share a common state between all users.

View file

@ -22,6 +22,8 @@ export const isEmbeddedWebsiteEvent = new tg.IsInterface()
y: tg.isNumber,
width: tg.isNumber,
height: tg.isNumber,
origin: tg.isSingletonStringUnion("player", "map"),
scale: tg.isNumber,
})
.get();
@ -35,6 +37,8 @@ export const isCreateEmbeddedWebsiteEvent = new tg.IsInterface()
visible: tg.isBoolean,
allowApi: tg.isBoolean,
allow: tg.isString,
origin: tg.isSingletonStringUnion("player", "map"),
scale: tg.isNumber,
})
.get();

View file

@ -10,6 +10,7 @@ export const isGameStateEvent = new tg.IsInterface()
tags: tg.isArray(tg.isString),
variables: tg.isObject,
userRoomToken: tg.isUnion(tg.isString, tg.isUndefined),
playerVariables: tg.isObject,
})
.get();
/**

View file

@ -30,6 +30,8 @@ import type { MenuRegisterEvent, UnregisterMenuEvent } from "./ui/MenuRegisterEv
import type { ChangeLayerEvent } from "./ChangeLayerEvent";
import type { ChangeZoneEvent } from "./ChangeZoneEvent";
import { isColorEvent } from "./ColorEvent";
import { isPlayerPosition } from "./PlayerPosition";
import type { WasCameraUpdatedEvent } from "./WasCameraUpdatedEvent";
export interface TypedMessageEvent<T> extends MessageEvent {
data: T;
@ -50,6 +52,7 @@ export type IframeEventMap = {
displayBubble: null;
removeBubble: null;
onPlayerMove: undefined;
onCameraUpdate: undefined;
showLayer: LayerEvent;
hideLayer: LayerEvent;
setProperty: SetPropertyEvent;
@ -82,6 +85,7 @@ export interface IframeResponseEventMap {
leaveZoneEvent: ChangeZoneEvent;
buttonClickedEvent: ButtonClickedEvent;
hasPlayerMoved: HasPlayerMovedEvent;
wasCameraUpdated: WasCameraUpdatedEvent;
menuItemClicked: MenuItemClickedEvent;
setVariable: SetVariableEvent;
messageTriggered: MessageReferenceEvent;
@ -161,6 +165,10 @@ export const iframeQueryMapTypeGuards = {
query: tg.isUndefined,
answer: tg.isUndefined,
},
getPlayerPosition: {
query: tg.isUndefined,
answer: isPlayerPosition,
},
};
type GuardedType<T> = T extends (x: unknown) => x is infer T ? T : never;

View file

@ -0,0 +1,10 @@
import * as tg from "generic-type-guard";
export const isPlayerPosition = new tg.IsInterface()
.withProperties({
x: tg.isNumber,
y: tg.isNumber,
})
.get();
export type PlayerPosition = tg.GuardedType<typeof isPlayerPosition>;

View file

@ -4,6 +4,7 @@ export const isSetVariableEvent = new tg.IsInterface()
.withProperties({
key: tg.isString,
value: tg.isUnknown,
target: tg.isSingletonStringUnion("global", "player"),
})
.get();
/**

View file

@ -0,0 +1,19 @@
import * as tg from "generic-type-guard";
export const isWasCameraUpdatedEvent = new tg.IsInterface()
.withProperties({
x: tg.isNumber,
y: tg.isNumber,
width: tg.isNumber,
height: tg.isNumber,
zoom: tg.isNumber,
})
.get();
/**
* A message sent from the game to the iFrame to notify a movement from the camera.
*/
export type WasCameraUpdatedEvent = tg.GuardedType<typeof isWasCameraUpdatedEvent>;
export type WasCameraUpdatedEventCallback = (event: WasCameraUpdatedEvent) => void;

View file

@ -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 { WasCameraUpdatedEvent } from "./Events/WasCameraUpdatedEvent";
import type { ChangeZoneEvent } from "./Events/ChangeZoneEvent";
type AnswererCallback<T extends keyof IframeQueryMap> = (
@ -85,6 +86,9 @@ class IframeListener {
private readonly _loadSoundStream: Subject<LoadSoundEvent> = new Subject();
public readonly loadSoundStream = this._loadSoundStream.asObservable();
private readonly _trackCameraUpdateStream: Subject<LoadSoundEvent> = new Subject();
public readonly trackCameraUpdateStream = this._trackCameraUpdateStream.asObservable();
private readonly _setTilesStream: Subject<SetTilesEvent> = new Subject();
public readonly setTilesStream = this._setTilesStream.asObservable();
@ -226,6 +230,8 @@ class IframeListener {
this._removeBubbleStream.next();
} else if (payload.type == "onPlayerMove") {
this.sendPlayerMove = true;
} else if (payload.type == "onCameraUpdate") {
this._trackCameraUpdateStream.next();
} else if (payload.type == "setTiles" && isSetTilesEvent(payload.data)) {
this._setTilesStream.next(payload.data);
} else if (payload.type == "modifyEmbeddedWebsite" && isEmbeddedWebsiteEvent(payload.data)) {
@ -442,6 +448,13 @@ class IframeListener {
}
}
sendCameraUpdated(event: WasCameraUpdatedEvent) {
this.postMessage({
type: "wasCameraUpdated",
data: event,
});
}
sendButtonClickedEvent(popupId: number, buttonId: number): void {
this.postMessage({
type: "buttonClickedEvent",

View file

@ -12,6 +12,8 @@ export class EmbeddedWebsite {
private _allow: string;
private _allowApi: boolean;
private _position: Rectangle;
private readonly origin: "map" | "player" | undefined;
private _scale: number;
constructor(private config: CreateEmbeddedWebsiteEvent) {
this.name = config.name;
@ -20,6 +22,12 @@ export class EmbeddedWebsite {
this._allow = config.allow ?? "";
this._allowApi = config.allowApi ?? false;
this._position = config.position;
this.origin = config.origin;
this._scale = config.scale ?? 1;
}
public get url() {
return this._url;
}
public set url(url: string) {
@ -33,6 +41,10 @@ export class EmbeddedWebsite {
});
}
public get visible() {
return this._visible;
}
public set visible(visible: boolean) {
this._visible = visible;
sendToWorkadventure({
@ -44,6 +56,10 @@ export class EmbeddedWebsite {
});
}
public get x() {
return this._position.x;
}
public set x(x: number) {
this._position.x = x;
sendToWorkadventure({
@ -55,6 +71,10 @@ export class EmbeddedWebsite {
});
}
public get y() {
return this._position.y;
}
public set y(y: number) {
this._position.y = y;
sendToWorkadventure({
@ -66,6 +86,10 @@ export class EmbeddedWebsite {
});
}
public get width() {
return this._position.width;
}
public set width(width: number) {
this._position.width = width;
sendToWorkadventure({
@ -77,6 +101,10 @@ export class EmbeddedWebsite {
});
}
public get height() {
return this._position.height;
}
public set height(height: number) {
this._position.height = height;
sendToWorkadventure({
@ -87,4 +115,19 @@ export class EmbeddedWebsite {
},
});
}
public get scale(): number {
return this._scale;
}
public set scale(scale: number) {
this._scale = scale;
sendToWorkadventure({
type: "modifyEmbeddedWebsite",
data: {
name: this.name,
scale: this._scale,
},
});
}
}

View file

@ -0,0 +1,29 @@
import { IframeApiContribution, sendToWorkadventure } from "./IframeApiContribution";
import { Subject } from "rxjs";
import type { WasCameraUpdatedEvent } from "../Events/WasCameraUpdatedEvent";
import { apiCallback } from "./registeredCallbacks";
import { isWasCameraUpdatedEvent } from "../Events/WasCameraUpdatedEvent";
const moveStream = new Subject<WasCameraUpdatedEvent>();
export class WorkAdventureCameraCommands extends IframeApiContribution<WorkAdventureCameraCommands> {
callbacks = [
apiCallback({
type: "wasCameraUpdated",
typeChecker: isWasCameraUpdatedEvent,
callback: (payloadData) => {
moveStream.next(payloadData);
},
}),
];
onCameraUpdate(): Subject<WasCameraUpdatedEvent> {
sendToWorkadventure({
type: "onCameraUpdate",
data: null,
});
return moveStream;
}
}
export default new WorkAdventureCameraCommands();

View file

@ -3,6 +3,7 @@ import type { HasPlayerMovedEvent, HasPlayerMovedEventCallback } from "../Events
import { Subject } from "rxjs";
import { apiCallback } from "./registeredCallbacks";
import { isHasPlayerMovedEvent } from "../Events/HasPlayerMovedEvent";
import { createState } from "./state";
const moveStream = new Subject<HasPlayerMovedEvent>();
@ -31,6 +32,8 @@ export const setUuid = (_uuid: string | undefined) => {
};
export class WorkadventurePlayerCommands extends IframeApiContribution<WorkadventurePlayerCommands> {
readonly state = createState("player");
callbacks = [
apiCallback({
type: "hasPlayerMoved",
@ -74,6 +77,13 @@ export class WorkadventurePlayerCommands extends IframeApiContribution<Workadven
return uuid;
}
async getPosition(): Promise<Position> {
return await queryWorkadventure({
type: "getPlayerPosition",
data: undefined,
});
}
get userRoomToken(): string | undefined {
if (userRoomToken === undefined) {
throw new Error(
@ -102,4 +112,9 @@ export class WorkadventurePlayerCommands extends IframeApiContribution<Workadven
}
}
export type Position = {
x: number;
y: number;
};
export default new WorkadventurePlayerCommands();

View file

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

View file

@ -1,8 +1,4 @@
import type { LoadSoundEvent } from "../Events/LoadSoundEvent";
import type { PlaySoundEvent } from "../Events/PlaySoundEvent";
import type { StopSoundEvent } from "../Events/StopSoundEvent";
import { IframeApiContribution, queryWorkadventure, sendToWorkadventure } from "./IframeApiContribution";
import { Sound } from "./Sound/Sound";
import { EmbeddedWebsite } from "./Room/EmbeddedWebsite";
import type { CreateEmbeddedWebsiteEvent } from "../Events/EmbeddedWebsiteEvent";

View file

@ -22,8 +22,8 @@ const nonce = "nonce";
const notification = "notificationPermission";
const code = "code";
const cameraSetup = "cameraSetup";
const cacheAPIIndex = "workavdenture-cache";
const userProperties = "user-properties";
class LocalUserStore {
saveUser(localUser: LocalUser) {
@ -220,6 +220,27 @@ class LocalUserStore {
const cameraSetupValues = localStorage.getItem(cameraSetup);
return cameraSetupValues != undefined ? JSON.parse(cameraSetupValues) : undefined;
}
getAllUserProperties(): Map<string, unknown> {
const result = new Map<string, string>();
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key) {
if (key.startsWith(userProperties + "_")) {
const value = localStorage.getItem(key);
if (value) {
const userKey = key.substr((userProperties + "_").length);
result.set(userKey, JSON.parse(value));
}
}
}
}
return result;
}
setUserProperty(name: string, value: unknown): void {
localStorage.setItem(userProperties + "_" + name, JSON.stringify(value));
}
}
export const localUserStore = new LocalUserStore();

View file

@ -16,7 +16,8 @@ export class EmbeddedWebsiteManager {
if (website === undefined) {
throw new Error('Cannot find embedded website with name "' + name + '"');
}
const rect = website.iframe.getBoundingClientRect();
const scale = website.scale ?? 1;
return {
url: website.url,
name: website.name,
@ -26,9 +27,11 @@ export class EmbeddedWebsiteManager {
position: {
x: website.phaserObject.x,
y: website.phaserObject.y,
width: rect["width"],
height: rect["height"],
width: website.phaserObject.width * scale,
height: website.phaserObject.height * scale,
},
origin: website.origin,
scale: website.scale,
};
});
@ -59,7 +62,9 @@ export class EmbeddedWebsiteManager {
createEmbeddedWebsiteEvent.position.height,
createEmbeddedWebsiteEvent.visible ?? true,
createEmbeddedWebsiteEvent.allowApi ?? false,
createEmbeddedWebsiteEvent.allow ?? ""
createEmbeddedWebsiteEvent.allow ?? "",
createEmbeddedWebsiteEvent.origin ?? "map",
createEmbeddedWebsiteEvent.scale ?? 1
);
}
);
@ -107,10 +112,18 @@ export class EmbeddedWebsiteManager {
website.phaserObject.y = embeddedWebsiteEvent.y;
}
if (embeddedWebsiteEvent?.width !== undefined) {
website.iframe.style.width = embeddedWebsiteEvent.width + "px";
website.position.width = embeddedWebsiteEvent.width;
website.iframe.style.width = embeddedWebsiteEvent.width / website.phaserObject.scale + "px";
}
if (embeddedWebsiteEvent?.height !== undefined) {
website.iframe.style.height = embeddedWebsiteEvent.height + "px";
website.position.height = embeddedWebsiteEvent.height;
website.iframe.style.height = embeddedWebsiteEvent.height / website.phaserObject.scale + "px";
}
if (embeddedWebsiteEvent?.scale !== undefined) {
website.phaserObject.scale = embeddedWebsiteEvent.scale;
website.iframe.style.width = website.position.width / embeddedWebsiteEvent.scale + "px";
website.iframe.style.height = website.position.height / embeddedWebsiteEvent.scale + "px";
}
}
);
@ -125,7 +138,9 @@ export class EmbeddedWebsiteManager {
height: number,
visible: boolean,
allowApi: boolean,
allow: string
allow: string,
origin: "map" | "player" | undefined,
scale: number | undefined
): void {
if (this.embeddedWebsites.has(name)) {
throw new Error('An embedded website with the name "' + name + '" already exists in your map');
@ -135,9 +150,9 @@ export class EmbeddedWebsiteManager {
name,
url,
/*x,
y,
width,
height,*/
y,
width,
height,*/
allow,
allowApi,
visible,
@ -147,6 +162,8 @@ export class EmbeddedWebsiteManager {
width,
height,
},
origin,
scale,
};
const embeddedWebsite = this.doCreateEmbeddedWebsite(embeddedWebsiteEvent, visible);
@ -161,22 +178,43 @@ export class EmbeddedWebsiteManager {
const absoluteUrl = new URL(embeddedWebsiteEvent.url, this.gameScene.MapUrlFile).toString();
const iframe = document.createElement("iframe");
const scale = embeddedWebsiteEvent.scale ?? 1;
iframe.src = absoluteUrl;
iframe.tabIndex = -1;
iframe.style.width = embeddedWebsiteEvent.position.width + "px";
iframe.style.height = embeddedWebsiteEvent.position.height + "px";
iframe.style.width = embeddedWebsiteEvent.position.width / scale + "px";
iframe.style.height = embeddedWebsiteEvent.position.height / scale + "px";
iframe.style.margin = "0";
iframe.style.padding = "0";
iframe.style.border = "none";
const domElement = new DOMElement(
this.gameScene,
embeddedWebsiteEvent.position.x,
embeddedWebsiteEvent.position.y,
iframe
);
domElement.setOrigin(0, 0);
if (embeddedWebsiteEvent.scale) {
domElement.scale = embeddedWebsiteEvent.scale;
}
domElement.setVisible(visible);
switch (embeddedWebsiteEvent.origin) {
case "player":
this.gameScene.CurrentPlayer.add(domElement);
break;
case "map":
default:
this.gameScene.add.existing(domElement);
}
const embeddedWebsite = {
...embeddedWebsiteEvent,
phaserObject: this.gameScene.add
.dom(embeddedWebsiteEvent.position.x, embeddedWebsiteEvent.position.y, iframe)
.setVisible(visible)
.setOrigin(0, 0),
phaserObject: domElement,
iframe: iframe,
};
if (embeddedWebsiteEvent.allowApi) {
iframeListener.registerIframe(iframe);
}

View file

@ -93,6 +93,8 @@ import { MapStore } from "../../Stores/Utils/MapStore";
import { SetPlayerDetailsMessage } from "../../Messages/generated/messages_pb";
import { followUsersColorStore, followUsersStore } from "../../Stores/FollowStore";
import { getColorRgbFromHue } from "../../WebRtc/ColorGenerator";
import Camera = Phaser.Cameras.Scene2D.Camera;
import type { WasCameraUpdatedEvent } from "../../Api/Events/WasCameraUpdatedEvent";
export interface GameSceneInitInterface {
initPosition: PointInterface | null;
@ -210,6 +212,8 @@ export class GameScene extends DirtyScene {
private objectsByType = new Map<string, ITiledMapObject[]>();
private embeddedWebsiteManager!: EmbeddedWebsiteManager;
private loader: Loader;
private lastCameraEvent: WasCameraUpdatedEvent | undefined;
private firstCameraUpdateSent: boolean = false;
constructor(private room: Room, MapUrlFile: string, customKey?: string | undefined) {
super({
@ -523,7 +527,9 @@ export class GameScene extends DirtyScene {
object.height,
object.visible,
allowApi ?? false,
""
"",
"map",
1
);
}
}
@ -1100,9 +1106,33 @@ ${escapedMessage}
);
this.iframeSubscriptionList.push(
iframeListener.stopSoundStream.subscribe((stopSoundEvent) => {
const url = new URL(stopSoundEvent.url, this.MapUrlFile);
soundManager.stopSound(this.sound, url.toString());
iframeListener.trackCameraUpdateStream.subscribe(() => {
if (!this.firstCameraUpdateSent) {
this.cameras.main.on("followupdate", (camera: Camera) => {
const cameraEvent: WasCameraUpdatedEvent = {
x: camera.worldView.x,
y: camera.worldView.y,
width: camera.worldView.width,
height: camera.worldView.height,
zoom: camera.scaleManager.zoom,
};
if (
this.lastCameraEvent?.x == cameraEvent.x &&
this.lastCameraEvent?.y == cameraEvent.y &&
this.lastCameraEvent?.width == cameraEvent.width &&
this.lastCameraEvent?.height == cameraEvent.height &&
this.lastCameraEvent?.zoom == cameraEvent.zoom
) {
return;
}
this.lastCameraEvent = cameraEvent;
iframeListener.sendCameraUpdated(cameraEvent);
this.firstCameraUpdateSent = true;
});
iframeListener.sendCameraUpdated(this.cameras.main);
}
})
);
@ -1165,6 +1195,12 @@ ${escapedMessage}
})
);
this.iframeSubscriptionList.push(
iframeListener.setPropertyStream.subscribe((setProperty) => {
this.setPropertyLayer(setProperty.layerName, setProperty.propertyName, setProperty.propertyValue);
})
);
iframeListener.registerAnswerer("openCoWebsite", async (openCoWebsite, source) => {
if (!source) {
throw new Error("Unknown query source");
@ -1235,6 +1271,7 @@ ${escapedMessage}
roomId: this.roomUrl,
tags: this.connection ? this.connection.getAllTags() : [],
variables: this.sharedVariablesManager.variables,
playerVariables: localUserStore.getAllUserProperties(),
userRoomToken: this.connection ? this.connection.userRoomToken : "",
};
});
@ -1325,6 +1362,22 @@ ${escapedMessage}
})
);
iframeListener.registerAnswerer("setVariable", (event, source) => {
switch (event.target) {
case "global": {
this.sharedVariablesManager.setVariable(event, source);
break;
}
case "player": {
localUserStore.setUserProperty(event.key, event.value);
break;
}
default: {
const _exhaustiveCheck: never = event.target;
}
}
});
iframeListener.registerAnswerer("removeActionMessage", (message) => {
layoutManagerActionStore.removeAction(message.uuid);
});
@ -1343,6 +1396,13 @@ ${escapedMessage}
this.CurrentPlayer.removeOutlineColor();
this.connection?.emitPlayerOutlineColor(null);
});
iframeListener.registerAnswerer("getPlayerPosition", () => {
return {
x: this.CurrentPlayer.x,
y: this.CurrentPlayer.y,
};
});
}
private setPropertyLayer(
@ -1467,6 +1527,7 @@ ${escapedMessage}
iframeListener.unregisterAnswerer("openCoWebsite");
iframeListener.unregisterAnswerer("getCoWebsites");
iframeListener.unregisterAnswerer("setPlayerOutline");
iframeListener.unregisterAnswerer("setVariable");
this.sharedVariablesManager?.close();
this.embeddedWebsiteManager?.close();
@ -1945,6 +2006,7 @@ ${escapedMessage}
this.loader.resize();
}
private getObjectLayerData(objectName: string): ITiledMapObject | undefined {
for (const layer of this.mapFile.layers) {
if (layer.type === "objectgroup" && layer.name === "floorLayer") {
@ -1957,6 +2019,7 @@ ${escapedMessage}
}
return undefined;
}
private reposition(): void {
// Recompute camera offset if needed
biggestAvailableAreaStore.recompute();

View file

@ -3,6 +3,7 @@ import { iframeListener } from "../../Api/IframeListener";
import type { GameMap } from "./GameMap";
import type { ITiledMapLayer, ITiledMapObject } from "../Map/ITiledMap";
import { GameMapProperties } from "./GameMapProperties";
import type { SetVariableEvent } from "../../Api/Events/SetVariableEvent";
interface Variable {
defaultValue: unknown;
@ -48,51 +49,51 @@ export class SharedVariablesManager {
iframeListener.setVariable({
key: name,
value: value,
target: "global",
});
});
}
// When a variable is modified from an iFrame
iframeListener.registerAnswerer("setVariable", (event, source) => {
const key = event.key;
public setVariable(event: SetVariableEvent, source: MessageEventSource | null): void {
const key = event.key;
const object = this.variableObjects.get(key);
const object = this.variableObjects.get(key);
if (object === undefined) {
const errMsg =
'A script is trying to modify variable "' +
key +
'" but this variable is not defined in the map.' +
'There should be an object in the map whose name is "' +
key +
'" and whose type is "variable"';
console.error(errMsg);
throw new Error(errMsg);
}
if (object === undefined) {
const errMsg =
'A script is trying to modify variable "' +
key +
'" but this variable is not defined in the map.' +
'There should be an object in the map whose name is "' +
key +
'" and whose type is "variable"';
console.error(errMsg);
throw new Error(errMsg);
}
if (object.writableBy && !this.roomConnection.hasTag(object.writableBy)) {
const errMsg =
'A script is trying to modify variable "' +
key +
'" but this variable is only writable for users with tag "' +
object.writableBy +
'".';
console.error(errMsg);
throw new Error(errMsg);
}
if (object.writableBy && !this.roomConnection.hasTag(object.writableBy)) {
const errMsg =
'A script is trying to modify variable "' +
key +
'" but this variable is only writable for users with tag "' +
object.writableBy +
'".';
console.error(errMsg);
throw new Error(errMsg);
}
// Let's stop any propagation of the value we set is the same as the existing value.
if (JSON.stringify(event.value) === JSON.stringify(this._variables.get(key))) {
return;
}
// Let's stop any propagation of the value we set is the same as the existing value.
if (JSON.stringify(event.value) === JSON.stringify(this._variables.get(key))) {
return;
}
this._variables.set(key, event.value);
this._variables.set(key, event.value);
// Dispatch to the room connection.
this.roomConnection.emitSetVariableEvent(key, event.value);
// Dispatch to the room connection.
this.roomConnection.emitSetVariableEvent(key, event.value);
// Dispatch to other iframes
iframeListener.dispatchVariableToOtherIframes(key, event.value, source);
});
// Dispatch to other iframes
iframeListener.dispatchVariableToOtherIframes(key, event.value, source);
}
private static findVariablesInMap(gameMap: GameMap): Map<string, Variable> {

View file

@ -14,25 +14,29 @@ import controls from "./Api/iframe/controls";
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 { createState } from "./Api/iframe/state";
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 } from "./Api/iframe/IframeApiContribution";
import camera from "./Api/iframe/camera";
const globalState = createState("global");
// Notify WorkAdventure that we are ready to receive data
const initPromise = queryWorkadventure({
type: "getState",
data: undefined,
}).then((state) => {
setPlayerName(state.nickname);
setRoomId(state.roomId);
setMapURL(state.mapUrl);
setTags(state.tags);
setUuid(state.uuid);
initVariables(state.variables as Map<string, unknown>);
setUserRoomToken(state.userRoomToken);
}).then((gameState) => {
setPlayerName(gameState.nickname);
setRoomId(gameState.roomId);
setMapURL(gameState.mapUrl);
setTags(gameState.tags);
setUuid(gameState.uuid);
globalState.initVariables(gameState.variables as Map<string, unknown>);
player.state.initVariables(gameState.playerVariables as Map<string, unknown>);
setUserRoomToken(gameState.userRoomToken);
});
const wa = {
@ -43,7 +47,8 @@ const wa = {
sound,
room,
player,
state,
camera,
state: globalState,
onInit(): Promise<void> {
return initPromise;
@ -225,7 +230,5 @@ window.addEventListener(
callback?.callback(payloadData);
}
}
// ...
}
);

View file

@ -15,6 +15,8 @@
const heightField = document.getElementById('height');
const urlField = document.getElementById('url');
const visibleField = document.getElementById('visible');
const originField = document.getElementById('origin');
const scaleField = document.getElementById('scale');
createButton.addEventListener('click', () => {
console.log('CREATING NEW EMBEDDED IFRAME');
@ -28,6 +30,8 @@
height: parseInt(heightField.value),
},
visible: !!visibleField.value,
origin: originField.value,
scale: parseFloat(scaleField.value),
});
});
@ -61,6 +65,16 @@
const website = await WA.room.website.get('test');
website.visible = this.checked;
});
originField.addEventListener('change', async function() {
const website = await WA.room.website.get('test');
website.origin = this.value;
});
scaleField.addEventListener('change', async function() {
const website = await WA.room.website.get('test');
website.scale = parseFloat(this.value);
});
});
})
</script>
@ -72,6 +86,8 @@ width: <input type="text" id="width" value="600" /><br/>
height: <input type="text" id="height" value="400" /><br/>
URL: <input type="text" id="url" value="https://mensuel.framapad.org/p/rt6c904745-9oxm?lang=en" /><br/>
Visible: <input type="checkbox" id="visible" value=1 /><br/>
Origin: <input type="text" id="origin" value="map" /><br/>
Scale: <input type="text" id="scale" value=1 /><br/>
<button id="createEmbeddedWebsite">Create embedded website</button>