diff --git a/front/package.json b/front/package.json index 9a17f4a0..86308a89 100644 --- a/front/package.json +++ b/front/package.json @@ -47,6 +47,7 @@ "@types/simple-peer": "^9.11.1", "@types/socket.io-client": "^1.4.32", "axios": "^0.21.2", + "cancelable-promise": "^4.2.1", "cross-env": "^7.0.3", "deep-copy-ts": "^0.5.0", "easystarjs": "^0.4.4", diff --git a/front/src/Components/ActionsMenu/ActionsMenu.svelte b/front/src/Components/ActionsMenu/ActionsMenu.svelte new file mode 100644 index 00000000..d660a570 --- /dev/null +++ b/front/src/Components/ActionsMenu/ActionsMenu.svelte @@ -0,0 +1,99 @@ + + + + +{#if actionsMenuData} +
+ +

{actionsMenuData.playerName}

+
+ {#each [...actionsMenuData.actions] as { actionName, callback }} + + {/each} +
+
+{/if} + + diff --git a/front/src/Components/EmbedScreens/CoWebsiteThumbnailSlot.svelte b/front/src/Components/EmbedScreens/CoWebsiteThumbnailSlot.svelte index afb1179c..e5c3c22e 100644 --- a/front/src/Components/EmbedScreens/CoWebsiteThumbnailSlot.svelte +++ b/front/src/Components/EmbedScreens/CoWebsiteThumbnailSlot.svelte @@ -20,7 +20,7 @@ onMount(() => { icon.src = `${ICON_URL}/icon?url=${urlObject.hostname}&size=64..96..256&fallback_icon_color=14304c`; - icon.alt = urlObject.hostname; + icon.alt = coWebsite.altMessage ?? urlObject.hostname; icon.onload = () => { iconLoaded = true; }; @@ -204,6 +204,10 @@ border-image-outset: 1; } + &:not(.vertical) { + animation: bounce 0.35s ease 6 alternate; + } + &.vertical { margin: 7px; @@ -216,6 +220,8 @@ width: 40px; height: 40px; } + + animation: shake 0.35s ease-in-out; } &.displayed { @@ -259,6 +265,41 @@ } } + @keyframes bounce { + from { + transform: translateY(0); + } + to { + transform: translateY(-15px); + } + } + + @keyframes shake { + 0% { + transform: translateX(0); + } + + 20% { + transform: translateX(-10px); + } + + 40% { + transform: translateX(10px); + } + + 60% { + transform: translateX(-10px); + } + + 80% { + transform: translateX(10px); + } + + 100% { + transform: translateX(0); + } + } + .cowebsite-icon { width: 50px; height: 50px; diff --git a/front/src/Components/FollowMenu/FollowMenu.svelte b/front/src/Components/FollowMenu/FollowMenu.svelte index 0a0f5b68..1dee7bb5 100644 --- a/front/src/Components/FollowMenu/FollowMenu.svelte +++ b/front/src/Components/FollowMenu/FollowMenu.svelte @@ -7,7 +7,7 @@ function name(userId: number): string { const user = gameScene.MapPlayersByKey.get(userId); - return user ? user.PlayerValue : ""; + return user ? user.playerName : ""; } function acceptFollowRequest() { diff --git a/front/src/Components/MainLayout.svelte b/front/src/Components/MainLayout.svelte index cf273e50..54fa9d3b 100644 --- a/front/src/Components/MainLayout.svelte +++ b/front/src/Components/MainLayout.svelte @@ -36,6 +36,8 @@ import LimitRoomModal from "./Modal/LimitRoomModal.svelte"; import ShareLinkMapModal from "./Modal/ShareLinkMapModal.svelte"; import { LayoutMode } from "../WebRtc/LayoutManager"; + import { actionsMenuStore } from "../Stores/ActionsMenuStore"; + import ActionsMenu from "./ActionsMenu/ActionsMenu.svelte"; let mainLayout: HTMLDivElement; @@ -106,6 +108,10 @@ {/if} + {#if $actionsMenuStore} + + {/if} + {#if $requestVisitCardsStore} {/if} diff --git a/front/src/Components/Video/MediaBox.svelte b/front/src/Components/Video/MediaBox.svelte index 2abfa953..ff3c81f5 100644 --- a/front/src/Components/Video/MediaBox.svelte +++ b/front/src/Components/Video/MediaBox.svelte @@ -44,7 +44,7 @@ pointer-events: auto; padding: 0; - max-height: 85%; + max-height: 200px; max-width: 85%; &:hover { diff --git a/front/src/Phaser/Entity/Character.ts b/front/src/Phaser/Entity/Character.ts index c6fd0c4a..fd264f03 100644 --- a/front/src/Phaser/Entity/Character.ts +++ b/front/src/Phaser/Entity/Character.ts @@ -15,6 +15,8 @@ import { TexturesHelper } from "../Helpers/TexturesHelper"; import type { PictureStore } from "../../Stores/PictureStore"; import { Unsubscriber, Writable, writable } from "svelte/store"; import { createColorStore } from "../../Stores/OutlineColorStore"; +import type { OutlineableInterface } from "../Game/OutlineableInterface"; +import type CancelablePromise from "cancelable-promise"; const playerNameY = -25; @@ -28,15 +30,16 @@ interface AnimationData { const interactiveRadius = 35; -export abstract class Character extends Container { +export abstract class Character extends Container implements OutlineableInterface { private bubble: SpeechBubble | null = null; - private readonly playerName: Text; + private readonly playerNameText: Text; private readonly iconTalk: Phaser.GameObjects.Image; - public PlayerValue: string; + public playerName: string; public sprites: Map; protected lastDirection: PlayerAnimationDirections = PlayerAnimationDirections.Down; //private teleportation: Sprite; private invisible: boolean; + private clickable: boolean; public companion?: Companion; private emote: Phaser.GameObjects.DOMElement | null = null; private emoteTween: Phaser.Tweens.Tween | null = null; @@ -44,12 +47,13 @@ export abstract class Character extends Container { private readonly _pictureStore: Writable; private readonly outlineColorStore = createColorStore(); private readonly outlineColorStoreUnsubscribe: Unsubscriber; + private texturePromise: CancelablePromise | undefined; constructor( scene: GameScene, x: number, y: number, - texturesPromise: Promise, + texturesPromise: CancelablePromise, name: string, direction: PlayerAnimationDirections, moving: boolean, @@ -60,14 +64,15 @@ export abstract class Character extends Container { ) { super(scene, x, y /*, texture, frame*/); this.scene = scene; - this.PlayerValue = name; + this.playerName = name; this.invisible = true; + this.clickable = false; this.sprites = new Map(); this._pictureStore = writable(undefined); //textures are inside a Promise in case they need to be lazyloaded before use. - texturesPromise + this.texturePromise = texturesPromise .then((textures) => { this.addTextures(textures, frame); this.invisible = false; @@ -82,9 +87,12 @@ export abstract class Character extends Container { this.invisible = false; this.playAnimation(direction, moving); }); + }) + .finally(() => { + this.texturePromise = undefined; }); - this.playerName = new Text(scene, 0, playerNameY, name, { + this.playerNameText = new Text(scene, 0, playerNameY, name, { fontFamily: '"Press Start 2P"', fontSize: "8px", strokeThickness: 2, @@ -95,8 +103,6 @@ export abstract class Character extends Container { fontSize: 35, }, }); - this.playerName.setOrigin(0.5).setDepth(DEPTH_INGAME_TEXT_INDEX); - this.add(this.playerName); this.iconTalk = new Phaser.GameObjects.Image(scene, 0, -45, 'iconTalk') .setScale(0.15) @@ -109,21 +115,18 @@ export abstract class Character extends Container { hitAreaCallback: Phaser.Geom.Circle.Contains, //eslint-disable-line @typescript-eslint/unbound-method useHandCursor: true, }); - - this.on("pointerover", () => { - this.outlineColorStore.pointerOver(); - }); - this.on("pointerout", () => { - this.outlineColorStore.pointerOut(); - }); } + this.playerNameText.setOrigin(0.5).setDepth(DEPTH_INGAME_TEXT_INDEX); + this.add(this.playerNameText); + + this.setClickable(isClickable); this.outlineColorStoreUnsubscribe = this.outlineColorStore.subscribe((color) => { if (color === undefined) { - this.getOutlinePlugin()?.remove(this.playerName); + this.getOutlinePlugin()?.remove(this.playerNameText); } else { - this.getOutlinePlugin()?.remove(this.playerName); - this.getOutlinePlugin()?.add(this.playerName, { + this.getOutlinePlugin()?.remove(this.playerNameText); + this.getOutlinePlugin()?.add(this.playerNameText, { thickness: 2, outlineColor: color, }); @@ -146,6 +149,34 @@ export abstract class Character extends Container { } } + public setClickable(clickable: boolean = true): void { + if (this.clickable === clickable) { + return; + } + this.clickable = clickable; + if (clickable) { + this.setInteractive({ + hitArea: new Phaser.Geom.Circle(0, 0, interactiveRadius), + hitAreaCallback: Phaser.Geom.Circle.Contains, //eslint-disable-line @typescript-eslint/unbound-method + useHandCursor: true, + }); + return; + } + this.disableInteractive(); + } + + public isClickable() { + return this.clickable; + } + + public getPosition(): { x: number; y: number } { + return { x: this.x, y: this.y }; + } + + public getObjectToOutline(): Phaser.GameObjects.GameObject { + return this.playerNameText; + } + private async getSnapshot(): Promise { const sprites = Array.from(this.sprites.values()).map((sprite) => { return { sprite, frame: 1 }; @@ -336,6 +367,7 @@ export abstract class Character extends Container { this.scene.sys.updateList.remove(sprite); } } + this.texturePromise?.cancel(); this.list.forEach((objectContaining) => objectContaining.destroy()); this.outlineColorStoreUnsubscribe(); super.destroy(); @@ -418,18 +450,42 @@ export abstract class Character extends Container { private destroyEmote() { this.emote?.destroy(); this.emote = null; - this.playerName.setVisible(true); + this.playerNameText.setVisible(true); } public get pictureStore(): PictureStore { return this._pictureStore; } - public setOutlineColor(color: number): void { - this.outlineColorStore.setColor(color); + public setFollowOutlineColor(color: number): void { + this.outlineColorStore.setFollowColor(color); } - public removeOutlineColor(): void { - this.outlineColorStore.removeColor(); + public removeFollowOutlineColor(): void { + this.outlineColorStore.removeFollowColor(); + } + + public setApiOutlineColor(color: number): void { + this.outlineColorStore.setApiColor(color); + } + + public removeApiOutlineColor(): void { + this.outlineColorStore.removeApiColor(); + } + + public pointerOverOutline(): void { + this.outlineColorStore.pointerOver(); + } + + public pointerOutOutline(): void { + this.outlineColorStore.pointerOut(); + } + + public characterCloseByOutline(): void { + this.outlineColorStore.characterCloseBy(); + } + + public characterFarAwayOutline(): void { + this.outlineColorStore.characterFarAway(); } } diff --git a/front/src/Phaser/Entity/PlayerTexturesLoadingManager.ts b/front/src/Phaser/Entity/PlayerTexturesLoadingManager.ts index 58fcf84c..40e68427 100644 --- a/front/src/Phaser/Entity/PlayerTexturesLoadingManager.ts +++ b/front/src/Phaser/Entity/PlayerTexturesLoadingManager.ts @@ -1,6 +1,7 @@ import LoaderPlugin = Phaser.Loader.LoaderPlugin; import type { CharacterTexture } from "../../Connexion/LocalUser"; import { BodyResourceDescriptionInterface, LAYERS, PLAYER_RESOURCES } from "./PlayerTextures"; +import CancelablePromise from "cancelable-promise"; export interface FrameConfig { frameWidth: number; @@ -30,7 +31,7 @@ export const loadAllDefaultModels = (load: LoaderPlugin): BodyResourceDescriptio export const loadCustomTexture = ( loaderPlugin: LoaderPlugin, texture: CharacterTexture -): Promise => { +): CancelablePromise => { const name = "customCharacterTexture" + texture.id; const playerResourceDescriptor: BodyResourceDescriptionInterface = { name, img: texture.url, level: texture.level }; return createLoadingPromise(loaderPlugin, playerResourceDescriptor, { @@ -42,8 +43,8 @@ export const loadCustomTexture = ( export const lazyLoadPlayerCharacterTextures = ( loadPlugin: LoaderPlugin, texturekeys: Array -): Promise => { - const promisesList: Promise[] = []; +): CancelablePromise => { + const promisesList: CancelablePromise[] = []; texturekeys.forEach((textureKey: string | BodyResourceDescriptionInterface) => { try { //TODO refactor @@ -60,12 +61,12 @@ export const lazyLoadPlayerCharacterTextures = ( console.error(err); } }); - let returnPromise: Promise>; + let returnPromise: CancelablePromise>; if (promisesList.length > 0) { loadPlugin.start(); - returnPromise = Promise.all(promisesList).then(() => texturekeys); + returnPromise = CancelablePromise.all(promisesList).then(() => texturekeys); } else { - returnPromise = Promise.resolve(texturekeys); + returnPromise = CancelablePromise.resolve(texturekeys); } //If the loading fail, we render the default model instead. @@ -98,10 +99,17 @@ export const createLoadingPromise = ( playerResourceDescriptor: BodyResourceDescriptionInterface, frameConfig: FrameConfig ) => { - return new Promise((res, rej) => { + return new CancelablePromise((res, rej, cancel) => { if (loadPlugin.textureManager.exists(playerResourceDescriptor.name)) { return res(playerResourceDescriptor); } + + cancel(() => { + loadPlugin.off("loaderror"); + loadPlugin.off("filecomplete-spritesheet-" + playerResourceDescriptor.name); + return; + }); + loadPlugin.spritesheet(playerResourceDescriptor.name, playerResourceDescriptor.img, frameConfig); const errorCallback = (file: { src: string }) => { if (file.src !== playerResourceDescriptor.img) return; diff --git a/front/src/Phaser/Entity/RemotePlayer.ts b/front/src/Phaser/Entity/RemotePlayer.ts index 1cd03d12..df496896 100644 --- a/front/src/Phaser/Entity/RemotePlayer.ts +++ b/front/src/Phaser/Entity/RemotePlayer.ts @@ -1,15 +1,24 @@ +import { requestVisitCardsStore } from "../../Stores/GameStore"; +import { ActionsMenuData, actionsMenuStore } from "../../Stores/ActionsMenuStore"; +import { Character } from "../Entity/Character"; import type { GameScene } from "../Game/GameScene"; import type { PointInterface } from "../../Connexion/ConnexionModels"; -import { Character } from "../Entity/Character"; import type { PlayerAnimationDirections } from "../Player/Animation"; -import { requestVisitCardsStore } from "../../Stores/GameStore"; +import type { Unsubscriber } from "svelte/store"; +import type { ActivatableInterface } from "../Game/ActivatableInterface"; +import type CancelablePromise from "cancelable-promise"; /** * Class representing the sprite of a remote player (a player that plays on another computer) */ -export class RemotePlayer extends Character { - userId: number; +export class RemotePlayer extends Character implements ActivatableInterface { + public userId: number; + public readonly activationRadius: number; + + private registeredActions: { actionName: string; callback: Function }[]; private visitCardUrl: string | null; + private isActionsMenuInitialized: boolean = false; + private actionsMenuStoreUnsubscriber: Unsubscriber; constructor( userId: number, @@ -17,39 +26,31 @@ export class RemotePlayer extends Character { x: number, y: number, name: string, - texturesPromise: Promise, + texturesPromise: CancelablePromise, direction: PlayerAnimationDirections, moving: boolean, visitCardUrl: string | null, companion: string | null, - companionTexturePromise?: Promise + companionTexturePromise?: Promise, + activationRadius?: number ) { - super( - Scene, - x, - y, - texturesPromise, - name, - direction, - moving, - 1, - !!visitCardUrl, - companion, - companionTexturePromise - ); + super(Scene, x, y, texturesPromise, name, direction, moving, 1, true, companion, companionTexturePromise); //set data this.userId = userId; + this.registeredActions = []; + this.registerDefaultActionsMenuActions(); + this.setClickable(this.registeredActions.length > 0); + this.activationRadius = activationRadius ?? 96; this.visitCardUrl = visitCardUrl; - - this.on("pointerdown", (event: Phaser.Input.Pointer) => { - if (event.downElement.nodeName === "CANVAS") { - requestVisitCardsStore.set(this.visitCardUrl); - } + this.actionsMenuStoreUnsubscriber = actionsMenuStore.subscribe((value: ActionsMenuData | undefined) => { + this.isActionsMenuInitialized = value ? true : false; }); + + this.bindEventHandlers(); } - updatePosition(position: PointInterface): void { + public updatePosition(position: PointInterface): void { this.playAnimation(position.direction as PlayerAnimationDirections, position.moving); this.setX(position.x); this.setY(position.y); @@ -60,4 +61,66 @@ export class RemotePlayer extends Character { this.companion.setTarget(position.x, position.y, position.direction as PlayerAnimationDirections); } } + + public registerActionsMenuAction(action: { actionName: string; callback: Function }): void { + this.registeredActions.push(action); + this.updateIsClickable(); + } + + public unregisterActionsMenuAction(actionName: string) { + const index = this.registeredActions.findIndex((action) => action.actionName === actionName); + if (index !== -1) { + this.registeredActions.splice(index, 1); + } + this.updateIsClickable(); + } + + public activate(): void { + this.toggleActionsMenu(); + } + + public destroy(): void { + this.actionsMenuStoreUnsubscriber(); + actionsMenuStore.clear(); + super.destroy(); + } + + public isActivatable(): boolean { + return this.isClickable(); + } + + private updateIsClickable(): void { + this.setClickable(this.registeredActions.length > 0); + } + + private toggleActionsMenu(): void { + if (this.isActionsMenuInitialized) { + actionsMenuStore.clear(); + return; + } + actionsMenuStore.initialize(this.playerName); + for (const action of this.registeredActions) { + actionsMenuStore.addAction(action.actionName, action.callback); + } + } + + private registerDefaultActionsMenuActions(): void { + if (this.visitCardUrl) { + this.registeredActions.push({ + actionName: "Visiting Card", + callback: () => { + requestVisitCardsStore.set(this.visitCardUrl); + actionsMenuStore.clear(); + }, + }); + } + } + + private bindEventHandlers(): void { + this.on(Phaser.Input.Events.POINTER_DOWN, (event: Phaser.Input.Pointer) => { + if (event.downElement.nodeName === "CANVAS") { + this.toggleActionsMenu(); + } + }); + } } diff --git a/front/src/Phaser/Game/ActivatableInterface.ts b/front/src/Phaser/Game/ActivatableInterface.ts new file mode 100644 index 00000000..809a09b6 --- /dev/null +++ b/front/src/Phaser/Game/ActivatableInterface.ts @@ -0,0 +1,6 @@ +export interface ActivatableInterface { + readonly activationRadius: number; + isActivatable: () => boolean; + activate: () => void; + getPosition: () => { x: number; y: number }; +} diff --git a/front/src/Phaser/Game/ActivatablesManager.ts b/front/src/Phaser/Game/ActivatablesManager.ts new file mode 100644 index 00000000..60e967d9 --- /dev/null +++ b/front/src/Phaser/Game/ActivatablesManager.ts @@ -0,0 +1,93 @@ +import { isOutlineable } from "../../Utils/CustomTypeGuards"; +import { MathUtils } from "../../Utils/MathUtils"; +import type { Player } from "../Player/Player"; +import type { ActivatableInterface } from "./ActivatableInterface"; + +export class ActivatablesManager { + // The item that can be selected by pressing the space key. + private selectedActivatableObjectByDistance?: ActivatableInterface; + private selectedActivatableObjectByPointer?: ActivatableInterface; + private activatableObjectsDistances: Map = new Map(); + + private currentPlayer: Player; + + constructor(currentPlayer: Player) { + this.currentPlayer = currentPlayer; + } + + public handlePointerOverActivatableObject(object: ActivatableInterface): void { + if (this.selectedActivatableObjectByPointer === object) { + return; + } + if (isOutlineable(this.selectedActivatableObjectByDistance)) { + this.selectedActivatableObjectByDistance?.characterFarAwayOutline(); + } + if (isOutlineable(this.selectedActivatableObjectByPointer)) { + this.selectedActivatableObjectByPointer?.pointerOutOutline(); + } + this.selectedActivatableObjectByPointer = object; + if (isOutlineable(this.selectedActivatableObjectByPointer)) { + this.selectedActivatableObjectByPointer?.pointerOverOutline(); + } + } + + public handlePointerOutActivatableObject(): void { + if (isOutlineable(this.selectedActivatableObjectByPointer)) { + this.selectedActivatableObjectByPointer?.pointerOutOutline(); + } + this.selectedActivatableObjectByPointer = undefined; + if (isOutlineable(this.selectedActivatableObjectByDistance)) { + this.selectedActivatableObjectByDistance?.characterCloseByOutline(); + } + } + + public getSelectedActivatableObject(): ActivatableInterface | undefined { + return this.selectedActivatableObjectByPointer ?? this.selectedActivatableObjectByDistance; + } + + public deduceSelectedActivatableObjectByDistance(): void { + const newNearestObject = this.findNearestActivatableObject(); + if (this.selectedActivatableObjectByDistance === newNearestObject) { + return; + } + // update value but do not change the outline + if (this.selectedActivatableObjectByPointer) { + this.selectedActivatableObjectByDistance = newNearestObject; + return; + } + if (isOutlineable(this.selectedActivatableObjectByDistance)) { + this.selectedActivatableObjectByDistance?.characterFarAwayOutline(); + } + this.selectedActivatableObjectByDistance = newNearestObject; + if (isOutlineable(this.selectedActivatableObjectByDistance)) { + this.selectedActivatableObjectByDistance?.characterCloseByOutline(); + } + } + + private findNearestActivatableObject(): ActivatableInterface | undefined { + let shortestDistance: number = Infinity; + let closestObject: ActivatableInterface | undefined = undefined; + + for (const [object, distance] of this.activatableObjectsDistances.entries()) { + if (object.isActivatable() && object.activationRadius > distance && shortestDistance > distance) { + shortestDistance = distance; + closestObject = object; + } + } + return closestObject; + } + public updateActivatableObjectsDistances(objects: ActivatableInterface[]): void { + const currentPlayerPos = this.currentPlayer.getPosition(); + for (const object of objects) { + const distance = MathUtils.distanceBetween(currentPlayerPos, object.getPosition()); + this.activatableObjectsDistances.set(object, distance); + } + } + + public updateDistanceForSingleActivatableObject(object: ActivatableInterface): void { + this.activatableObjectsDistances.set( + object, + MathUtils.distanceBetween(this.currentPlayer.getPosition(), object.getPosition()) + ); + } +} diff --git a/front/src/Phaser/Game/GameMapPropertiesListener.ts b/front/src/Phaser/Game/GameMapPropertiesListener.ts index fec982d1..caa83cb0 100644 --- a/front/src/Phaser/Game/GameMapPropertiesListener.ts +++ b/front/src/Phaser/Game/GameMapPropertiesListener.ts @@ -66,6 +66,7 @@ export class GameMapPropertiesListener { let websitePolicyProperty: string | undefined; let websitePositionProperty: number | undefined; let websiteTriggerProperty: string | undefined; + let websiteTriggerMessageProperty: string | undefined; layer.properties.forEach((property) => { switch (property.name) { @@ -84,6 +85,9 @@ export class GameMapPropertiesListener { case GameMapProperties.OPEN_WEBSITE_TRIGGER: websiteTriggerProperty = property.value as string | undefined; break; + case GameMapProperties.OPEN_WEBSITE_TRIGGER_MESSAGE: + websiteTriggerMessageProperty = property.value as string | undefined; + break; } }); diff --git a/front/src/Phaser/Game/GameScene.ts b/front/src/Phaser/Game/GameScene.ts index 215797bb..39f25f9f 100644 --- a/front/src/Phaser/Game/GameScene.ts +++ b/front/src/Phaser/Game/GameScene.ts @@ -12,7 +12,7 @@ import { UserInputManager } from "../UserInput/UserInputManager"; import { gameManager } from "./GameManager"; import { touchScreenManager } from "../../Touch/TouchScreenManager"; import { PinchManager } from "../UserInput/PinchManager"; -import { waScaleManager, WaScaleManagerEvent } from "../Services/WaScaleManager"; +import { waScaleManager } from "../Services/WaScaleManager"; import { EmoteManager } from "./EmoteManager"; import { soundManager } from "./SoundManager"; import { SharedVariablesManager } from "./SharedVariablesManager"; @@ -49,6 +49,7 @@ import { GameMapPropertiesListener } from "./GameMapPropertiesListener"; import { analyticsClient } from "../../Administration/AnalyticsClient"; import { GameMapProperties } from "./GameMapProperties"; import { PathfindingManager } from "../../Utils/PathfindingManager"; +import { ActivatablesManager } from "./ActivatablesManager"; import type { GroupCreatedUpdatedMessageInterface, MessageUserMovedInterface, @@ -89,10 +90,8 @@ import { deepCopy } from "deep-copy-ts"; import FILE_LOAD_ERROR = Phaser.Loader.Events.FILE_LOAD_ERROR; import { MapStore } from "../../Stores/Utils/MapStore"; import { followUsersColorStore } from "../../Stores/FollowStore"; -import Camera = Phaser.Cameras.Scene2D.Camera; import { GameSceneUserInputHandler } from "../UserInput/GameSceneUserInputHandler"; import { locale } from "../../i18n/i18n-svelte"; - export interface GameSceneInitInterface { initPosition: PointInterface | null; reconnecting: boolean; @@ -189,8 +188,6 @@ export class GameScene extends DirtyScene { private gameMap!: GameMap; private actionableItems: Map = new Map(); - // The item that can be selected by pressing the space key. - private outlinedItem: ActionableItem | null = null; public userInputManager!: UserInputManager; private isReconnecting: boolean | undefined = undefined; private playerName!: string; @@ -204,6 +201,7 @@ export class GameScene extends DirtyScene { private emoteManager!: EmoteManager; private cameraManager!: CameraManager; private pathfindingManager!: PathfindingManager; + private activatablesManager!: ActivatablesManager; private preloading: boolean = true; private startPositionCalculator!: StartPositionCalculator; private sharedVariablesManager!: SharedVariablesManager; @@ -577,6 +575,14 @@ export class GameScene extends DirtyScene { waScaleManager ); + this.pathfindingManager = new PathfindingManager( + this, + this.gameMap.getCollisionsGrid(), + this.gameMap.getTileDimensions() + ); + + this.activatablesManager = new ActivatablesManager(this.CurrentPlayer); + biggestAvailableAreaStore.recompute(); this.cameraManager.startFollowPlayer(this.CurrentPlayer); @@ -659,10 +665,10 @@ export class GameScene extends DirtyScene { this.followUsersColorStoreUnsubscribe = followUsersColorStore.subscribe((color) => { if (color !== undefined) { - this.CurrentPlayer.setOutlineColor(color); + this.CurrentPlayer.setFollowOutlineColor(color); this.connection?.emitPlayerOutlineColor(color); } else { - this.CurrentPlayer.removeOutlineColor(); + this.CurrentPlayer.removeFollowOutlineColor(); this.connection?.emitPlayerOutlineColor(null); } }); @@ -679,10 +685,6 @@ export class GameScene extends DirtyScene { ); } - public activateOutlinedItem(): void { - this.outlinedItem?.activate(); - } - /** * Initializes the connection to Pusher. */ @@ -805,11 +807,8 @@ export class GameScene extends DirtyScene { this.simplePeer = new SimplePeer(this.connection); userMessageManager.setReceiveBanListener(this.bannedUser.bind(this)); - //listen event to share position of user - this.CurrentPlayer.on(hasMovedEventName, this.pushPlayerPosition.bind(this)); - this.CurrentPlayer.on(hasMovedEventName, this.outlineItem.bind(this)); this.CurrentPlayer.on(hasMovedEventName, (event: HasPlayerMovedEvent) => { - this.gameMap.setPosition(event.x, event.y); + this.handleCurrentPlayerHasMovedEvent(event); }); // Set up variables manager @@ -1272,7 +1271,7 @@ ${escapedMessage} openCoWebsite.closable ?? true ); - if (openCoWebsite.lazy !== undefined && !openCoWebsite.lazy) { + if (openCoWebsite.lazy === undefined || !openCoWebsite.lazy) { await coWebsiteManager.loadCoWebsite(coWebsite); } @@ -1445,12 +1444,12 @@ ${escapedMessage} const green = normalizeColor(message.green); const blue = normalizeColor(message.blue); const color = (red << 16) | (green << 8) | blue; - this.CurrentPlayer.setOutlineColor(color); + this.CurrentPlayer.setApiOutlineColor(color); this.connection?.emitPlayerOutlineColor(color); }); iframeListener.registerAnswerer("removePlayerOutline", (message) => { - this.CurrentPlayer.removeOutlineColor(); + this.CurrentPlayer.removeApiOutlineColor(); this.connection?.emitPlayerOutlineColor(null); }); @@ -1683,7 +1682,18 @@ ${escapedMessage} } } - createCollisionWithPlayer() { + private handleCurrentPlayerHasMovedEvent(event: HasPlayerMovedEvent): void { + //listen event to share position of user + this.pushPlayerPosition(event); + this.gameMap.setPosition(event.x, event.y); + this.activatablesManager.updateActivatableObjectsDistances([ + ...Array.from(this.MapPlayersByKey.values()), + ...this.actionableItems.values(), + ]); + this.activatablesManager.deduceSelectedActivatableObjectByDistance(); + } + + private createCollisionWithPlayer() { //add collision layer for (const phaserLayer of this.gameMap.phaserLayers) { this.physics.add.collider(this.CurrentPlayer, phaserLayer, (object1: GameObject, object2: GameObject) => { @@ -1702,7 +1712,7 @@ ${escapedMessage} } } - createCurrentPlayer() { + private createCurrentPlayer() { //TODO create animation moving between exit and start const texturesPromise = lazyLoadPlayerCharacterTextures(this.load, this.characterLayers); try { @@ -1717,7 +1727,7 @@ ${escapedMessage} this.companion, this.companion !== null ? lazyLoadCompanionResource(this.load, this.companion) : undefined ); - this.CurrentPlayer.on("pointerdown", (pointer: Phaser.Input.Pointer) => { + this.CurrentPlayer.on(Phaser.Input.Events.POINTER_DOWN, (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. } @@ -1760,7 +1770,7 @@ ${escapedMessage} this.createCollisionWithPlayer(); } - pushPlayerPosition(event: HasPlayerMovedEvent) { + private pushPlayerPosition(event: HasPlayerMovedEvent) { if (this.lastMoveEventSent === event) { return; } @@ -1786,49 +1796,6 @@ ${escapedMessage} // Otherwise, do nothing. } - /** - * Finds the correct item to outline and outline it (if there is an item to be outlined) - * @param event - */ - private outlineItem(event: HasPlayerMovedEvent): void { - let x = event.x; - let y = event.y; - switch (event.direction) { - case PlayerAnimationDirections.Up: - y -= 32; - break; - case PlayerAnimationDirections.Down: - y += 32; - break; - case PlayerAnimationDirections.Left: - x -= 32; - break; - case PlayerAnimationDirections.Right: - x += 32; - break; - default: - throw new Error('Unexpected direction "' + event.direction + '"'); - } - - let shortestDistance: number = Infinity; - let selectedItem: ActionableItem | null = null; - for (const item of this.actionableItems.values()) { - const distance = item.actionableDistance(x, y); - if (distance !== null && distance < shortestDistance) { - shortestDistance = distance; - selectedItem = item; - } - } - - if (this.outlinedItem === selectedItem) { - return; - } - - this.outlinedItem?.notSelectable(); - this.outlinedItem = selectedItem; - this.outlinedItem?.selectable(); - } - private doPushPlayerPosition(event: HasPlayerMovedEvent): void { this.lastMoveEventSent = event; this.lastSentTick = this.currentTick; @@ -1846,7 +1813,7 @@ ${escapedMessage} * @param time * @param delta The delta time in ms since the last frame. This is a smoothed and capped value based on the FPS rate. */ - update(time: number, delta: number): void { + public update(time: number, delta: number): void { this.dirty = false; this.currentTick = time; this.CurrentPlayer.moveUser(delta, this.userInputManager.getEventListForGameTick()); @@ -1865,9 +1832,15 @@ ${escapedMessage} case "RemovePlayerEvent": this.doRemovePlayer(event.userId); break; - case "UserMovedEvent": + case "UserMovedEvent": { this.doUpdatePlayerPosition(event.event); + const remotePlayer = this.MapPlayersByKey.get(event.event.userId); + if (remotePlayer) { + this.activatablesManager.updateDistanceForSingleActivatableObject(remotePlayer); + this.activatablesManager.deduceSelectedActivatableObjectByDistance(); + } break; + } case "GroupCreatedUpdatedEvent": this.doShareGroupPosition(event.event); break; @@ -1954,11 +1927,21 @@ ${escapedMessage} addPlayerData.companion !== null ? lazyLoadCompanionResource(this.load, addPlayerData.companion) : undefined ); if (addPlayerData.outlineColor !== undefined) { - player.setOutlineColor(addPlayerData.outlineColor); + player.setApiOutlineColor(addPlayerData.outlineColor); } this.MapPlayers.add(player); this.MapPlayersByKey.set(player.userId, player); player.updatePosition(addPlayerData.position); + + player.on(Phaser.Input.Events.POINTER_OVER, () => { + this.activatablesManager.handlePointerOverActivatableObject(player); + this.markDirty(); + }); + + player.on(Phaser.Input.Events.POINTER_OUT, () => { + this.activatablesManager.handlePointerOutActivatableObject(); + this.markDirty(); + }); } /** @@ -1988,7 +1971,7 @@ ${escapedMessage} this.playersPositionInterpolator.removePlayer(userId); } - public updatePlayerPosition(message: MessageUserMovedInterface): void { + private updatePlayerPosition(message: MessageUserMovedInterface): void { this.pendingEvents.enqueue({ type: "UserMovedEvent", event: message, @@ -2018,7 +2001,7 @@ ${escapedMessage} this.playersPositionInterpolator.updatePlayerPosition(player.userId, playerMovement); } - public shareGroupPosition(groupPositionMessage: GroupCreatedUpdatedMessageInterface) { + private shareGroupPosition(groupPositionMessage: GroupCreatedUpdatedMessageInterface) { this.pendingEvents.enqueue({ type: "GroupCreatedUpdatedEvent", event: groupPositionMessage, @@ -2070,9 +2053,9 @@ ${escapedMessage} return; } if (message.removeOutlineColor) { - character.removeOutlineColor(); + character.removeApiOutlineColor(); } else { - character.setOutlineColor(message.outlineColor); + character.setApiOutlineColor(message.outlineColor); } } @@ -2229,4 +2212,8 @@ ${escapedMessage} public getPathfindingManager(): PathfindingManager { return this.pathfindingManager; } + + public getActivatablesManager(): ActivatablesManager { + return this.activatablesManager; + } } diff --git a/front/src/Phaser/Game/OutlineableInterface.ts b/front/src/Phaser/Game/OutlineableInterface.ts new file mode 100644 index 00000000..bee560cc --- /dev/null +++ b/front/src/Phaser/Game/OutlineableInterface.ts @@ -0,0 +1,10 @@ +export interface OutlineableInterface { + setFollowOutlineColor(color: number): void; + removeFollowOutlineColor(): void; + setApiOutlineColor(color: number): void; + removeApiOutlineColor(): void; + pointerOverOutline(): void; + pointerOutOutline(): void; + characterCloseByOutline(): void; + characterFarAwayOutline(): void; +} diff --git a/front/src/Phaser/Items/ActionableItem.ts b/front/src/Phaser/Items/ActionableItem.ts index 44b633ed..ff85e232 100644 --- a/front/src/Phaser/Items/ActionableItem.ts +++ b/front/src/Phaser/Items/ActionableItem.ts @@ -5,10 +5,11 @@ import Sprite = Phaser.GameObjects.Sprite; import type { GameScene } from "../Game/GameScene"; import type OutlinePipelinePlugin from "phaser3-rex-plugins/plugins/outlinepipeline-plugin.js"; +import type { ActivatableInterface } from "../Game/ActivatableInterface"; type EventCallback = (state: unknown, parameters: unknown) => void; -export class ActionableItem { +export class ActionableItem implements ActivatableInterface { private readonly activationRadiusSquared: number; private isSelectable: boolean = false; private callbacks: Map> = new Map>(); @@ -17,7 +18,7 @@ export class ActionableItem { private id: number, private sprite: Sprite, private eventHandler: GameScene, - private activationRadius: number, + public readonly activationRadius: number, private onActivateCallback: (item: ActionableItem) => void ) { this.activationRadiusSquared = activationRadius * activationRadius; @@ -40,6 +41,10 @@ export class ActionableItem { } } + public getPosition(): { x: number; y: number } { + return { x: this.sprite.x, y: this.sprite.y }; + } + /** * Show the outline of the sprite. */ @@ -70,9 +75,10 @@ export class ActionableItem { return this.sprite.scene.plugins.get("rexOutlinePipeline") as unknown as OutlinePipelinePlugin | undefined; } - /** - * Triggered when the "space" key is pressed and the object is in range of being activated. - */ + public isActivatable(): boolean { + return this.isSelectable; + } + public activate(): void { this.onActivateCallback(this); } diff --git a/front/src/Phaser/Login/AbstractCharacterScene.ts b/front/src/Phaser/Login/AbstractCharacterScene.ts index 6376498a..67e2ba3d 100644 --- a/front/src/Phaser/Login/AbstractCharacterScene.ts +++ b/front/src/Phaser/Login/AbstractCharacterScene.ts @@ -3,11 +3,12 @@ import { localUserStore } from "../../Connexion/LocalUserStore"; import type { BodyResourceDescriptionInterface } from "../Entity/PlayerTextures"; import { loadCustomTexture } from "../Entity/PlayerTexturesLoadingManager"; import type { CharacterTexture } from "../../Connexion/LocalUser"; +import type CancelablePromise from "cancelable-promise"; export abstract class AbstractCharacterScene extends ResizableScene { loadCustomSceneSelectCharacters(): Promise { const textures = this.getTextures(); - const promises: Promise[] = []; + const promises: CancelablePromise[] = []; if (textures) { for (const texture of textures) { if (texture.level === -1) { @@ -21,7 +22,7 @@ export abstract class AbstractCharacterScene extends ResizableScene { loadSelectSceneCharacters(): Promise { const textures = this.getTextures(); - const promises: Promise[] = []; + const promises: CancelablePromise[] = []; if (textures) { for (const texture of textures) { if (texture.level !== -1) { diff --git a/front/src/Phaser/Login/CustomizeScene.ts b/front/src/Phaser/Login/CustomizeScene.ts index 838f789d..e95eab60 100644 --- a/front/src/Phaser/Login/CustomizeScene.ts +++ b/front/src/Phaser/Login/CustomizeScene.ts @@ -289,7 +289,6 @@ export class CustomizeScene extends AbstractCharacterScene { gameManager.setCharacterLayers(layers); this.scene.sleep(CustomizeSceneName); waScaleManager.restoreZoom(); - this.events.removeListener("wake"); gameManager.tryResumingGame(EnableCameraSceneName); customCharacterSceneVisibleStore.set(false); } diff --git a/front/src/Phaser/Player/Player.ts b/front/src/Phaser/Player/Player.ts index c5134f1c..a6fe29a1 100644 --- a/front/src/Phaser/Player/Player.ts +++ b/front/src/Phaser/Player/Player.ts @@ -6,6 +6,7 @@ import { Character } from "../Entity/Character"; import { get } from "svelte/store"; import { userMovingStore } from "../../Stores/GameStore"; import { followStateStore, followRoleStore, followUsersStore } from "../../Stores/FollowStore"; +import type CancelablePromise from "cancelable-promise"; export const hasMovedEventName = "hasMoved"; export const requestEmoteEventName = "requestEmote"; @@ -20,7 +21,7 @@ export class Player extends Character { x: number, y: number, name: string, - texturesPromise: Promise, + texturesPromise: CancelablePromise, direction: PlayerAnimationDirections, moving: boolean, companion: string | null, diff --git a/front/src/Phaser/Services/WaScaleManager.ts b/front/src/Phaser/Services/WaScaleManager.ts index 9334d78a..7958e79d 100644 --- a/front/src/Phaser/Services/WaScaleManager.ts +++ b/front/src/Phaser/Services/WaScaleManager.ts @@ -41,8 +41,8 @@ export class WaScaleManager { this.actualZoom = realSize.width / gameSize.width / devicePixelRatio; } - this.scaleManager.setZoom(this.actualZoom); this.scaleManager.resize(gameSize.width, gameSize.height); + this.scaleManager.setZoom(this.actualZoom); // Override bug in canvas resizing in Phaser. Let's resize the canvas ourselves const style = this.scaleManager.canvas.style; diff --git a/front/src/Phaser/UserInput/GameSceneUserInputHandler.ts b/front/src/Phaser/UserInput/GameSceneUserInputHandler.ts index 19bb02bb..6ffd69db 100644 --- a/front/src/Phaser/UserInput/GameSceneUserInputHandler.ts +++ b/front/src/Phaser/UserInput/GameSceneUserInputHandler.ts @@ -1,3 +1,6 @@ +import { Player } from "../Player/Player"; +import { RemotePlayer } from "../Entity/RemotePlayer"; + import type { UserInputHandlerInterface } from "../../Interfaces/UserInputHandlerInterface"; import type { GameScene } from "../Game/GameScene"; @@ -22,6 +25,11 @@ export class GameSceneUserInputHandler implements UserInputHandlerInterface { if (pointer.rightButtonReleased() || pointer.getDuration() > 250) { return; } + for (const object of gameObjects) { + if (object instanceof Player || object instanceof RemotePlayer) { + return; + } + } const camera = this.gameScene.getCameraManager().getCamera(); const index = this.gameScene .getGameMap() @@ -45,7 +53,10 @@ export class GameSceneUserInputHandler implements UserInputHandlerInterface { public handlePointerDownEvent(pointer: Phaser.Input.Pointer, gameObjects: Phaser.GameObjects.GameObject[]): void {} public handleSpaceKeyUpEvent(event: Event): Event { - this.gameScene.activateOutlinedItem(); + const activatable = this.gameScene.getActivatablesManager().getSelectedActivatableObject(); + if (activatable && activatable.isActivatable()) { + activatable.activate(); + } return event; } } diff --git a/front/src/Stores/ActionsMenuStore.ts b/front/src/Stores/ActionsMenuStore.ts new file mode 100644 index 00000000..f891dad8 --- /dev/null +++ b/front/src/Stores/ActionsMenuStore.ts @@ -0,0 +1,43 @@ +import { writable } from "svelte/store"; + +export interface ActionsMenuData { + playerName: string; + actions: { actionName: string; callback: Function }[]; +} + +function createActionsMenuStore() { + const { subscribe, update, set } = writable(undefined); + + return { + subscribe, + initialize: (playerName: string) => { + set({ + playerName, + actions: [], + }); + }, + addAction: (actionName: string, callback: Function) => { + update((data) => { + data?.actions.push({ actionName, callback }); + return data; + }); + }, + removeAction: (actionName: string) => { + update((data) => { + const actionIndex = data?.actions.findIndex((action) => action.actionName === actionName); + if (actionIndex !== undefined && actionIndex != -1) { + data?.actions.splice(actionIndex, 1); + } + return data; + }); + }, + /** + * Hides menu + */ + clear: () => { + set(undefined); + }, + }; +} + +export const actionsMenuStore = createActionsMenuStore(); diff --git a/front/src/Stores/OutlineColorStore.ts b/front/src/Stores/OutlineColorStore.ts index 1618eebc..a35cc9c9 100644 --- a/front/src/Stores/OutlineColorStore.ts +++ b/front/src/Stores/OutlineColorStore.ts @@ -3,14 +3,17 @@ import { writable } from "svelte/store"; export function createColorStore() { const { subscribe, set } = writable(undefined); - let color: number | undefined = undefined; - let focused: boolean = false; + let followColor: number | undefined = undefined; + let apiColor: number | undefined = undefined; + + let pointedByPointer: boolean = false; + let pointedByCharacter: boolean = false; const updateColor = () => { - if (focused) { + if (pointedByPointer || pointedByCharacter) { set(0xffff00); } else { - set(color); + set(followColor ?? apiColor); } }; @@ -18,22 +21,42 @@ export function createColorStore() { subscribe, pointerOver() { - focused = true; + pointedByPointer = true; updateColor(); }, pointerOut() { - focused = false; + pointedByPointer = false; updateColor(); }, - setColor(newColor: number) { - color = newColor; + characterCloseBy() { + pointedByCharacter = true; updateColor(); }, - removeColor() { - color = undefined; + characterFarAway() { + pointedByCharacter = false; + updateColor(); + }, + + setFollowColor(newColor: number) { + followColor = newColor; + updateColor(); + }, + + removeFollowColor() { + followColor = undefined; + updateColor(); + }, + + setApiColor(newColor: number) { + apiColor = newColor; + updateColor(); + }, + + removeApiColor() { + apiColor = undefined; updateColor(); }, }; diff --git a/front/src/Utils/CustomTypeGuards.ts b/front/src/Utils/CustomTypeGuards.ts new file mode 100644 index 00000000..f2bdb0f9 --- /dev/null +++ b/front/src/Utils/CustomTypeGuards.ts @@ -0,0 +1,5 @@ +import type { OutlineableInterface } from "../Phaser/Game/OutlineableInterface"; + +export function isOutlineable(object: unknown): object is OutlineableInterface { + return (object as OutlineableInterface)?.pointerOverOutline !== undefined; +} diff --git a/front/src/WebRtc/CoWebsiteManager.ts b/front/src/WebRtc/CoWebsiteManager.ts index 9ca20545..8e646d9d 100644 --- a/front/src/WebRtc/CoWebsiteManager.ts +++ b/front/src/WebRtc/CoWebsiteManager.ts @@ -44,6 +44,7 @@ export type CoWebsite = { allowPolicy: string | undefined; allowApi: boolean | undefined; jitsi?: boolean; + altMessage?: string; }; class CoWebsiteManager { @@ -533,7 +534,8 @@ class CoWebsiteManager { allowApi?: boolean, allowPolicy?: string, position?: number, - closable?: boolean + closable?: boolean, + altMessage?: string ): CoWebsite { const iframe = document.createElement("iframe"); const fullUrl = new URL(url, base); @@ -547,6 +549,7 @@ class CoWebsiteManager { closable: closable ?? false, allowPolicy, allowApi, + altMessage, }; this.initialiseCowebsite(newCoWebsite, position); diff --git a/front/yarn.lock b/front/yarn.lock index 329bce4d..3572e44b 100644 --- a/front/yarn.lock +++ b/front/yarn.lock @@ -1352,6 +1352,11 @@ camelcase@^6.2.0: resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-6.2.0.tgz#924af881c9d525ac9d87f40d964e5cea982a1809" integrity sha512-c7wVvbw3f37nuobQNtgsgG9POC9qMbNuMQmTCqZv23b6MIz0fcYpBiOlv9gEN/hdLdnZTDQhg6e9Dq5M1vKvfg== +cancelable-promise@^4.2.1: + version "4.2.1" + resolved "https://registry.yarnpkg.com/cancelable-promise/-/cancelable-promise-4.2.1.tgz#b02f79c5dde2704acfff1bc1ac2b4090f55541fe" + integrity sha512-PJZ/000ocWhPZQBAuNewAOMA2WEkJ8RhXI6AxeGLiGdW8EYDmumzo9wKyNgjDgxc1q/HbXuTdlcI+wXrOe/jMw== + caniuse-api@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/caniuse-api/-/caniuse-api-3.0.0.tgz#5e4d90e2274961d46291997df599e3ed008ee4c0"