Merge pull request #1654 from thecodingmachine/feature-camera-management

Feature camera management
This commit is contained in:
David Négrier 2022-01-14 11:57:05 +01:00 committed by GitHub
commit 82c2d21423
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 639 additions and 131 deletions

View file

@ -0,0 +1,11 @@
import * as tg from "generic-type-guard";
export const isCameraFollowPlayerEvent = new tg.IsInterface()
.withProperties({
smooth: tg.isBoolean,
})
.get();
/**
* A message sent from the iFrame to the game to make the camera follow player.
*/
export type CameraFollowPlayerEvent = tg.GuardedType<typeof isCameraFollowPlayerEvent>;

View file

@ -0,0 +1,16 @@
import * as tg from "generic-type-guard";
export const isCameraSetEvent = new tg.IsInterface()
.withProperties({
x: tg.isNumber,
y: tg.isNumber,
width: tg.isOptional(tg.isNumber),
height: tg.isOptional(tg.isNumber),
lock: tg.isBoolean,
smooth: tg.isBoolean,
})
.get();
/**
* A message sent from the iFrame to the game to change the camera position.
*/
export type CameraSetEvent = tg.GuardedType<typeof isCameraSetEvent>;

View file

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

View file

@ -28,10 +28,12 @@ import type { MessageReferenceEvent } from "./ui/TriggerActionMessageEvent";
import { isMessageReferenceEvent, isTriggerActionMessageEvent } from "./ui/TriggerActionMessageEvent";
import type { MenuRegisterEvent, UnregisterMenuEvent } from "./ui/MenuRegisterEvent";
import type { ChangeLayerEvent } from "./ChangeLayerEvent";
import type { ChangeZoneEvent } from "./ChangeZoneEvent";
import { isColorEvent } from "./ColorEvent";
import { isPlayerPosition } from "./PlayerPosition";
import type { WasCameraUpdatedEvent } from "./WasCameraUpdatedEvent";
import type { ChangeZoneEvent } from "./ChangeZoneEvent";
import type { CameraSetEvent } from "./CameraSetEvent";
import type { CameraFollowPlayerEvent } from "./CameraFollowPlayerEvent";
import { isColorEvent } from "./ColorEvent";
export interface TypedMessageEvent<T> extends MessageEvent {
data: T;
@ -43,6 +45,8 @@ export interface TypedMessageEvent<T> extends MessageEvent {
export type IframeEventMap = {
loadPage: LoadPageEvent;
chat: ChatEvent;
cameraFollowPlayer: CameraFollowPlayerEvent;
cameraSet: CameraSetEvent;
openPopup: OpenPopupEvent;
closePopup: ClosePopupEvent;
openTab: OpenTabEvent;

View file

@ -33,6 +33,8 @@ import { handleMenuRegistrationEvent, handleMenuUnregisterEvent } from "../Store
import type { ChangeLayerEvent } from "./Events/ChangeLayerEvent";
import type { WasCameraUpdatedEvent } from "./Events/WasCameraUpdatedEvent";
import type { ChangeZoneEvent } from "./Events/ChangeZoneEvent";
import { CameraSetEvent, isCameraSetEvent } from "./Events/CameraSetEvent";
import { CameraFollowPlayerEvent, isCameraFollowPlayerEvent } from "./Events/CameraFollowPlayerEvent";
type AnswererCallback<T extends keyof IframeQueryMap> = (
query: IframeQueryMap[T]["query"],
@ -56,6 +58,12 @@ class IframeListener {
private readonly _disablePlayerControlStream: Subject<void> = new Subject();
public readonly disablePlayerControlStream = this._disablePlayerControlStream.asObservable();
private readonly _cameraSetStream: Subject<CameraSetEvent> = new Subject();
public readonly cameraSetStream = this._cameraSetStream.asObservable();
private readonly _cameraFollowPlayerStream: Subject<CameraFollowPlayerEvent> = new Subject();
public readonly cameraFollowPlayerStream = this._cameraFollowPlayerStream.asObservable();
private readonly _enablePlayerControlStream: Subject<void> = new Subject();
public readonly enablePlayerControlStream = this._enablePlayerControlStream.asObservable();
@ -202,6 +210,10 @@ class IframeListener {
this._hideLayerStream.next(payload.data);
} else if (payload.type === "setProperty" && isSetPropertyEvent(payload.data)) {
this._setPropertyStream.next(payload.data);
} else if (payload.type === "cameraSet" && isCameraSetEvent(payload.data)) {
this._cameraSetStream.next(payload.data);
} else if (payload.type === "cameraFollowPlayer" && isCameraFollowPlayerEvent(payload.data)) {
this._cameraFollowPlayerStream.next(payload.data);
} else if (payload.type === "chat" && isChatEvent(payload.data)) {
scriptUtils.sendAnonymousChat(payload.data);
} else if (payload.type === "openPopup" && isOpenPopupEvent(payload.data)) {

View file

@ -17,6 +17,27 @@ export class WorkAdventureCameraCommands extends IframeApiContribution<WorkAdven
}),
];
public set(
x: number,
y: number,
width?: number,
height?: number,
lock: boolean = false,
smooth: boolean = false
): void {
sendToWorkadventure({
type: "cameraSet",
data: { x, y, width, height, lock, smooth },
});
}
public followPlayer(smooth: boolean = false): void {
sendToWorkadventure({
type: "cameraFollowPlayer",
data: { smooth },
});
}
onCameraUpdate(): Subject<WasCameraUpdatedEvent> {
sendToWorkadventure({
type: "onCameraUpdate",

View file

@ -1,4 +1,3 @@
import type { ChatEvent } from "../Events/ChatEvent";
import { isUserInputChatEvent, UserInputChatEvent } from "../Events/UserInputChatEvent";
import { IframeApiContribution, sendToWorkadventure } from "./IframeApiContribution";
import { apiCallback } from "./registeredCallbacks";

View file

@ -1,28 +1,49 @@
import { Easing } from "../../types";
import { HtmlUtils } from "../../WebRtc/HtmlUtils";
import type { Box } from "../../WebRtc/LayoutManager";
import type { Player } from "../Player/Player";
import type { WaScaleManager } from "../Services/WaScaleManager";
import { hasMovedEventName, Player } from "../Player/Player";
import { WaScaleManager, WaScaleManagerEvent, WaScaleManagerFocusTarget } from "../Services/WaScaleManager";
import type { GameScene } from "./GameScene";
export enum CameraMode {
Free = "Free",
/**
* Camera looks at certain point but is not locked and will start following the player on his movement
*/
Positioned = "Positioned",
/**
* Camera is actively following the player
*/
Follow = "Follow",
/**
* Camera is focusing on certain point and will not break this focus even on player movement
*/
Focus = "Focus",
}
export enum CameraManagerEvent {
CameraUpdate = "CameraUpdate",
}
export interface CameraManagerEventCameraUpdateData {
x: number;
y: number;
width: number;
height: number;
zoom: number;
}
export class CameraManager extends Phaser.Events.EventEmitter {
private scene: GameScene;
private camera: Phaser.Cameras.Scene2D.Camera;
private cameraBounds: { x: number; y: number };
private waScaleManager: WaScaleManager;
private cameraMode: CameraMode = CameraMode.Free;
private cameraMode: CameraMode = CameraMode.Positioned;
private restoreZoomTween?: Phaser.Tweens.Tween;
private startFollowTween?: Phaser.Tweens.Tween;
private cameraFollowTarget?: { x: number; y: number };
private playerToFollow?: Player;
private cameraLocked: boolean;
constructor(scene: GameScene, cameraBounds: { x: number; y: number }, waScaleManager: WaScaleManager) {
@ -41,7 +62,7 @@ export class CameraManager extends Phaser.Events.EventEmitter {
}
public destroy(): void {
this.scene.game.events.off("wa-scale-manager:refresh-focus-on-target");
this.scene.game.events.off(WaScaleManagerEvent.RefreshFocusOnTarget);
super.destroy();
}
@ -49,52 +70,96 @@ export class CameraManager extends Phaser.Events.EventEmitter {
return this.camera;
}
public enterFocusMode(
focusOn: { x: number; y: number; width: number; height: number },
margin: number = 0,
duration: number = 1000
): void {
/**
* Set camera view to specific destination without changing current camera mode. Won't work if camera mode is set to Focus.
* @param setTo Viewport on which the camera should set the position
* @param duration Time for the transition im MS. If set to 0, transition will occur immediately
*/
public setPosition(setTo: WaScaleManagerFocusTarget, duration: number = 1000): void {
if (this.cameraMode === CameraMode.Focus) {
return;
}
this.setCameraMode(CameraMode.Positioned);
this.waScaleManager.saveZoom();
this.camera.stopFollow();
const currentZoomModifier = this.waScaleManager.zoomModifier;
const zoomModifierChange = this.getZoomModifierChange(setTo.width, setTo.height);
if (duration === 0) {
this.waScaleManager.zoomModifier = currentZoomModifier + zoomModifierChange;
this.camera.centerOn(setTo.x, setTo.y);
this.emit(CameraManagerEvent.CameraUpdate, this.getCameraUpdateEventData());
this.playerToFollow?.once(hasMovedEventName, () => {
if (this.playerToFollow) {
this.startFollowPlayer(this.playerToFollow, duration);
}
});
return;
}
this.camera.pan(setTo.x, setTo.y, duration, Easing.SineEaseOut, true, (camera, progress, x, y) => {
if (this.cameraMode === CameraMode.Positioned) {
this.waScaleManager.zoomModifier = currentZoomModifier + progress * zoomModifierChange;
this.emit(CameraManagerEvent.CameraUpdate, this.getCameraUpdateEventData());
}
if (progress === 1) {
this.playerToFollow?.once(hasMovedEventName, () => {
if (this.playerToFollow) {
this.startFollowPlayer(this.playerToFollow, duration);
}
});
}
});
}
/**
* Set camera to focus mode. As long as the camera is in the Focus mode, its view cannot be changed.
* @param setTo Viewport on which the camera should focus on
* @param duration Time for the transition im MS. If set to 0, transition will occur immediately
*/
public enterFocusMode(focusOn: WaScaleManagerFocusTarget, margin: number = 0, duration: number = 1000): void {
this.setCameraMode(CameraMode.Focus);
this.waScaleManager.saveZoom();
this.waScaleManager.setFocusTarget(focusOn);
this.cameraLocked = true;
this.unlockCameraWithDelay(duration);
this.restoreZoomTween?.stop();
this.startFollowTween?.stop();
const marginMult = 1 + margin;
const targetZoomModifier = this.waScaleManager.getTargetZoomModifierFor(
focusOn.width * marginMult,
focusOn.height * marginMult
);
const currentZoomModifier = this.waScaleManager.zoomModifier;
const zoomModifierChange = targetZoomModifier - currentZoomModifier;
this.camera.stopFollow();
this.cameraFollowTarget = undefined;
this.camera.pan(
focusOn.x + focusOn.width * 0.5 * marginMult,
focusOn.y + focusOn.height * 0.5 * marginMult,
duration,
Easing.SineEaseOut,
true,
(camera, progress, x, y) => {
this.waScaleManager.zoomModifier = currentZoomModifier + progress * zoomModifierChange;
this.playerToFollow = undefined;
const currentZoomModifier = this.waScaleManager.zoomModifier;
const zoomModifierChange = this.getZoomModifierChange(focusOn.width, focusOn.height, 1 + margin);
if (duration === 0) {
this.waScaleManager.zoomModifier = currentZoomModifier + zoomModifierChange;
this.camera.centerOn(focusOn.x, focusOn.y);
this.emit(CameraManagerEvent.CameraUpdate, this.getCameraUpdateEventData());
return;
}
this.camera.pan(focusOn.x, focusOn.y, duration, Easing.SineEaseOut, true, (camera, progress, x, y) => {
this.waScaleManager.zoomModifier = currentZoomModifier + progress * zoomModifierChange;
if (progress === 1) {
// NOTE: Making sure the last action will be centering after zoom change
this.camera.centerOn(focusOn.x, focusOn.y);
}
);
this.emit(CameraManagerEvent.CameraUpdate, this.getCameraUpdateEventData());
});
}
public leaveFocusMode(player: Player, duration = 0): void {
this.waScaleManager.setFocusTarget();
this.unlockCameraWithDelay(duration);
this.startFollow(player, duration);
this.startFollowPlayer(player, duration);
this.restoreZoom(duration);
}
public startFollow(target: object | Phaser.GameObjects.GameObject, duration: number = 0): void {
this.cameraFollowTarget = target as { x: number; y: number };
public startFollowPlayer(player: Player, duration: number = 0): void {
this.playerToFollow = player;
this.setCameraMode(CameraMode.Follow);
if (duration === 0) {
this.camera.startFollow(target, true);
this.camera.startFollow(player, true);
return;
}
const oldPos = { x: this.camera.scrollX, y: this.camera.scrollY };
@ -104,17 +169,18 @@ export class CameraManager extends Phaser.Events.EventEmitter {
duration,
ease: Easing.SineEaseOut,
onUpdate: (tween: Phaser.Tweens.Tween) => {
if (!this.cameraFollowTarget) {
if (!this.playerToFollow) {
return;
}
const shiftX =
(this.cameraFollowTarget.x - this.camera.worldView.width * 0.5 - oldPos.x) * tween.getValue();
(this.playerToFollow.x - this.camera.worldView.width * 0.5 - oldPos.x) * tween.getValue();
const shiftY =
(this.cameraFollowTarget.y - this.camera.worldView.height * 0.5 - oldPos.y) * tween.getValue();
(this.playerToFollow.y - this.camera.worldView.height * 0.5 - oldPos.y) * tween.getValue();
this.camera.setScroll(oldPos.x + shiftX, oldPos.y + shiftY);
this.emit(CameraManagerEvent.CameraUpdate, this.getCameraUpdateEventData());
},
onComplete: () => {
this.camera.startFollow(target, true);
this.camera.startFollow(player, true);
},
});
}
@ -140,6 +206,18 @@ export class CameraManager extends Phaser.Events.EventEmitter {
return this.cameraLocked;
}
private getZoomModifierChange(width?: number, height?: number, multiplier: number = 1): number {
if (!width || !height) {
return 0;
}
const targetZoomModifier = this.waScaleManager.getTargetZoomModifierFor(
width * multiplier,
height * multiplier
);
const currentZoomModifier = this.waScaleManager.zoomModifier;
return targetZoomModifier - currentZoomModifier;
}
private unlockCameraWithDelay(delay: number): void {
this.scene.time.delayedCall(delay, () => {
this.cameraLocked = false;
@ -166,6 +244,7 @@ export class CameraManager extends Phaser.Events.EventEmitter {
ease: Easing.SineEaseOut,
onUpdate: (tween: Phaser.Tweens.Tween) => {
this.waScaleManager.zoomModifier = tween.getValue();
this.emit(CameraManagerEvent.CameraUpdate, this.getCameraUpdateEventData());
},
});
}
@ -177,13 +256,28 @@ export class CameraManager extends Phaser.Events.EventEmitter {
private bindEventHandlers(): void {
this.scene.game.events.on(
"wa-scale-manager:refresh-focus-on-target",
WaScaleManagerEvent.RefreshFocusOnTarget,
(focusOn: { x: number; y: number; width: number; height: number }) => {
if (!focusOn) {
return;
}
this.camera.centerOn(focusOn.x + focusOn.width * 0.5, focusOn.y + focusOn.height * 0.5);
this.camera.centerOn(focusOn.x, focusOn.y);
this.emit(CameraManagerEvent.CameraUpdate, this.getCameraUpdateEventData());
}
);
this.camera.on("followupdate", () => {
this.emit(CameraManagerEvent.CameraUpdate, this.getCameraUpdateEventData());
});
}
private getCameraUpdateEventData(): CameraManagerEventCameraUpdateData {
return {
x: this.camera.worldView.x,
y: this.camera.worldView.y,
width: this.camera.worldView.width,
height: this.camera.worldView.height,
zoom: this.camera.scaleManager.zoom,
};
}
}

View file

@ -64,7 +64,7 @@ import type { ActionableItem } from "../Items/ActionableItem";
import type { ItemFactoryInterface } from "../Items/ItemFactoryInterface";
import type { ITiledMap, ITiledMapLayer, ITiledMapProperty, ITiledMapObject, ITiledTileSet } from "../Map/ITiledMap";
import type { AddPlayerInterface } from "./AddPlayerInterface";
import { CameraManager } from "./CameraManager";
import { CameraManager, CameraManagerEvent, CameraManagerEventCameraUpdateData } from "./CameraManager";
import type { HasPlayerMovedEvent } from "../../Api/Events/HasPlayerMovedEvent";
import type { Character } from "../Entity/Character";
@ -75,6 +75,7 @@ import { playersStore } from "../../Stores/PlayersStore";
import { emoteStore, emoteMenuStore } from "../../Stores/EmoteStore";
import { userIsAdminStore } from "../../Stores/GameStore";
import { contactPageStore } from "../../Stores/MenuStore";
import type { WasCameraUpdatedEvent } from "../../Api/Events/WasCameraUpdatedEvent";
import { audioManagerFileStore, audioManagerVisibilityStore } from "../../Stores/AudioManagerStore";
import EVENT_TYPE = Phaser.Scenes.Events;
@ -91,7 +92,6 @@ import { MapStore } from "../../Stores/Utils/MapStore";
import { followUsersColorStore, followUsersStore } from "../../Stores/FollowStore";
import { getColorRgbFromHue } from "../../WebRtc/ColorGenerator";
import Camera = Phaser.Cameras.Scene2D.Camera;
import type { WasCameraUpdatedEvent } from "../../Api/Events/WasCameraUpdatedEvent";
export interface GameSceneInitInterface {
initPosition: PointInterface | null;
@ -569,7 +569,7 @@ export class GameScene extends DirtyScene {
waScaleManager
);
biggestAvailableAreaStore.recompute();
this.cameraManager.startFollow(this.CurrentPlayer);
this.cameraManager.startFollowPlayer(this.CurrentPlayer);
this.animatedTiles.init(this.Map);
this.events.on("tileanimationupdate", () => (this.dirty = true));
@ -843,7 +843,12 @@ export class GameScene extends DirtyScene {
if (focusable && focusable.value === true) {
const zoomMargin = zone.properties?.find((property) => property.name === "zoom_margin");
this.cameraManager.enterFocusMode(
zone,
{
x: zone.x + zone.width * 0.5,
y: zone.y + zone.height * 0.5,
width: zone.width,
height: zone.height,
},
zoomMargin ? Math.max(0, Number(zoomMargin.value)) : undefined
);
break;
@ -1122,6 +1127,21 @@ ${escapedMessage}
})
);
this.iframeSubscriptionList.push(
iframeListener.cameraSetStream.subscribe((cameraSetEvent) => {
const duration = cameraSetEvent.smooth ? 1000 : 0;
cameraSetEvent.lock
? this.cameraManager.enterFocusMode({ ...cameraSetEvent }, undefined, duration)
: this.cameraManager.setPosition({ ...cameraSetEvent }, duration);
})
);
this.iframeSubscriptionList.push(
iframeListener.cameraFollowPlayerStream.subscribe((cameraFollowPlayerEvent) => {
this.cameraManager.leaveFocusMode(this.CurrentPlayer, cameraFollowPlayerEvent.smooth ? 1000 : 0);
})
);
this.iframeSubscriptionList.push(
iframeListener.playSoundStream.subscribe((playSoundEvent) => {
const url = new URL(playSoundEvent.url, this.MapUrlFile);
@ -1134,30 +1154,31 @@ ${escapedMessage}
this.iframeSubscriptionList.push(
iframeListener.trackCameraUpdateStream.subscribe(() => {
if (!this.firstCameraUpdateSent) {
this.cameras.main.on("followupdate", (camera: Camera) => {
const cameraEvent: WasCameraUpdatedEvent = {
x: camera.worldView.x,
y: camera.worldView.y,
width: camera.worldView.width,
height: camera.worldView.height,
zoom: camera.scaleManager.zoom,
};
if (
this.lastCameraEvent?.x == cameraEvent.x &&
this.lastCameraEvent?.y == cameraEvent.y &&
this.lastCameraEvent?.width == cameraEvent.width &&
this.lastCameraEvent?.height == cameraEvent.height &&
this.lastCameraEvent?.zoom == cameraEvent.zoom
) {
return;
this.cameraManager.on(
CameraManagerEvent.CameraUpdate,
(data: CameraManagerEventCameraUpdateData) => {
const cameraEvent: WasCameraUpdatedEvent = {
x: data.x,
y: data.y,
width: data.width,
height: data.height,
zoom: data.zoom,
};
if (
this.lastCameraEvent?.x == cameraEvent.x &&
this.lastCameraEvent?.y == cameraEvent.y &&
this.lastCameraEvent?.width == cameraEvent.width &&
this.lastCameraEvent?.height == cameraEvent.height &&
this.lastCameraEvent?.zoom == cameraEvent.zoom
) {
return;
}
this.lastCameraEvent = cameraEvent;
iframeListener.sendCameraUpdated(cameraEvent);
this.firstCameraUpdateSent = true;
}
this.lastCameraEvent = cameraEvent;
iframeListener.sendCameraUpdated(cameraEvent);
this.firstCameraUpdateSent = true;
});
iframeListener.sendCameraUpdated(this.cameras.main);
);
}
})
);

View file

@ -9,6 +9,8 @@ export enum WaScaleManagerEvent {
RefreshFocusOnTarget = "wa-scale-manager:refresh-focus-on-target",
}
export type WaScaleManagerFocusTarget = { x: number; y: number; width?: number; height?: number };
export class WaScaleManager {
private hdpiManager: HdpiManager;
private scaleManager!: ScaleManager;
@ -16,7 +18,7 @@ export class WaScaleManager {
private actualZoom: number = 1;
private _saveZoom: number = 1;
private focusTarget?: { x: number; y: number; width: number; height: number };
private focusTarget?: WaScaleManagerFocusTarget;
public constructor(private minGamePixelsNumber: number, private absoluteMinPixelNumber: number) {
this.hdpiManager = new HdpiManager(minGamePixelsNumber, absoluteMinPixelNumber);
@ -72,11 +74,13 @@ export class WaScaleManager {
if (!this.focusTarget) {
return;
}
this.zoomModifier = this.getTargetZoomModifierFor(this.focusTarget.width, this.focusTarget.height);
if (this.focusTarget.width && this.focusTarget.height) {
this.zoomModifier = this.getTargetZoomModifierFor(this.focusTarget.width, this.focusTarget.height);
}
this.game.events.emit(WaScaleManagerEvent.RefreshFocusOnTarget, this.focusTarget);
}
public setFocusTarget(targetDimensions?: { x: number; y: number; width: number; height: number }): void {
public setFocusTarget(targetDimensions?: WaScaleManagerFocusTarget): void {
this.focusTarget = targetDimensions;
}
@ -109,7 +113,7 @@ export class WaScaleManager {
}
}
public getFocusTarget(): { x: number; y: number; width: number; height: number } | undefined {
public getFocusTarget(): WaScaleManagerFocusTarget | undefined {
return this.focusTarget;
}

View file

@ -34,9 +34,9 @@ const initPromise = queryWorkadventure({
setMapURL(gameState.mapUrl);
setTags(gameState.tags);
setUuid(gameState.uuid);
setUserRoomToken(gameState.userRoomToken);
globalState.initVariables(gameState.variables as Map<string, unknown>);
player.state.initVariables(gameState.playerVariables as Map<string, unknown>);
setUserRoomToken(gameState.userRoomToken);
});
const wa = {