diff --git a/front/src/Components/Companion/Companion.svelte b/front/src/Components/Companion/Companion.svelte index 984e8bba..54ee31ac 100644 --- a/front/src/Components/Companion/Companion.svelte +++ b/front/src/Components/Companion/Companion.svelte @@ -1,7 +1,7 @@ diff --git a/front/src/Components/Woka/Woka.svelte b/front/src/Components/Woka/Woka.svelte index 1311973b..0106676e 100644 --- a/front/src/Components/Woka/Woka.svelte +++ b/front/src/Components/Woka/Woka.svelte @@ -9,10 +9,16 @@ export let height: string = "62px"; const gameScene = gameManager.getCurrentGameScene(); - const playerWokaPictureStore = gameScene.getUserWokaPictureStore(userId); + let playerWokaPictureStore; + if (userId === -1) { + playerWokaPictureStore = gameScene.CurrentPlayer.pictureStore; + } else { + playerWokaPictureStore = gameScene.MapPlayersByKey.getNestedStore(userId, (item) => item.pictureStore); + } let src = placeholderSrc; - const unsubscribe = playerWokaPictureStore.picture.subscribe((source) => { + + const unsubscribe = playerWokaPictureStore.subscribe((source) => { src = source ?? placeholderSrc; }); diff --git a/front/src/Phaser/Companion/Companion.ts b/front/src/Phaser/Companion/Companion.ts index f7f010ac..80b0236e 100644 --- a/front/src/Phaser/Companion/Companion.ts +++ b/front/src/Phaser/Companion/Companion.ts @@ -2,6 +2,8 @@ import Sprite = Phaser.GameObjects.Sprite; import Container = Phaser.GameObjects.Container; import { PlayerAnimationDirections, PlayerAnimationTypes } from "../Player/Animation"; import { TexturesHelper } from "../Helpers/TexturesHelper"; +import { Writable, writable } from "svelte/store"; +import type { PictureStore } from "../../Stores/PictureStore"; export interface CompanionStatus { x: number; @@ -22,6 +24,7 @@ export class Companion extends Container { private companionName: string; private direction: PlayerAnimationDirections; private animationType: PlayerAnimationTypes; + private readonly _pictureStore: Writable; constructor(scene: Phaser.Scene, x: number, y: number, name: string, texturePromise: Promise) { super(scene, x + 14, y + 4); @@ -36,11 +39,14 @@ export class Companion extends Container { this.animationType = PlayerAnimationTypes.Idle; this.companionName = name; + this._pictureStore = writable(undefined); texturePromise.then((resource) => { this.addResource(resource); this.invisible = false; - this.emit("texture-loaded"); + return this.getSnapshot().then((htmlImageElementSrc) => { + this._pictureStore.set(htmlImageElementSrc); + }); }); this.scene.physics.world.enableBody(this); @@ -238,4 +244,8 @@ export class Companion extends Container { super.destroy(); } + + public get pictureStore(): PictureStore { + return this._pictureStore; + } } diff --git a/front/src/Phaser/Entity/Character.ts b/front/src/Phaser/Entity/Character.ts index 6a8e0752..2e0bd363 100644 --- a/front/src/Phaser/Entity/Character.ts +++ b/front/src/Phaser/Entity/Character.ts @@ -12,6 +12,8 @@ import type OutlinePipelinePlugin from "phaser3-rex-plugins/plugins/outlinepipel import { isSilentStore } from "../../Stores/MediaStore"; import { lazyLoadPlayerCharacterTextures, loadAllDefaultModels } from "./PlayerTexturesLoadingManager"; import { TexturesHelper } from "../Helpers/TexturesHelper"; +import type { PictureStore } from "../../Stores/PictureStore"; +import { Writable, writable } from "svelte/store"; const playerNameY = -25; @@ -37,6 +39,7 @@ export abstract class Character extends Container { private emote: Phaser.GameObjects.DOMElement | null = null; private emoteTween: Phaser.Tweens.Tween | null = null; scene: GameScene; + private readonly _pictureStore: Writable; constructor( scene: GameScene, @@ -57,6 +60,7 @@ export abstract class Character extends Container { this.invisible = true; this.sprites = new Map(); + this._pictureStore = writable(undefined); //textures are inside a Promise in case they need to be lazyloaded before use. texturesPromise @@ -64,7 +68,9 @@ export abstract class Character extends Container { this.addTextures(textures, frame); this.invisible = false; this.playAnimation(direction, moving); - this.emit("woka-textures-loaded"); + return this.getSnapshot().then((htmlImageElementSrc) => { + this._pictureStore.set(htmlImageElementSrc); + }); }) .catch(() => { return lazyLoadPlayerCharacterTextures(scene.load, ["color_22", "eyes_23"]).then((textures) => { @@ -118,7 +124,7 @@ export abstract class Character extends Container { } } - public async getSnapshot(): Promise { + private async getSnapshot(): Promise { const sprites = Array.from(this.sprites.values()).map((sprite) => { return { sprite, frame: 1 }; }); @@ -137,9 +143,6 @@ export abstract class Character extends Container { public addCompanion(name: string, texturePromise?: Promise): void { if (typeof texturePromise !== "undefined") { this.companion = new Companion(this.scene, this.x, this.y, name, texturePromise); - this.companion.once("texture-loaded", () => { - this.emit("companion-texture-loaded", this.companion?.getSnapshot()); - }); } } @@ -394,4 +397,8 @@ export abstract class Character extends Container { this.emote = null; this.playerName.setVisible(true); } + + public get pictureStore(): PictureStore { + return this._pictureStore; + } } diff --git a/front/src/Phaser/Game/GameScene.ts b/front/src/Phaser/Game/GameScene.ts index 558b4d21..ae89e2c3 100644 --- a/front/src/Phaser/Game/GameScene.ts +++ b/front/src/Phaser/Game/GameScene.ts @@ -77,8 +77,6 @@ import { emoteStore, emoteMenuStore } from "../../Stores/EmoteStore"; import { userIsAdminStore } from "../../Stores/GameStore"; import { contactPageStore } from "../../Stores/MenuStore"; import { audioManagerFileStore, audioManagerVisibilityStore } from "../../Stores/AudioManagerStore"; -import { UserWokaPictureStore } from "../../Stores/UserWokaPictureStore"; -import { UserCompanionPictureStore } from "../../Stores/UserCompanionPictureStore"; import EVENT_TYPE = Phaser.Scenes.Events; import Texture = Phaser.Textures.Texture; @@ -89,6 +87,7 @@ import DOMElement = Phaser.GameObjects.DOMElement; import Tileset = Phaser.Tilemaps.Tileset; import SpriteSheetFile = Phaser.Loader.FileTypes.SpriteSheetFile; import FILE_LOAD_ERROR = Phaser.Loader.Events.FILE_LOAD_ERROR; +import { MapStore } from "../../Stores/Utils/MapStore"; export interface GameSceneInitInterface { initPosition: PointInterface | null; reconnecting: boolean; @@ -128,7 +127,7 @@ export class GameScene extends DirtyScene { Terrains: Array; CurrentPlayer!: Player; MapPlayers!: Phaser.Physics.Arcade.Group; - MapPlayersByKey: Map = new Map(); + MapPlayersByKey: MapStore = new MapStore(); Map!: Phaser.Tilemaps.Tilemap; Objects!: Array; mapFile!: ITiledMap; @@ -204,11 +203,6 @@ export class GameScene extends DirtyScene { private objectsByType = new Map(); private embeddedWebsiteManager!: EmbeddedWebsiteManager; private loader: Loader; - private userWokaPictureStores: Map = new Map(); - private userCompanionPictureStores: Map = new Map< - number, - UserCompanionPictureStore - >(); constructor(private room: Room, MapUrlFile: string, customKey?: string | undefined) { super({ @@ -342,24 +336,6 @@ export class GameScene extends DirtyScene { this.loader.addLoader(); } - public getUserWokaPictureStore(userId: number) { - let store = this.userWokaPictureStores.get(userId); - if (!store) { - store = new UserWokaPictureStore(); - this.userWokaPictureStores.set(userId, store); - } - return store; - } - - public getUserCompanionPictureStore(userId: number) { - let store = this.userCompanionPictureStores.get(userId); - if (!store) { - store = new UserCompanionPictureStore(); - this.userCompanionPictureStores.set(userId, store); - } - return store; - } - // FIXME: we need to put a "unknown" instead of a "any" and validate the structure of the JSON we are receiving. // eslint-disable-next-line @typescript-eslint/no-explicit-any private async onMapLoad(data: any): Promise { @@ -1466,7 +1442,7 @@ ${escapedMessage} this.MapPlayers.remove(player); }); - this.MapPlayersByKey = new Map(); + this.MapPlayersByKey.clear(); } private getExitUrl(layer: ITiledMapLayer): string | undefined { @@ -1559,14 +1535,6 @@ ${escapedMessage} this.companion, this.companion !== null ? lazyLoadCompanionResource(this.load, this.companion) : undefined ); - this.CurrentPlayer.once("woka-textures-loaded", () => { - this.savePlayerWokaPicture(this.CurrentPlayer, -1); - }); - this.CurrentPlayer.once("companion-texture-loaded", (snapshotPromise: Promise) => { - snapshotPromise.then((snapshot: string) => { - this.savePlayerCompanionPicture(-1, snapshot); - }); - }); this.CurrentPlayer.on("pointerdown", (pointer: Phaser.Input.Pointer) => { if (pointer.wasTouch && (pointer.event as TouchEvent).touches.length > 1) { return; //we don't want the menu to open when pinching on a touch screen. @@ -1594,15 +1562,6 @@ ${escapedMessage} this.createCollisionWithPlayer(); } - private async savePlayerWokaPicture(character: Character, userId: number): Promise { - const htmlImageElementSrc = await character.getSnapshot(); - this.getUserWokaPictureStore(userId).picture.set(htmlImageElementSrc); - } - - private savePlayerCompanionPicture(userId: number, snapshot: string): void { - this.getUserCompanionPictureStore(userId).picture.set(snapshot); - } - pushPlayerPosition(event: HasPlayerMovedEvent) { if (this.lastMoveEventSent === event) { return; @@ -1790,9 +1749,6 @@ ${escapedMessage} addPlayerData.companion, addPlayerData.companion !== null ? lazyLoadCompanionResource(this.load, addPlayerData.companion) : undefined ); - player.once("woka-textures-loaded", () => { - this.savePlayerWokaPicture(player, addPlayerData.userId); - }); this.MapPlayers.add(player); this.MapPlayersByKey.set(player.userId, player); player.updatePosition(addPlayerData.position); diff --git a/front/src/Stores/PictureStore.ts b/front/src/Stores/PictureStore.ts new file mode 100644 index 00000000..9908c942 --- /dev/null +++ b/front/src/Stores/PictureStore.ts @@ -0,0 +1,6 @@ +import type { Readable } from "svelte/store"; + +/** + * A store that contains the player/companion avatar picture + */ +export type PictureStore = Readable; diff --git a/front/src/Stores/PlayersStore.ts b/front/src/Stores/PlayersStore.ts index e6f5b1af..07c18b96 100644 --- a/front/src/Stores/PlayersStore.ts +++ b/front/src/Stores/PlayersStore.ts @@ -12,7 +12,7 @@ let idCount = 0; function createPlayersStore() { let players = new Map(); - const { subscribe, set, update } = writable(players); + const { subscribe, set, update } = writable>(players); return { subscribe, diff --git a/front/src/Stores/UserCompanionPictureStore.ts b/front/src/Stores/UserCompanionPictureStore.ts deleted file mode 100644 index 5483ca91..00000000 --- a/front/src/Stores/UserCompanionPictureStore.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { writable, Writable } from "svelte/store"; - -/** - * A store that contains the player companion picture - */ -export class UserCompanionPictureStore { - constructor(public picture: Writable = writable(undefined)) {} -} diff --git a/front/src/Stores/UserWokaPictureStore.ts b/front/src/Stores/UserWokaPictureStore.ts deleted file mode 100644 index 8422ae50..00000000 --- a/front/src/Stores/UserWokaPictureStore.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { writable, Writable } from "svelte/store"; - -/** - * A store that contains the player avatar picture - */ -export class UserWokaPictureStore { - constructor(public picture: Writable = writable(undefined)) {} -} diff --git a/front/src/Stores/Utils/MapStore.ts b/front/src/Stores/Utils/MapStore.ts new file mode 100644 index 00000000..63c6c819 --- /dev/null +++ b/front/src/Stores/Utils/MapStore.ts @@ -0,0 +1,122 @@ +import type { Readable, Subscriber, Unsubscriber, Writable } from "svelte/store"; +import { get, readable, writable } from "svelte/store"; + +/** + * Is it a Map? Is it a Store? No! It's a MapStore! + * + * The MapStore behaves just like a regular JS Map, but... it is also a regular Svelte store. + * + * As a bonus, you can also get a store on any given key of the map. + * + * For instance: + * + * const mapStore = new MapStore(); + * mapStore.getStore('foo').subscribe((value) => { + * console.log('Foo key has been written to the store. New value: ', value); + * }); + * mapStore.set('foo', 'bar'); + * + * + * Even better, if the items stored in map contain stores, you can directly get the store to those values: + * + * const mapStore = new MapStore + * }>(); + * + * mapStore.getNestedStore('foo', item => item.nestedStore).subscribe((value) => { + * console.log('Foo key has been written to the store or the nested store has been updated. New value: ', value); + * }); + * mapStore.set('foo', { + * nestedStore: writable('bar') + * }); + * // Whenever the nested store is updated OR the 'foo' key is overwritten, the store returned by mapStore.getNestedStore + * // will be triggered. + */ +export class MapStore extends Map implements Readable> { + private readonly store = writable(this); + private readonly storesByKey = new Map>(); + + subscribe(run: Subscriber>, invalidate?: (value?: Map) => void): Unsubscriber { + return this.store.subscribe(run, invalidate); + } + + clear() { + super.clear(); + this.store.set(this); + this.storesByKey.forEach((store) => { + store.set(undefined); + }); + } + + delete(key: K): boolean { + const result = super.delete(key); + if (result) { + this.store.set(this); + this.storesByKey.get(key)?.set(undefined); + } + return result; + } + + set(key: K, value: V): this { + super.set(key, value); + this.store.set(this); + this.storesByKey.get(key)?.set(value); + return this; + } + + getStore(key: K): Readable { + const store = writable(this.get(key), () => { + return () => { + // No more subscribers! + this.storesByKey.delete(key); + }; + }); + this.storesByKey.set(key, store); + return store; + } + + /** + * Returns an "inner" store inside a value stored in the map. + */ + getNestedStore(key: K, accessor: (value: V) => Readable | undefined): Readable { + const initVal = this.get(key); + let initStore: Readable | undefined; + let initStoreValue: T | undefined; + if (initVal) { + initStore = accessor(initVal); + if (initStore !== undefined) { + initStoreValue = get(initStore); + } + } + + return readable(initStoreValue, (set) => { + const storeByKey = this.getStore(key); + + let unsubscribeDeepStore: Unsubscriber | undefined; + const unsubscribe = storeByKey.subscribe((newMapValue) => { + if (unsubscribeDeepStore) { + unsubscribeDeepStore(); + } + if (newMapValue === undefined) { + set(undefined); + } else { + const deepValueStore = accessor(newMapValue); + if (deepValueStore !== undefined) { + set(get(deepValueStore)); + + unsubscribeDeepStore = deepValueStore.subscribe((value) => { + set(value); + }); + } + } + }); + + return () => { + unsubscribe(); + if (unsubscribeDeepStore) { + unsubscribeDeepStore(); + } + }; + }); + } +} diff --git a/front/tests/Stores/Utils/MapStoreTest.ts b/front/tests/Stores/Utils/MapStoreTest.ts new file mode 100644 index 00000000..dfb4967d --- /dev/null +++ b/front/tests/Stores/Utils/MapStoreTest.ts @@ -0,0 +1,97 @@ +import "jasmine"; +import {MapStore} from "../../../src/Stores/Utils/MapStore"; +import type {Readable, Writable} from "svelte/store"; +import {get, writable} from "svelte/store"; + +describe("Main store", () => { + it("Set / delete / clear triggers main store updates", () => { + const mapStore = new MapStore(); + + let triggered = false; + + mapStore.subscribe((map) => { + triggered = true; + expect(map).toBe(mapStore); + }) + + expect(triggered).toBeTrue(); + triggered = false; + mapStore.set('foo', 'bar'); + expect(triggered).toBeTrue(); + + triggered = false; + mapStore.delete('baz'); + expect(triggered).toBe(false); + mapStore.delete('foo'); + expect(triggered).toBe(true); + + triggered = false; + mapStore.clear(); + expect(triggered).toBe(true); + }); + + it("generates stores for keys with getStore", () => { + + const mapStore = new MapStore(); + + let valueReceivedInStoreForFoo: string|undefined; + let valueReceivedInStoreForBar: string|undefined; + + mapStore.set('foo', 'someValue'); + + mapStore.getStore('foo').subscribe((value) => { + valueReceivedInStoreForFoo = value; + }); + const unsubscribeBar = mapStore.getStore('bar').subscribe((value) => { + valueReceivedInStoreForBar = value; + }); + + expect(valueReceivedInStoreForFoo).toBe('someValue'); + expect(valueReceivedInStoreForBar).toBe(undefined); + mapStore.set('foo', 'someOtherValue'); + expect(valueReceivedInStoreForFoo).toBe('someOtherValue'); + mapStore.delete('foo'); + expect(valueReceivedInStoreForFoo).toBe(undefined); + mapStore.set('bar', 'baz'); + expect(valueReceivedInStoreForBar).toBe('baz'); + mapStore.clear(); + expect(valueReceivedInStoreForBar).toBe(undefined); + unsubscribeBar(); + mapStore.set('bar', 'fiz'); + expect(valueReceivedInStoreForBar).toBe(undefined); + }); + + it("generates stores with getStoreByAccessor", () => { + const mapStore = new MapStore + }>(); + + const fooStore = mapStore.getNestedStore('foo', (value) => { + return value.store; + }); + + mapStore.set('foo', { + foo: 'bar', + store: writable('init') + }); + + expect(get(fooStore)).toBe('init'); + + mapStore.get('foo')?.store.set('newVal'); + + expect(get(fooStore)).toBe('newVal'); + + mapStore.set('foo', { + foo: 'bar', + store: writable('anotherVal') + }); + + expect(get(fooStore)).toBe('anotherVal'); + + mapStore.delete('foo'); + + expect(get(fooStore)).toBeUndefined(); + + }); +});