From 3836d5037c95d2ec78a3a8855ed2c7c0df5ed20b Mon Sep 17 00:00:00 2001 From: jonny Date: Wed, 21 Apr 2021 15:51:01 +0200 Subject: [PATCH 1/5] game state can be read out by the client APIs # Conflicts: # front/src/Api/IframeListener.ts # front/src/Phaser/Game/GameScene.ts # front/src/iframe_api.ts --- front/src/Api/Events/ApiGameStateEvent.ts | 11 +++++++++++ front/src/Api/IframeListener.ts | 16 ++++++++++++++++ front/src/Phaser/Game/GameScene.ts | 7 +++++++ front/src/iframe_api.ts | 22 ++++++++++++++++++++++ front/src/utility.ts | 18 ++++++++++++++++++ 5 files changed, 74 insertions(+) create mode 100644 front/src/Api/Events/ApiGameStateEvent.ts create mode 100644 front/src/utility.ts diff --git a/front/src/Api/Events/ApiGameStateEvent.ts b/front/src/Api/Events/ApiGameStateEvent.ts new file mode 100644 index 00000000..2d5ec686 --- /dev/null +++ b/front/src/Api/Events/ApiGameStateEvent.ts @@ -0,0 +1,11 @@ +import * as tg from "generic-type-guard"; + +export const isGameStateEvent = + new tg.IsInterface().withProperties({ + roomId: tg.isString, + data:tg.isObject + }).get(); +/** + * A message sent from the game to the iFrame when a user enters or leaves a zone marked with the "zone" property. + */ +export type GameStateEvent = tg.GuardedType; \ No newline at end of file diff --git a/front/src/Api/IframeListener.ts b/front/src/Api/IframeListener.ts index c875ebbb..ef7dc6a3 100644 --- a/front/src/Api/IframeListener.ts +++ b/front/src/Api/IframeListener.ts @@ -12,6 +12,8 @@ import {ClosePopupEvent, isClosePopupEvent} from "./Events/ClosePopupEvent"; import {scriptUtils} from "./ScriptUtils"; import {GoToPageEvent, isGoToPageEvent} from "./Events/GoToPageEvent"; import {isOpenCoWebsite, OpenCoWebSiteEvent} from "./Events/OpenCoWebSiteEvent"; +import { GameStateEvent } from './Events/ApiGameStateEvent'; +import { deepFreezeClone as deepFreezeClone } from '../utility'; /** @@ -52,6 +54,10 @@ class IframeListener { private readonly _removeBubbleStream: Subject = new Subject(); public readonly removeBubbleStream = this._removeBubbleStream.asObservable(); + + private readonly _gameStateStream: Subject = new Subject(); + public readonly gameStateStream = this._gameStateStream.asObservable(); + private readonly iframes = new Set(); private readonly scripts = new Map(); @@ -103,6 +109,8 @@ class IframeListener { } else if (payload.type === 'removeBubble'){ this._removeBubbleStream.next(); + }else if(payload.type=="getState"){ + this._gameStateStream.next(); } } @@ -111,6 +119,14 @@ class IframeListener { } + + sendFrozenGameStateEvent(gameStateEvent: GameStateEvent) { + this.postMessage({ + 'type': 'gameState', + 'data': deepFreezeClone(gameStateEvent) + }); + } + /** * Allows the passed iFrame to send/receive messages via the API. */ diff --git a/front/src/Phaser/Game/GameScene.ts b/front/src/Phaser/Game/GameScene.ts index 990f702c..ae9f23b8 100644 --- a/front/src/Phaser/Game/GameScene.ts +++ b/front/src/Phaser/Game/GameScene.ts @@ -841,6 +841,13 @@ ${escapedMessage} this.iframeSubscriptionList.push(iframeListener.enablePlayerControlStream.subscribe(()=>{ this.userInputManager.restoreControls(); })); + this.iframeSubscriptionList.push(iframeListener.gameStateStream.subscribe(()=>{ + iframeListener.sendFrozenGameStateEvent({ + roomId:this.RoomId, + data: this.mapFile + }) + })); + let scriptedBubbleSprite : Sprite; this.iframeSubscriptionList.push(iframeListener.displayBubbleStream.subscribe(()=>{ scriptedBubbleSprite = new Sprite(this,this.CurrentPlayer.x + 25,this.CurrentPlayer.y,'circleSprite-white'); diff --git a/front/src/iframe_api.ts b/front/src/iframe_api.ts index 18d8d172..b1a8de48 100644 --- a/front/src/iframe_api.ts +++ b/front/src/iframe_api.ts @@ -9,6 +9,7 @@ import {ClosePopupEvent} from "./Api/Events/ClosePopupEvent"; import {OpenTabEvent} from "./Api/Events/OpenTabEvent"; import {GoToPageEvent} from "./Api/Events/GoToPageEvent"; import {OpenCoWebSiteEvent} from "./Api/Events/OpenCoWebSiteEvent"; +import { GameStateEvent, isGameStateEvent } from './Api/Events/ApiGameStateEvent'; interface WorkAdventureApi { sendChatMessage(message: string, author: string): void; @@ -24,6 +25,7 @@ interface WorkAdventureApi { restorePlayerControl() : void; displayBubble() : void; removeBubble() : void; + getGameState():Promise } declare global { @@ -74,7 +76,23 @@ class Popup { } } + +const stateResolvers:Array<(event:GameStateEvent)=>void> =[] + window.WA = { + + + + getGameState(){ + return new Promise((resolver,thrower)=>{ + stateResolvers.push(resolver); + window.parent.postMessage({ + type:"getState" + },"*") + }) + }, + + /** * Send a message in the chat. * Only the local user will receive this message. @@ -224,6 +242,10 @@ window.addEventListener('message', message => { if (callback) { callback(popup); } + }else if(payload.type=="gameState" && isGameStateEvent(payloadData)){ + stateResolvers.forEach(resolver=>{ + resolver(payloadData); + }) } } diff --git a/front/src/utility.ts b/front/src/utility.ts new file mode 100644 index 00000000..a95da6f8 --- /dev/null +++ b/front/src/utility.ts @@ -0,0 +1,18 @@ +export function deepFreezeClone (obj:T):Readonly { + return deepFreeze(JSON.parse(JSON.stringify(obj))); +} + +function deepFreeze (obj:T):T{ + Object.freeze(obj); + if (obj === undefined) { + return obj; + } + const propertyNames = Object.getOwnPropertyNames(obj) as Array; + propertyNames.forEach(function (prop) { + if (obj[prop] !== null&& (typeof obj[prop] === "object" || typeof obj[prop] === "function") && !Object.isFrozen(obj[prop])) { + deepFreezeClone(obj[prop]); + } + }); + + return obj; +} \ No newline at end of file From cd77af318d779a10deb136b980d5dc1304340f30 Mon Sep 17 00:00:00 2001 From: jonny Date: Sat, 1 May 2021 19:44:14 +0200 Subject: [PATCH 2/5] added more properties # Conflicts: # front/src/Phaser/Game/GameScene.ts --- front/src/Api/Events/ApiGameStateEvent.ts | 21 ++++++++++++++- front/src/Phaser/Game/GameScene.ts | 32 ++++++++++++++++++++--- 2 files changed, 49 insertions(+), 4 deletions(-) diff --git a/front/src/Api/Events/ApiGameStateEvent.ts b/front/src/Api/Events/ApiGameStateEvent.ts index 2d5ec686..4f4e98ff 100644 --- a/front/src/Api/Events/ApiGameStateEvent.ts +++ b/front/src/Api/Events/ApiGameStateEvent.ts @@ -1,9 +1,28 @@ import * as tg from "generic-type-guard"; +export const isPositionState = new tg.IsInterface().withProperties({ + x: tg.isNumber, + y: tg.isNumber +}).get() +export const isPlayerState = new tg.IsInterface() + .withStringIndexSignature( + new tg.IsInterface().withProperties({ + position: isPositionState, + pusherId: tg.isUnion(tg.isNumber, tg.isUndefined) + }).get() + ).get() + +export type PlayerStateObject = tg.GuardedType; + export const isGameStateEvent = new tg.IsInterface().withProperties({ roomId: tg.isString, - data:tg.isObject + data: tg.isObject, + mapUrl: tg.isString, + nickName: tg.isString, + uuid: tg.isUnion(tg.isString, tg.isUndefined), + players: isPlayerState, + startLayerName: tg.isUnion(tg.isString, tg.isNull) }).get(); /** * A message sent from the game to the iFrame when a user enters or leaves a zone marked with the "zone" property. diff --git a/front/src/Phaser/Game/GameScene.ts b/front/src/Phaser/Game/GameScene.ts index ae9f23b8..3841ab07 100644 --- a/front/src/Phaser/Game/GameScene.ts +++ b/front/src/Phaser/Game/GameScene.ts @@ -80,6 +80,7 @@ import { lazyLoadCompanionResource } from "../Companion/CompanionTexturesLoading import {touchScreenManager} from "../../Touch/TouchScreenManager"; import {PinchManager} from "../UserInput/PinchManager"; import {joystickBaseImg, joystickBaseKey, joystickThumbImg, joystickThumbKey} from "../Components/MobileJoystick"; +import { PlayerStateObject } from '../../Api/Events/ApiGameStateEvent'; export interface GameSceneInitInterface { initPosition: PointInterface|null, @@ -841,10 +842,35 @@ ${escapedMessage} this.iframeSubscriptionList.push(iframeListener.enablePlayerControlStream.subscribe(()=>{ this.userInputManager.restoreControls(); })); - this.iframeSubscriptionList.push(iframeListener.gameStateStream.subscribe(()=>{ + this.iframeSubscriptionList.push(iframeListener.gameStateStream.subscribe(() => { + const playerObject: PlayerStateObject = { + [this.playerName]: { + position: { + x: this.CurrentPlayer.x, + y: this.CurrentPlayer.y + }, + pusherId: this.connection?.getUserId() + } + } + for (const mapPlayer of this.MapPlayers.children.entries) { + const remotePlayer: RemotePlayer = mapPlayer as RemotePlayer; + playerObject[remotePlayer.PlayerValue] = { + position: { + x: remotePlayer.x, + y: remotePlayer.y + }, + pusherId: remotePlayer.userId + + } + } iframeListener.sendFrozenGameStateEvent({ - roomId:this.RoomId, - data: this.mapFile + mapUrl: this.MapUrlFile, + nickName: this.playerName, + startLayerName: this.startLayerName, + uuid: localUserStore.getLocalUser()?.uuid, + roomId: this.RoomId, + data: this.mapFile, + players: playerObject }) })); From 8e136cebe8a787433a33b13153f1e8f0d9a9f625 Mon Sep 17 00:00:00 2001 From: jonny Date: Mon, 10 May 2021 21:27:17 +0200 Subject: [PATCH 3/5] added callback on playermove - gets quite delayed after walking for a few seconds --- front/src/Api/Events/HasMovedEvent.ts | 19 ++++++ front/src/Api/Events/IframeEvent.ts | 4 +- front/src/Api/IframeListener.ts | 34 +++++++---- front/src/Phaser/Game/GameManager.ts | 7 +-- front/src/Phaser/Game/GameScene.ts | 8 ++- front/src/Phaser/Game/PlayerMovement.ts | 7 ++- .../Game/PlayersPositionInterpolator.ts | 8 +-- front/src/iframe_api.ts | 61 ++++++++++++++----- 8 files changed, 104 insertions(+), 44 deletions(-) create mode 100644 front/src/Api/Events/HasMovedEvent.ts diff --git a/front/src/Api/Events/HasMovedEvent.ts b/front/src/Api/Events/HasMovedEvent.ts new file mode 100644 index 00000000..fef8e731 --- /dev/null +++ b/front/src/Api/Events/HasMovedEvent.ts @@ -0,0 +1,19 @@ +import * as tg from "generic-type-guard"; + + + +export const isHasMovedEvent = + new tg.IsInterface().withProperties({ + direction: tg.isString, + moving: tg.isBoolean, + x: tg.isNumber, + y: tg.isNumber + }).get(); + +/** + * A message sent from the iFrame to the game to add a message in the chat. + */ +export type HasMovedEvent = tg.GuardedType; + + +export type HasMovedEventCallback = (event: HasMovedEvent) => void diff --git a/front/src/Api/Events/IframeEvent.ts b/front/src/Api/Events/IframeEvent.ts index c1ad6955..f28ea85e 100644 --- a/front/src/Api/Events/IframeEvent.ts +++ b/front/src/Api/Events/IframeEvent.ts @@ -1,11 +1,11 @@ - import { GameStateEvent } from './ApiGameStateEvent'; import { ButtonClickedEvent } from './ButtonClickedEvent'; import { ChatEvent } from './ChatEvent'; import { ClosePopupEvent } from './ClosePopupEvent'; import { EnterLeaveEvent } from './EnterLeaveEvent'; import { GoToPageEvent } from './GoToPageEvent'; +import { HasMovedEvent } from './HasMovedEvent'; import { OpenCoWebSiteEvent } from './OpenCoWebSiteEvent'; import { OpenPopupEvent } from './OpenPopupEvent'; import { OpenTabEvent } from './OpenTabEvent'; @@ -30,6 +30,7 @@ export type IframeEventMap = { restorePlayerControl: null displayBubble: null removeBubble: null + enableMoveEvents: undefined } export interface IframeEvent { type: T; @@ -46,6 +47,7 @@ export interface IframeResponseEventMap { leaveEvent: EnterLeaveEvent buttonClickedEvent: ButtonClickedEvent gameState: GameStateEvent + hasMovedEvent: HasMovedEvent } export interface IframeResponseEvent { type: T; diff --git a/front/src/Api/IframeListener.ts b/front/src/Api/IframeListener.ts index fcf4e854..f10d0fc1 100644 --- a/front/src/Api/IframeListener.ts +++ b/front/src/Api/IframeListener.ts @@ -14,6 +14,7 @@ import { IframeEventMap, IframeEvent, IframeResponseEvent, IframeResponseEventMa import { UserInputChatEvent } from "./Events/UserInputChatEvent"; import { GameStateEvent } from './Events/ApiGameStateEvent'; import { deepFreezeClone as deepFreezeClone } from '../utility'; +import { HasMovedEvent } from './Events/HasMovedEvent'; /** @@ -21,6 +22,7 @@ import { deepFreezeClone as deepFreezeClone } from '../utility'; * Also allows to send messages to those iframes. */ class IframeListener { + private readonly _chatStream: Subject = new Subject(); public readonly chatStream = this._chatStream.asObservable(); @@ -54,12 +56,13 @@ class IframeListener { private readonly _removeBubbleStream: Subject = new Subject(); public readonly removeBubbleStream = this._removeBubbleStream.asObservable(); - + private readonly _gameStateStream: Subject = new Subject(); public readonly gameStateStream = this._gameStateStream.asObservable(); private readonly iframes = new Set(); private readonly scripts = new Map(); + private sendMoveEvents: boolean = false; init() { window.addEventListener("message", (message: TypedMessageEvent>) => { @@ -101,20 +104,18 @@ class IframeListener { } else if (payload.type === 'closeCoWebSite') { scriptUtils.closeCoWebSite(); - } - else if (payload.type === 'disablePlayerControl') { + } else if (payload.type === 'disablePlayerControl') { this._disablePlayerControlStream.next(); - } - else if (payload.type === 'restorePlayerControl') { + } else if (payload.type === 'restorePlayerControl') { this._enablePlayerControlStream.next(); - } - else if (payload.type === 'displayBubble') { + } else if (payload.type === 'displayBubble') { this._displayBubbleStream.next(); - } - else if (payload.type === 'removeBubble') { + } else if (payload.type === 'removeBubble') { this._removeBubbleStream.next(); - }else if(payload.type=="getState"){ + } else if (payload.type == "getState") { this._gameStateStream.next(); + } else if (payload.type == "enableMoveEvents") { + this.sendMoveEvents = true } } @@ -123,11 +124,11 @@ class IframeListener { } - + sendFrozenGameStateEvent(gameStateEvent: GameStateEvent) { this.postMessage({ 'type': 'gameState', - 'data': deepFreezeClone(gameStateEvent) + 'data': deepFreezeClone(gameStateEvent) }); } @@ -234,6 +235,15 @@ class IframeListener { }); } + hasMovedEvent(event: HasMovedEvent) { + if (this.sendMoveEvents) { + this.postMessage({ + 'type': 'hasMovedEvent', + 'data': event + }); + } + } + sendButtonClickedEvent(popupId: number, buttonId: number): void { this.postMessage({ 'type': 'buttonClickedEvent', diff --git a/front/src/Phaser/Game/GameManager.ts b/front/src/Phaser/Game/GameManager.ts index 6047d430..157e8e80 100644 --- a/front/src/Phaser/Game/GameManager.ts +++ b/front/src/Phaser/Game/GameManager.ts @@ -8,12 +8,7 @@ import {SelectCharacterSceneName} from "../Login/SelectCharacterScene"; import {EnableCameraSceneName} from "../Login/EnableCameraScene"; import {localUserStore} from "../../Connexion/LocalUserStore"; -export interface HasMovedEvent { - direction: string; - moving: boolean; - x: number; - y: number; -} + /** * This class should be responsible for any scene starting/stopping diff --git a/front/src/Phaser/Game/GameScene.ts b/front/src/Phaser/Game/GameScene.ts index 7d0d51d3..63efa3e6 100644 --- a/front/src/Phaser/Game/GameScene.ts +++ b/front/src/Phaser/Game/GameScene.ts @@ -1,4 +1,4 @@ -import {gameManager, HasMovedEvent} from "./GameManager"; +import { gameManager } from "./GameManager"; import { GroupCreatedUpdatedMessageInterface, MessageUserJoined, @@ -91,7 +91,8 @@ import {touchScreenManager} from "../../Touch/TouchScreenManager"; import {PinchManager} from "../UserInput/PinchManager"; import {joystickBaseImg, joystickBaseKey, joystickThumbImg, joystickThumbKey} from "../Components/MobileJoystick"; import { PlayerStateObject } from '../../Api/Events/ApiGameStateEvent'; -import {waScaleManager} from "../Services/WaScaleManager"; +import { waScaleManager } from "../Services/WaScaleManager"; +import { HasMovedEvent } from '../../Api/Events/HasMovedEvent'; export interface GameSceneInitInterface { initPosition: PointInterface|null, @@ -631,6 +632,9 @@ export class GameScene extends DirtyScene implements CenterListener { //listen event to share position of user this.CurrentPlayer.on(hasMovedEventName, this.pushPlayerPosition.bind(this)) + this.CurrentPlayer.on(hasMovedEventName, (event: HasMovedEvent) => { + iframeListener.hasMovedEvent(event) + }) this.CurrentPlayer.on(hasMovedEventName, this.outlineItem.bind(this)) this.CurrentPlayer.on(hasMovedEventName, (event: HasMovedEvent) => { this.gameMap.setPosition(event.x, event.y); diff --git a/front/src/Phaser/Game/PlayerMovement.ts b/front/src/Phaser/Game/PlayerMovement.ts index eb1a5d1b..18c3ee0c 100644 --- a/front/src/Phaser/Game/PlayerMovement.ts +++ b/front/src/Phaser/Game/PlayerMovement.ts @@ -1,6 +1,7 @@ -import {HasMovedEvent} from "./GameManager"; -import {MAX_EXTRAPOLATION_TIME} from "../../Enum/EnvironmentVariable"; -import {PositionInterface} from "../../Connexion/ConnexionModels"; + +import { MAX_EXTRAPOLATION_TIME } from "../../Enum/EnvironmentVariable"; +import { PositionInterface } from "../../Connexion/ConnexionModels"; +import { HasMovedEvent } from '../../Api/Events/HasMovedEvent'; export class PlayerMovement { public constructor(private startPosition: PositionInterface, private startTick: number, private endPosition: HasMovedEvent, private endTick: number) { diff --git a/front/src/Phaser/Game/PlayersPositionInterpolator.ts b/front/src/Phaser/Game/PlayersPositionInterpolator.ts index 3ac87397..321396e2 100644 --- a/front/src/Phaser/Game/PlayersPositionInterpolator.ts +++ b/front/src/Phaser/Game/PlayersPositionInterpolator.ts @@ -2,13 +2,13 @@ * This class is in charge of computing the position of all players. * Player movement is delayed by 200ms so position depends on ticks. */ -import {PlayerMovement} from "./PlayerMovement"; -import {HasMovedEvent} from "./GameManager"; +import { HasMovedEvent } from '../../Api/Events/HasMovedEvent'; +import { PlayerMovement } from "./PlayerMovement"; export class PlayersPositionInterpolator { playerMovements: Map = new Map(); - updatePlayerPosition(userId: number, playerMovement: PlayerMovement) : void { + updatePlayerPosition(userId: number, playerMovement: PlayerMovement): void { this.playerMovements.set(userId, playerMovement); } @@ -16,7 +16,7 @@ export class PlayersPositionInterpolator { this.playerMovements.delete(userId); } - getUpdatedPositions(tick: number) : Map { + getUpdatedPositions(tick: number): Map { const positions = new Map(); this.playerMovements.forEach((playerMovement: PlayerMovement, userId: number) => { if (playerMovement.isOutdated(tick)) { diff --git a/front/src/iframe_api.ts b/front/src/iframe_api.ts index cb55f1aa..9a3e63b0 100644 --- a/front/src/iframe_api.ts +++ b/front/src/iframe_api.ts @@ -1,5 +1,5 @@ import { ChatEvent } from "./Api/Events/ChatEvent"; -import { isIframeResponseEventWrapper } from "./Api/Events/IframeEvent"; +import { IframeEvent, IframeEventMap, isIframeResponseEventWrapper } from "./Api/Events/IframeEvent"; import { isUserInputChatEvent, UserInputChatEvent } from "./Api/Events/UserInputChatEvent"; import { Subject } from "rxjs"; import { EnterLeaveEvent, isEnterLeaveEvent } from "./Api/Events/EnterLeaveEvent"; @@ -10,6 +10,7 @@ import { OpenTabEvent } from "./Api/Events/OpenTabEvent"; import { GoToPageEvent } from "./Api/Events/GoToPageEvent"; import { OpenCoWebSiteEvent } from "./Api/Events/OpenCoWebSiteEvent"; import { GameStateEvent, isGameStateEvent } from './Api/Events/ApiGameStateEvent'; +import { HasMovedEvent, HasMovedEventCallback, isHasMovedEvent } from './Api/Events/HasMovedEvent'; interface WorkAdventureApi { sendChatMessage(message: string, author: string): void; @@ -17,15 +18,17 @@ interface WorkAdventureApi { onEnterZone(name: string, callback: () => void): void; onLeaveZone(name: string, callback: () => void): void; openPopup(targetObject: string, message: string, buttons: ButtonDescriptor[]): Popup; - openTab(url : string): void; - goToPage(url : string): void; - openCoWebSite(url : string): void; + openTab(url: string): void; + goToPage(url: string): void; + openCoWebSite(url: string): void; closeCoWebSite(): void; disablePlayerControl(): void; restorePlayerControl(): void; displayBubble(): void; removeBubble(): void; - getGameState():Promise + getGameState(): Promise + + onMoveEvent(callback: (moveEvent: HasMovedEvent) => void): void } declare global { @@ -75,20 +78,44 @@ class Popup { }, '*'); } } +function uuidv4() { + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => { + const r = Math.random() * 16 | 0, v = c === 'x' ? r : (r & 0x3 | 0x8); + return v.toString(16); + }); +} + +const stateResolvers: Array<(event: GameStateEvent) => void> = [] + +const callbacks: { [type: string]: HasMovedEventCallback | ((arg?: HasMovedEvent | never) => void) } = {} -const stateResolvers:Array<(event:GameStateEvent)=>void> =[] +function postToParent(content: IframeEvent) { + window.parent.postMessage(content, "*") +} +let moveEventUuid: string | undefined; window.WA = { + onMoveEvent(callback: HasMovedEventCallback): void { + moveEventUuid = uuidv4(); + callbacks[moveEventUuid] = callback; + postToParent({ + type: "enableMoveEvents", + data: undefined + }) + window.parent.postMessage({ + type: "enable" + }, "*") + }, - getGameState(){ - return new Promise((resolver,thrower)=>{ + getGameState() { + return new Promise((resolver, thrower) => { stateResolvers.push(resolver); - window.parent.postMessage({ - type:"getState" - },"*") + window.parent.postMessage({ + type: "getState" + }, "*") }) }, @@ -140,10 +167,10 @@ window.WA = { }, '*'); }, - openCoWebSite(url : string) : void{ + openCoWebSite(url: string): void { window.parent.postMessage({ - "type" : 'openCoWebSite', - "data" : { + "type": 'openCoWebSite', + "data": { url } as OpenCoWebSiteEvent }, '*'); @@ -242,10 +269,12 @@ window.addEventListener('message', message => { if (callback) { callback(popup); } - }else if(payload.type=="gameState" && isGameStateEvent(payloadData)){ - stateResolvers.forEach(resolver=>{ + } else if (payload.type == "gameState" && isGameStateEvent(payloadData)) { + stateResolvers.forEach(resolver => { resolver(payloadData); }) + } else if (payload.type == "hasMovedEvent" && isHasMovedEvent(payloadData) && moveEventUuid) { + callbacks[moveEventUuid](payloadData) } } From 2c4c98b0e56c3d064d2a93aa6464b1b8d508b4de Mon Sep 17 00:00:00 2001 From: jonny Date: Mon, 10 May 2021 21:44:15 +0200 Subject: [PATCH 4/5] limited event trigger to max 10 per second --- front/src/Api/IframeListener.ts | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/front/src/Api/IframeListener.ts b/front/src/Api/IframeListener.ts index f10d0fc1..975dde67 100644 --- a/front/src/Api/IframeListener.ts +++ b/front/src/Api/IframeListener.ts @@ -15,6 +15,8 @@ import { UserInputChatEvent } from "./Events/UserInputChatEvent"; import { GameStateEvent } from './Events/ApiGameStateEvent'; import { deepFreezeClone as deepFreezeClone } from '../utility'; import { HasMovedEvent } from './Events/HasMovedEvent'; +import { Math } from 'phaser'; + /** @@ -63,6 +65,7 @@ class IframeListener { private readonly iframes = new Set(); private readonly scripts = new Map(); private sendMoveEvents: boolean = false; + private lastMoveTimestamp: number = 0 init() { window.addEventListener("message", (message: TypedMessageEvent>) => { @@ -237,10 +240,14 @@ class IframeListener { hasMovedEvent(event: HasMovedEvent) { if (this.sendMoveEvents) { - this.postMessage({ - 'type': 'hasMovedEvent', - 'data': event - }); + if (this.lastMoveTimestamp < Date.now() - 100) { + this.lastMoveTimestamp = Date.now() + this.postMessage({ + 'type': 'hasMovedEvent', + 'data': event + }); + } + } } From 3edfd5b285c5d2f51eab85c0c1fc865a04ffcc8e Mon Sep 17 00:00:00 2001 From: GRL Date: Tue, 18 May 2021 11:33:16 +0200 Subject: [PATCH 5/5] GameState is now save in cache HasPlayerMoved is send when the player is actually moving on the map every 200ms. --- ...ApiGameStateEvent.ts => GameStateEvent.ts} | 9 +- .../Api/Events/HasDataLayerChangedEvent.ts | 16 ++ front/src/Api/Events/HasMovedEvent.ts | 19 -- front/src/Api/Events/HasPlayerMovedEvent.ts | 19 ++ front/src/Api/Events/IframeEvent.ts | 11 +- front/src/Api/IframeListener.ts | 42 ++-- front/src/Phaser/Game/GameScene.ts | 28 +-- front/src/Phaser/Game/PlayerMovement.ts | 6 +- .../Game/PlayersPositionInterpolator.ts | 6 +- front/src/iframe_api.ts | 109 ++++++--- maps/tests/Metadata/map.json | 230 ++++++++++++++++++ maps/tests/Metadata/script.js | 9 + maps/tests/Metadata/tileset_dungeon.png | Bin 0 -> 9696 bytes 13 files changed, 404 insertions(+), 100 deletions(-) rename front/src/Api/Events/{ApiGameStateEvent.ts => GameStateEvent.ts} (72%) create mode 100644 front/src/Api/Events/HasDataLayerChangedEvent.ts delete mode 100644 front/src/Api/Events/HasMovedEvent.ts create mode 100644 front/src/Api/Events/HasPlayerMovedEvent.ts create mode 100644 maps/tests/Metadata/map.json create mode 100644 maps/tests/Metadata/script.js create mode 100644 maps/tests/Metadata/tileset_dungeon.png diff --git a/front/src/Api/Events/ApiGameStateEvent.ts b/front/src/Api/Events/GameStateEvent.ts similarity index 72% rename from front/src/Api/Events/ApiGameStateEvent.ts rename to front/src/Api/Events/GameStateEvent.ts index 4f4e98ff..418d1ca0 100644 --- a/front/src/Api/Events/ApiGameStateEvent.ts +++ b/front/src/Api/Events/GameStateEvent.ts @@ -1,6 +1,6 @@ import * as tg from "generic-type-guard"; -export const isPositionState = new tg.IsInterface().withProperties({ +/*export const isPositionState = new tg.IsInterface().withProperties({ x: tg.isNumber, y: tg.isNumber }).get() @@ -12,19 +12,16 @@ export const isPlayerState = new tg.IsInterface() }).get() ).get() -export type PlayerStateObject = tg.GuardedType; +export type PlayerStateObject = tg.GuardedType;*/ export const isGameStateEvent = new tg.IsInterface().withProperties({ roomId: tg.isString, - data: tg.isObject, mapUrl: tg.isString, - nickName: tg.isString, uuid: tg.isUnion(tg.isString, tg.isUndefined), - players: isPlayerState, startLayerName: tg.isUnion(tg.isString, tg.isNull) }).get(); /** - * A message sent from the game to the iFrame when a user enters or leaves a zone marked with the "zone" property. + * A message sent from the game to the iFrame when the gameState is got by the script */ export type GameStateEvent = tg.GuardedType; \ No newline at end of file diff --git a/front/src/Api/Events/HasDataLayerChangedEvent.ts b/front/src/Api/Events/HasDataLayerChangedEvent.ts new file mode 100644 index 00000000..7714f978 --- /dev/null +++ b/front/src/Api/Events/HasDataLayerChangedEvent.ts @@ -0,0 +1,16 @@ +import * as tg from "generic-type-guard"; + + + +export const isHasDataLayerChangedEvent = + new tg.IsInterface().withProperties({ + data: tg.isObject + }).get(); + +/** + * A message sent from the game to the iFrame when the data of the layers change after the iFrame send a message to the game that it want to listen to the data of the layers + */ +export type HasDataLayerChangedEvent = tg.GuardedType; + + +export type HasDataLayerChangedEventCallback = (event: HasDataLayerChangedEvent) => void \ No newline at end of file diff --git a/front/src/Api/Events/HasMovedEvent.ts b/front/src/Api/Events/HasMovedEvent.ts deleted file mode 100644 index fef8e731..00000000 --- a/front/src/Api/Events/HasMovedEvent.ts +++ /dev/null @@ -1,19 +0,0 @@ -import * as tg from "generic-type-guard"; - - - -export const isHasMovedEvent = - new tg.IsInterface().withProperties({ - direction: tg.isString, - moving: tg.isBoolean, - x: tg.isNumber, - y: tg.isNumber - }).get(); - -/** - * A message sent from the iFrame to the game to add a message in the chat. - */ -export type HasMovedEvent = tg.GuardedType; - - -export type HasMovedEventCallback = (event: HasMovedEvent) => void diff --git a/front/src/Api/Events/HasPlayerMovedEvent.ts b/front/src/Api/Events/HasPlayerMovedEvent.ts new file mode 100644 index 00000000..28603284 --- /dev/null +++ b/front/src/Api/Events/HasPlayerMovedEvent.ts @@ -0,0 +1,19 @@ +import * as tg from "generic-type-guard"; + + + +export const isHasPlayerMovedEvent = + new tg.IsInterface().withProperties({ + direction: tg.isString, + moving: tg.isBoolean, + x: tg.isNumber, + y: tg.isNumber + }).get(); + +/** + * A message sent from the game to the iFrame when the player move after the iFrame send a message to the game that it want to listen to the position of the player + */ +export type HasPlayerMovedEvent = tg.GuardedType; + + +export type HasPlayerMovedEventCallback = (event: HasPlayerMovedEvent) => void diff --git a/front/src/Api/Events/IframeEvent.ts b/front/src/Api/Events/IframeEvent.ts index 307b09fc..ae0eab34 100644 --- a/front/src/Api/Events/IframeEvent.ts +++ b/front/src/Api/Events/IframeEvent.ts @@ -1,15 +1,16 @@ -import { GameStateEvent } from './ApiGameStateEvent'; +import { GameStateEvent } from './GameStateEvent'; import { ButtonClickedEvent } from './ButtonClickedEvent'; import { ChatEvent } from './ChatEvent'; import { ClosePopupEvent } from './ClosePopupEvent'; import { EnterLeaveEvent } from './EnterLeaveEvent'; import { GoToPageEvent } from './GoToPageEvent'; -import { HasMovedEvent } from './HasMovedEvent'; +import { HasPlayerMovedEvent } from './HasPlayerMovedEvent'; import { OpenCoWebSiteEvent } from './OpenCoWebSiteEvent'; import { OpenPopupEvent } from './OpenPopupEvent'; import { OpenTabEvent } from './OpenTabEvent'; import { UserInputChatEvent } from './UserInputChatEvent'; +import { HasDataLayerChangedEvent } from "./HasDataLayerChangedEvent"; export interface TypedMessageEvent extends MessageEvent { @@ -30,7 +31,8 @@ export type IframeEventMap = { restorePlayerControls: null displayBubble: null removeBubble: null - enableMoveEvents: undefined + onPlayerMove: undefined + onDataLayerChange: undefined } export interface IframeEvent { type: T; @@ -47,7 +49,8 @@ export interface IframeResponseEventMap { leaveEvent: EnterLeaveEvent buttonClickedEvent: ButtonClickedEvent gameState: GameStateEvent - hasMovedEvent: HasMovedEvent + hasPlayerMoved: HasPlayerMovedEvent + hasDataLayerChanged: HasDataLayerChangedEvent } export interface IframeResponseEvent { type: T; diff --git a/front/src/Api/IframeListener.ts b/front/src/Api/IframeListener.ts index 82dd23cf..d6c02516 100644 --- a/front/src/Api/IframeListener.ts +++ b/front/src/Api/IframeListener.ts @@ -12,10 +12,11 @@ import { GoToPageEvent, isGoToPageEvent } from "./Events/GoToPageEvent"; import { isOpenCoWebsite, OpenCoWebSiteEvent } from "./Events/OpenCoWebSiteEvent"; import { IframeEventMap, IframeEvent, IframeResponseEvent, IframeResponseEventMap, isIframeEventWrapper, TypedMessageEvent } from "./Events/IframeEvent"; import { UserInputChatEvent } from "./Events/UserInputChatEvent"; -import { GameStateEvent } from './Events/ApiGameStateEvent'; +import { GameStateEvent } from './Events/GameStateEvent'; import { deepFreezeClone as deepFreezeClone } from '../utility'; -import { HasMovedEvent } from './Events/HasMovedEvent'; +import { HasPlayerMovedEvent } from './Events/HasPlayerMovedEvent'; import { Math } from 'phaser'; +import { HasDataLayerChangedEvent } from "./Events/HasDataLayerChangedEvent"; @@ -58,14 +59,14 @@ class IframeListener { private readonly _removeBubbleStream: Subject = new Subject(); public readonly removeBubbleStream = this._removeBubbleStream.asObservable(); - private readonly _gameStateStream: Subject = new Subject(); public readonly gameStateStream = this._gameStateStream.asObservable(); + private readonly iframes = new Set(); private readonly scripts = new Map(); - private sendMoveEvents: boolean = false; - private lastMoveTimestamp: number = 0 + private sendPlayerMove: boolean = false; + private sendDataLayerChange: boolean = false; init() { window.addEventListener("message", (message: TypedMessageEvent>) => { @@ -119,8 +120,10 @@ class IframeListener { this._removeBubbleStream.next(); } else if (payload.type == "getState") { this._gameStateStream.next(); - } else if (payload.type == "enableMoveEvents") { - this.sendMoveEvents = true + } else if (payload.type == "onPlayerMove") { + this.sendPlayerMove = true + } else if (payload.type == "onDataLayerChange") { + this.sendDataLayerChange = true } } @@ -133,7 +136,7 @@ class IframeListener { sendFrozenGameStateEvent(gameStateEvent: GameStateEvent) { this.postMessage({ 'type': 'gameState', - 'data': deepFreezeClone(gameStateEvent) + 'data': gameStateEvent //deepFreezeClone(gameStateEvent) }); } @@ -240,16 +243,21 @@ class IframeListener { }); } - hasMovedEvent(event: HasMovedEvent) { - if (this.sendMoveEvents) { - if (this.lastMoveTimestamp < Date.now() - 100) { - this.lastMoveTimestamp = Date.now() - this.postMessage({ - 'type': 'hasMovedEvent', - 'data': event - }); - } + hasPlayerMoved(event: HasPlayerMovedEvent) { + if (this.sendPlayerMove) { + this.postMessage({ + 'type': 'hasPlayerMoved', + 'data': event + }); + } + } + hasDataLayerChanged(event: HasDataLayerChangedEvent) { + if (this.sendDataLayerChange) { + this.postMessage({ + 'type' : 'hasDataLayerChanged', + 'data' : event + }); } } diff --git a/front/src/Phaser/Game/GameScene.ts b/front/src/Phaser/Game/GameScene.ts index 39fa79db..83256cec 100644 --- a/front/src/Phaser/Game/GameScene.ts +++ b/front/src/Phaser/Game/GameScene.ts @@ -90,9 +90,9 @@ import { TextUtils } from "../Components/TextUtils"; import { touchScreenManager } from "../../Touch/TouchScreenManager"; import { PinchManager } from "../UserInput/PinchManager"; import { joystickBaseImg, joystickBaseKey, joystickThumbImg, joystickThumbKey } from "../Components/MobileJoystick"; -import { PlayerStateObject } from '../../Api/Events/ApiGameStateEvent'; +//import { PlayerStateObject } from '../../Api/Events/GameStateEvent'; import { waScaleManager } from "../Services/WaScaleManager"; -import { HasMovedEvent } from '../../Api/Events/HasMovedEvent'; +import { HasPlayerMovedEvent } from '../../Api/Events/HasPlayerMovedEvent'; export interface GameSceneInitInterface { initPosition: PointInterface | null, @@ -164,7 +164,7 @@ export class GameScene extends DirtyScene implements CenterListener { currentTick!: number; lastSentTick!: number; // The last tick at which a position was sent. - lastMoveEventSent: HasMovedEvent = { + lastMoveEventSent: HasPlayerMovedEvent = { direction: '', moving: false, x: -1000, @@ -632,11 +632,11 @@ export class GameScene extends DirtyScene implements CenterListener { //listen event to share position of user this.CurrentPlayer.on(hasMovedEventName, this.pushPlayerPosition.bind(this)) - this.CurrentPlayer.on(hasMovedEventName, (event: HasMovedEvent) => { - iframeListener.hasMovedEvent(event) + this.CurrentPlayer.on(hasMovedEventName, (event: HasPlayerMovedEvent) => { + //iframeListener.hasMovedEvent(event) }) this.CurrentPlayer.on(hasMovedEventName, this.outlineItem.bind(this)) - this.CurrentPlayer.on(hasMovedEventName, (event: HasMovedEvent) => { + this.CurrentPlayer.on(hasMovedEventName, (event: HasPlayerMovedEvent) => { this.gameMap.setPosition(event.x, event.y); }) @@ -870,7 +870,7 @@ ${escapedMessage} this.userInputManager.restoreControls(); })); this.iframeSubscriptionList.push(iframeListener.gameStateStream.subscribe(() => { - const playerObject: PlayerStateObject = { + /*const playerObject: PlayerStateObject = { [this.playerName]: { position: { x: this.CurrentPlayer.x, @@ -889,15 +889,12 @@ ${escapedMessage} pusherId: remotePlayer.userId } - } + }*/ iframeListener.sendFrozenGameStateEvent({ mapUrl: this.MapUrlFile, - nickName: this.playerName, startLayerName: this.startLayerName, uuid: localUserStore.getLocalUser()?.uuid, roomId: this.RoomId, - data: this.mapFile, - players: playerObject }) })); @@ -1158,7 +1155,7 @@ ${escapedMessage} this.createCollisionWithPlayer(); } - pushPlayerPosition(event: HasMovedEvent) { + pushPlayerPosition(event: HasPlayerMovedEvent) { if (this.lastMoveEventSent === event) { return; } @@ -1188,7 +1185,7 @@ ${escapedMessage} * Finds the correct item to outline and outline it (if there is an item to be outlined) * @param event */ - private outlineItem(event: HasMovedEvent): void { + private outlineItem(event: HasPlayerMovedEvent): void { let x = event.x; let y = event.y; switch (event.direction) { @@ -1227,7 +1224,7 @@ ${escapedMessage} this.outlinedItem?.selectable(); } - private doPushPlayerPosition(event: HasMovedEvent): void { + private doPushPlayerPosition(event: HasPlayerMovedEvent): void { this.lastMoveEventSent = event; this.lastSentTick = this.currentTick; const camera = this.cameras.main; @@ -1237,6 +1234,7 @@ ${escapedMessage} right: camera.scrollX + camera.width, bottom: camera.scrollY + camera.height, }); + iframeListener.hasPlayerMoved(event); } /** @@ -1286,7 +1284,7 @@ ${escapedMessage} } // Let's move all users const updatedPlayersPositions = this.playersPositionInterpolator.getUpdatedPositions(time); - updatedPlayersPositions.forEach((moveEvent: HasMovedEvent, userId: number) => { + updatedPlayersPositions.forEach((moveEvent: HasPlayerMovedEvent, userId: number) => { this.dirty = true; const player: RemotePlayer | undefined = this.MapPlayersByKey.get(userId); if (player === undefined) { diff --git a/front/src/Phaser/Game/PlayerMovement.ts b/front/src/Phaser/Game/PlayerMovement.ts index 18c3ee0c..5680d7de 100644 --- a/front/src/Phaser/Game/PlayerMovement.ts +++ b/front/src/Phaser/Game/PlayerMovement.ts @@ -1,10 +1,10 @@ import { MAX_EXTRAPOLATION_TIME } from "../../Enum/EnvironmentVariable"; import { PositionInterface } from "../../Connexion/ConnexionModels"; -import { HasMovedEvent } from '../../Api/Events/HasMovedEvent'; +import { HasPlayerMovedEvent } from '../../Api/Events/HasPlayerMovedEvent'; export class PlayerMovement { - public constructor(private startPosition: PositionInterface, private startTick: number, private endPosition: HasMovedEvent, private endTick: number) { + public constructor(private startPosition: PositionInterface, private startTick: number, private endPosition: HasPlayerMovedEvent, private endTick: number) { } public isOutdated(tick: number): boolean { @@ -18,7 +18,7 @@ export class PlayerMovement { return tick > this.endTick + MAX_EXTRAPOLATION_TIME; } - public getPosition(tick: number): HasMovedEvent { + public getPosition(tick: number): HasPlayerMovedEvent { // Special case: end position reached and end position is not moving if (tick >= this.endTick && this.endPosition.moving === false) { //console.log('Movement finished ', this.endPosition) diff --git a/front/src/Phaser/Game/PlayersPositionInterpolator.ts b/front/src/Phaser/Game/PlayersPositionInterpolator.ts index 321396e2..53578884 100644 --- a/front/src/Phaser/Game/PlayersPositionInterpolator.ts +++ b/front/src/Phaser/Game/PlayersPositionInterpolator.ts @@ -2,7 +2,7 @@ * This class is in charge of computing the position of all players. * Player movement is delayed by 200ms so position depends on ticks. */ -import { HasMovedEvent } from '../../Api/Events/HasMovedEvent'; +import { HasPlayerMovedEvent } from '../../Api/Events/HasPlayerMovedEvent'; import { PlayerMovement } from "./PlayerMovement"; export class PlayersPositionInterpolator { @@ -16,8 +16,8 @@ export class PlayersPositionInterpolator { this.playerMovements.delete(userId); } - getUpdatedPositions(tick: number): Map { - const positions = new Map(); + getUpdatedPositions(tick: number): Map { + const positions = new Map(); this.playerMovements.forEach((playerMovement: PlayerMovement, userId: number) => { if (playerMovement.isOutdated(tick)) { //console.log("outdated") diff --git a/front/src/iframe_api.ts b/front/src/iframe_api.ts index 17c489ca..c2e91ea5 100644 --- a/front/src/iframe_api.ts +++ b/front/src/iframe_api.ts @@ -9,8 +9,9 @@ import { ClosePopupEvent } from "./Api/Events/ClosePopupEvent"; import { OpenTabEvent } from "./Api/Events/OpenTabEvent"; import { GoToPageEvent } from "./Api/Events/GoToPageEvent"; import { OpenCoWebSiteEvent } from "./Api/Events/OpenCoWebSiteEvent"; -import { GameStateEvent, isGameStateEvent } from './Api/Events/ApiGameStateEvent'; -import { HasMovedEvent, HasMovedEventCallback, isHasMovedEvent } from './Api/Events/HasMovedEvent'; +import { GameStateEvent, isGameStateEvent } from './Api/Events/GameStateEvent'; +import { HasPlayerMovedEvent, HasPlayerMovedEventCallback, isHasPlayerMovedEvent } from './Api/Events/HasPlayerMovedEvent'; +import { HasDataLayerChangedEvent, HasDataLayerChangedEventCallback, isHasDataLayerChangedEvent} from "./Api/Events/HasDataLayerChangedEvent"; interface WorkAdventureApi { sendChatMessage(message: string, author: string): void; @@ -26,9 +27,14 @@ interface WorkAdventureApi { restorePlayerControls(): void; displayBubble(): void; removeBubble(): void; - getGameState(): Promise + getMapUrl(): Promise; + getUuid(): Promise; + getRoomId(): Promise; + getStartLayerName(): Promise; - onMoveEvent(callback: (moveEvent: HasMovedEvent) => void): void + + onPlayerMove(callback: (playerMovedEvent: HasPlayerMovedEvent) => void): void + onDataLayerChange(callback: (dataLayerChangedEvent: HasDataLayerChangedEvent) => void): void } declare global { @@ -84,41 +90,75 @@ function uuidv4() { return v.toString(16); }); } - -const stateResolvers: Array<(event: GameStateEvent) => void> = [] - -const callbacks: { [type: string]: HasMovedEventCallback | ((arg?: HasMovedEvent | never) => void) } = {} - - -function postToParent(content: IframeEvent) { - window.parent.postMessage(content, "*") -} -let moveEventUuid: string | undefined; - -window.WA = { - - onMoveEvent(callback: HasMovedEventCallback): void { - moveEventUuid = uuidv4(); - callbacks[moveEventUuid] = callback; - postToParent({ - type: "enableMoveEvents", - data: undefined - }) - - window.parent.postMessage({ - type: "enable" - }, "*") - }, - - getGameState() { +function getGameState(): Promise { + if (immutableData) { + return Promise.resolve(immutableData); + } + else { return new Promise((resolver, thrower) => { stateResolvers.push(resolver); window.parent.postMessage({ type: "getState" }, "*") }) + } +} + +const stateResolvers: Array<(event: GameStateEvent) => void> = [] +let immutableData: GameStateEvent; + +const callbackPlayerMoved: { [type: string]: HasPlayerMovedEventCallback | ((arg?: HasPlayerMovedEvent | never) => void) } = {} +const callbackDataLayerChanged: { [type: string]: HasDataLayerChangedEventCallback | ((arg?: HasDataLayerChangedEvent | never) => void) } = {} + + +function postToParent(content: IframeEvent) { + window.parent.postMessage(content, "*") +} +let playerUuid: string | undefined; + +window.WA = { + + onPlayerMove(callback: HasPlayerMovedEventCallback): void { + playerUuid = uuidv4(); + callbackPlayerMoved[playerUuid] = callback; + postToParent({ + type: "onPlayerMove", + data: undefined + }) }, + onDataLayerChange(callback: HasDataLayerChangedEventCallback): void { + callbackDataLayerChanged['test'] = callback; + postToParent({ + type : "onDataLayerChange", + data: undefined + }) + }, + + + getMapUrl() { + return getGameState().then((res) => { + return res.mapUrl; + }) + }, + + getUuid() { + return getGameState().then((res) => { + return res.uuid; + }) + }, + + getRoomId() { + return getGameState().then((res) => { + return res.roomId; + }) + }, + + getStartLayerName() { + return getGameState().then((res) => { + return res.startLayerName; + }) + }, /** * Send a message in the chat. @@ -273,8 +313,11 @@ window.addEventListener('message', message => { stateResolvers.forEach(resolver => { resolver(payloadData); }) - } else if (payload.type == "hasMovedEvent" && isHasMovedEvent(payloadData) && moveEventUuid) { - callbacks[moveEventUuid](payloadData) + immutableData = payloadData; + } else if (payload.type == "hasPlayerMoved" && isHasPlayerMovedEvent(payloadData) && playerUuid) { + callbackPlayerMoved[playerUuid](payloadData) + } else if (payload.type == "hasDataLayerChanged" && isHasDataLayerChangedEvent(payloadData)) { + callbackDataLayerChanged['test'](payloadData) } } diff --git a/maps/tests/Metadata/map.json b/maps/tests/Metadata/map.json new file mode 100644 index 00000000..8967ed02 --- /dev/null +++ b/maps/tests/Metadata/map.json @@ -0,0 +1,230 @@ +{ "compressionlevel":-1, + "height":10, + "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, 16, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 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":10, + "id":1, + "name":"start", + "opacity":1, + "type":"tilelayer", + "visible":true, + "width":10, + "x":0, + "y":0 + }, + { + "data":[46, 46, 46, 46, 46, 46, 46, 46, 46, 46, 46, 33, 34, 34, 34, 34, 34, 34, 35, 46, 46, 41, 42, 42, 42, 42, 42, 42, 43, 46, 46, 41, 42, 42, 42, 42, 42, 42, 43, 46, 46, 41, 42, 42, 42, 42, 42, 42, 43, 46, 46, 41, 42, 42, 42, 42, 42, 42, 43, 46, 46, 41, 42, 42, 42, 42, 42, 42, 43, 46, 46, 41, 42, 42, 42, 42, 42, 42, 43, 46, 46, 49, 50, 50, 50, 50, 50, 50, 51, 46, 46, 46, 46, 46, 46, 46, 46, 46, 46, 46], + "height":10, + "id":2, + "name":"bottom", + "opacity":1, + "type":"tilelayer", + "visible":true, + "width":10, + "x":0, + "y":0 + }, + { + "data":[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 52, 52, 52, 0, 0, 0, 0, 0, 0, 0, 52, 52, 52, 0, 0, 0, 0, 0, 0, 0, 52, 52, 52, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 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":10, + "id":4, + "name":"metadata", + "opacity":1, + "type":"tilelayer", + "visible":true, + "width":10, + "x":0, + "y":0 + }, + { + "draworder":"topdown", + "id":5, + "name":"floorLayer", + "objects":[], + "opacity":1, + "type":"objectgroup", + "visible":true, + "x":0, + "y":0 + }, + { + "data":[1, 2, 2, 2, 2, 2, 2, 2, 2, 3, 9, 0, 0, 0, 0, 0, 0, 0, 0, 11, 9, 0, 0, 0, 0, 0, 0, 0, 0, 11, 9, 0, 0, 0, 0, 0, 0, 0, 0, 11, 9, 0, 0, 0, 0, 0, 0, 0, 0, 11, 9, 0, 0, 0, 0, 0, 0, 0, 0, 11, 9, 0, 0, 0, 0, 0, 0, 0, 0, 11, 9, 0, 0, 0, 0, 0, 0, 0, 0, 11, 9, 0, 0, 0, 0, 0, 0, 0, 0, 11, 17, 18, 18, 18, 18, 18, 18, 18, 18, 19], + "height":10, + "id":3, + "name":"wall", + "opacity":1, + "type":"tilelayer", + "visible":true, + "width":10, + "x":0, + "y":0 + }], + "nextlayerid":6, + "nextobjectid":1, + "orientation":"orthogonal", + "properties":[ + { + "name":"script", + "type":"string", + "value":"script.js" + }], + "renderorder":"right-down", + "tiledversion":"1.4.3", + "tileheight":32, + "tilesets":[ + { + "columns":8, + "firstgid":1, + "image":"tileset_dungeon.png", + "imageheight":256, + "imagewidth":256, + "margin":0, + "name":"TDungeon", + "spacing":0, + "tilecount":64, + "tileheight":32, + "tiles":[ + { + "id":0, + "properties":[ + { + "name":"collides", + "type":"bool", + "value":true + }] + }, + { + "id":1, + "properties":[ + { + "name":"collides", + "type":"bool", + "value":true + }] + }, + { + "id":2, + "properties":[ + { + "name":"collides", + "type":"bool", + "value":true + }] + }, + { + "id":3, + "properties":[ + { + "name":"collides", + "type":"bool", + "value":true + }] + }, + { + "id":4, + "properties":[ + { + "name":"collides", + "type":"bool", + "value":true + }] + }, + { + "id":8, + "properties":[ + { + "name":"collides", + "type":"bool", + "value":true + }] + }, + { + "id":9, + "properties":[ + { + "name":"collides", + "type":"bool", + "value":true + }] + }, + { + "id":10, + "properties":[ + { + "name":"collides", + "type":"bool", + "value":true + }] + }, + { + "id":11, + "properties":[ + { + "name":"collides", + "type":"bool", + "value":true + }] + }, + { + "id":12, + "properties":[ + { + "name":"collides", + "type":"bool", + "value":true + }] + }, + { + "id":16, + "properties":[ + { + "name":"collides", + "type":"bool", + "value":true + }] + }, + { + "id":17, + "properties":[ + { + "name":"collides", + "type":"bool", + "value":true + }] + }, + { + "id":18, + "properties":[ + { + "name":"collides", + "type":"bool", + "value":true + }] + }, + { + "id":19, + "properties":[ + { + "name":"collides", + "type":"bool", + "value":true + }] + }, + { + "id":20, + "properties":[ + { + "name":"collides", + "type":"bool", + "value":true + }] + }], + "tilewidth":32 + }], + "tilewidth":32, + "type":"map", + "version":1.4, + "width":10 +} \ No newline at end of file diff --git a/maps/tests/Metadata/script.js b/maps/tests/Metadata/script.js new file mode 100644 index 00000000..f3ac255a --- /dev/null +++ b/maps/tests/Metadata/script.js @@ -0,0 +1,9 @@ + + +WA.getMapUrl().then((map) => {console.log('mapUrl : ', map)}); +WA.getUuid().then((uuid) => {console.log('Uuid : ',uuid)}); +WA.getRoomId().then((roomId) => console.log('roomID : ',roomId)); + +WA.listenPositionPlayer(console.log); + + diff --git a/maps/tests/Metadata/tileset_dungeon.png b/maps/tests/Metadata/tileset_dungeon.png new file mode 100644 index 0000000000000000000000000000000000000000..fcac082c33704b31451bc8a58af5982c06586aa6 GIT binary patch literal 9696 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