Merge pull request #1616 from Waclaw-I/feature-camera-zone-focus

Focusable zones
This commit is contained in:
David Négrier 2021-12-14 15:43:45 +01:00 committed by GitHub
commit 0a705eadf9
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
30 changed files with 987 additions and 47 deletions

View file

@ -0,0 +1,178 @@
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 type { GameScene } from "./GameScene";
export enum CameraMode {
Free = "Free",
Follow = "Follow",
Focus = "Focus",
}
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 restoreZoomTween?: Phaser.Tweens.Tween;
private startFollowTween?: Phaser.Tweens.Tween;
private cameraFollowTarget?: { x: number; y: number };
constructor(scene: GameScene, cameraBounds: { x: number; y: number }, waScaleManager: WaScaleManager) {
super();
this.scene = scene;
this.camera = scene.cameras.main;
this.cameraBounds = cameraBounds;
this.waScaleManager = waScaleManager;
this.initCamera();
this.bindEventHandlers();
}
public destroy(): void {
this.scene.game.events.off("wa-scale-manager:refresh-focus-on-target");
super.destroy();
}
public getCamera(): Phaser.Cameras.Scene2D.Camera {
return this.camera;
}
public enterFocusMode(
focusOn: { x: number; y: number; width: number; height: number },
margin: number = 0,
duration: number = 1000
): void {
this.setCameraMode(CameraMode.Focus);
this.waScaleManager.saveZoom();
this.waScaleManager.setFocusTarget(focusOn);
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;
}
);
}
public leaveFocusMode(player: Player): void {
this.waScaleManager.setFocusTarget();
this.startFollow(player, 1000);
this.restoreZoom(1000);
}
public startFollow(target: object | Phaser.GameObjects.GameObject, duration: number = 0): void {
this.cameraFollowTarget = target as { x: number; y: number };
this.setCameraMode(CameraMode.Follow);
if (duration === 0) {
this.camera.startFollow(target, true);
return;
}
const oldPos = { x: this.camera.scrollX, y: this.camera.scrollY };
this.startFollowTween = this.scene.tweens.addCounter({
from: 0,
to: 1,
duration,
ease: Easing.SineEaseOut,
onUpdate: (tween: Phaser.Tweens.Tween) => {
if (!this.cameraFollowTarget) {
return;
}
const shiftX =
(this.cameraFollowTarget.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.camera.setScroll(oldPos.x + shiftX, oldPos.y + shiftY);
},
onComplete: () => {
this.camera.startFollow(target, true);
},
});
}
/**
* Updates the offset of the character compared to the center of the screen according to the layout manager
* (tries to put the character in the center of the remaining space if there is a discussion going on.
*/
public updateCameraOffset(array: Box): void {
const xCenter = (array.xEnd - array.xStart) / 2 + array.xStart;
const yCenter = (array.yEnd - array.yStart) / 2 + array.yStart;
const game = HtmlUtils.querySelectorOrFail<HTMLCanvasElement>("#game canvas");
// Let's put this in Game coordinates by applying the zoom level:
this.camera.setFollowOffset(
((xCenter - game.offsetWidth / 2) * window.devicePixelRatio) / this.scene.scale.zoom,
((yCenter - game.offsetHeight / 2) * window.devicePixelRatio) / this.scene.scale.zoom
);
}
public isCameraLocked(): boolean {
return this.cameraMode === CameraMode.Focus;
}
private setCameraMode(mode: CameraMode): void {
if (this.cameraMode === mode) {
return;
}
this.cameraMode = mode;
}
private restoreZoom(duration: number = 0): void {
if (duration === 0) {
this.waScaleManager.zoomModifier = this.waScaleManager.getSaveZoom();
return;
}
this.restoreZoomTween?.stop();
this.restoreZoomTween = this.scene.tweens.addCounter({
from: this.waScaleManager.zoomModifier,
to: this.waScaleManager.getSaveZoom(),
duration,
ease: Easing.SineEaseOut,
onUpdate: (tween: Phaser.Tweens.Tween) => {
this.waScaleManager.zoomModifier = tween.getValue();
},
});
}
private initCamera() {
this.camera = this.scene.cameras.main;
this.camera.setBounds(0, 0, this.cameraBounds.x, this.cameraBounds.y);
}
private bindEventHandlers(): void {
this.scene.game.events.on(
"wa-scale-manager:refresh-focus-on-target",
(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);
}
);
}
}

View file

@ -1,8 +1,15 @@
import type { ITiledMap, ITiledMapLayer, ITiledMapProperty } from "../Map/ITiledMap";
import type {
ITiledMap,
ITiledMapLayer,
ITiledMapObject,
ITiledMapObjectLayer,
ITiledMapProperty,
} from "../Map/ITiledMap";
import { flattenGroupLayersMap } from "../Map/LayersFlattener";
import TilemapLayer = Phaser.Tilemaps.TilemapLayer;
import { DEPTH_OVERLAY_INDEX } from "./DepthIndexes";
import { GameMapProperties } from "./GameMapProperties";
import { MathUtils } from "../../Utils/MathUtils";
export type PropertyChangeCallback = (
newValue: string | number | boolean | undefined,
@ -15,24 +22,48 @@ export type layerChangeCallback = (
allLayersOnNewPosition: Array<ITiledMapLayer>
) => void;
export type zoneChangeCallback = (
zonesChangedByAction: Array<ITiledMapObject>,
allZonesOnNewPosition: Array<ITiledMapObject>
) => void;
/**
* A wrapper around a ITiledMap interface to provide additional capabilities.
* It is used to handle layer properties.
*/
export class GameMap {
// oldKey is the index of the previous tile.
/**
* oldKey is the index of the previous tile.
*/
private oldKey: number | undefined;
// key is the index of the current tile.
/**
* key is the index of the current tile.
*/
private key: number | undefined;
/**
* oldPosition is the previous position of the player.
*/
private oldPosition: { x: number; y: number } | undefined;
/**
* position is the current position of the player.
*/
private position: { x: number; y: number } | undefined;
private lastProperties = new Map<string, string | boolean | number>();
private propertiesChangeCallbacks = new Map<string, Array<PropertyChangeCallback>>();
private enterLayerCallbacks = Array<layerChangeCallback>();
private leaveLayerCallbacks = Array<layerChangeCallback>();
private enterZoneCallbacks = Array<zoneChangeCallback>();
private leaveZoneCallbacks = Array<zoneChangeCallback>();
private tileNameMap = new Map<string, number>();
private tileSetPropertyMap: { [tile_index: number]: Array<ITiledMapProperty> } = {};
public readonly flatLayers: ITiledMapLayer[];
public readonly tiledObjects: ITiledMapObject[];
public readonly phaserLayers: TilemapLayer[] = [];
public readonly zones: ITiledMapObject[] = [];
public exitUrls: Array<string> = [];
@ -44,6 +75,9 @@ export class GameMap {
terrains: Array<Phaser.Tilemaps.Tileset>
) {
this.flatLayers = flattenGroupLayersMap(map);
this.tiledObjects = this.getObjectsFromLayers(this.flatLayers);
this.zones = this.tiledObjects.filter((object) => object.type === "zone");
let depth = -2;
for (const layer of this.flatLayers) {
if (layer.type === "tilelayer") {
@ -88,6 +122,10 @@ export class GameMap {
* This will trigger events if properties are changing.
*/
public setPosition(x: number, y: number) {
this.oldPosition = this.position;
this.position = { x, y };
this.triggerZonesChange();
this.oldKey = this.key;
const xMap = Math.floor(x / this.map.tilewidth);
@ -126,7 +164,7 @@ export class GameMap {
}
}
private triggerLayersChange() {
private triggerLayersChange(): void {
const layersByOldKey = this.oldKey ? this.getLayersByKey(this.oldKey) : [];
const layersByNewKey = this.key ? this.getLayersByKey(this.key) : [];
@ -155,6 +193,53 @@ export class GameMap {
}
}
/**
* We use Tiled Objects with type "zone" as zones with defined x, y, width and height for easier event triggering.
*/
private triggerZonesChange(): void {
const zonesByOldPosition = this.oldPosition
? this.zones.filter((zone) => {
if (!this.oldPosition) {
return false;
}
return MathUtils.isOverlappingWithRectangle(this.oldPosition, zone);
})
: [];
const zonesByNewPosition = this.position
? this.zones.filter((zone) => {
if (!this.position) {
return false;
}
return MathUtils.isOverlappingWithRectangle(this.position, zone);
})
: [];
const enterZones = new Set(zonesByNewPosition);
const leaveZones = new Set(zonesByOldPosition);
enterZones.forEach((zone) => {
if (leaveZones.has(zone)) {
leaveZones.delete(zone);
enterZones.delete(zone);
}
});
if (enterZones.size > 0) {
const zonesArray = Array.from(enterZones);
for (const callback of this.enterZoneCallbacks) {
callback(zonesArray, zonesByNewPosition);
}
}
if (leaveZones.size > 0) {
const zonesArray = Array.from(leaveZones);
for (const callback of this.leaveZoneCallbacks) {
callback(zonesArray, zonesByNewPosition);
}
}
}
public getCurrentProperties(): Map<string, string | boolean | number> {
return this.lastProperties;
}
@ -251,6 +336,20 @@ export class GameMap {
this.leaveLayerCallbacks.push(callback);
}
/**
* Registers a callback called when the user moves inside another zone.
*/
public onEnterZone(callback: zoneChangeCallback) {
this.enterZoneCallbacks.push(callback);
}
/**
* Registers a callback called when the user moves outside another zone.
*/
public onLeaveZone(callback: zoneChangeCallback) {
this.leaveZoneCallbacks.push(callback);
}
public findLayer(layerName: string): ITiledMapLayer | undefined {
return this.flatLayers.find((layer) => layer.name === layerName);
}
@ -362,4 +461,17 @@ export class GameMap {
this.trigger(oldPropName, oldPropValue, undefined, emptyProps);
}
}
private getObjectsFromLayers(layers: ITiledMapLayer[]): ITiledMapObject[] {
const objects: ITiledMapObject[] = [];
const objectLayers = layers.filter((layer) => layer.type === "objectgroup");
for (const objectLayer of objectLayers) {
if (objectLayer.type === "objectgroup") {
objects.push(...objectLayer.objects);
}
}
return objects;
}
}

View file

@ -60,6 +60,7 @@ import { PinchManager } from "../UserInput/PinchManager";
import { joystickBaseImg, joystickBaseKey, joystickThumbImg, joystickThumbKey } from "../Components/MobileJoystick";
import { waScaleManager } from "../Services/WaScaleManager";
import { EmoteManager } from "./EmoteManager";
import { CameraManager } from "./CameraManager";
import EVENT_TYPE = Phaser.Scenes.Events;
import type { HasPlayerMovedEvent } from "../../Api/Events/HasPlayerMovedEvent";
@ -198,6 +199,7 @@ export class GameScene extends DirtyScene {
private pinchManager: PinchManager | undefined;
private mapTransitioning: boolean = false; //used to prevent transitions happening at the same time.
private emoteManager!: EmoteManager;
private cameraManager!: CameraManager;
private preloading: boolean = true;
private startPositionCalculator!: StartPositionCalculator;
private sharedVariablesManager!: SharedVariablesManager;
@ -550,7 +552,13 @@ export class GameScene extends DirtyScene {
this.createCurrentPlayer();
this.removeAllRemotePlayers(); //cleanup the list of remote players in case the scene was rebooted
this.initCamera();
this.cameraManager = new CameraManager(
this,
{ x: this.Map.widthInPixels, y: this.Map.heightInPixels },
waScaleManager
);
biggestAvailableAreaStore.recompute();
this.cameraManager.startFollow(this.CurrentPlayer);
this.animatedTiles.init(this.Map);
this.events.on("tileanimationupdate", () => (this.dirty = true));
@ -591,7 +599,7 @@ export class GameScene extends DirtyScene {
// From now, this game scene will be notified of reposition events
this.biggestAvailableAreaStoreUnsubscribe = biggestAvailableAreaStore.subscribe((box) =>
this.updateCameraOffset(box)
this.cameraManager.updateCameraOffset(box)
);
new GameMapPropertiesListener(this, this.gameMap).register();
@ -644,7 +652,7 @@ export class GameScene extends DirtyScene {
* Initializes the connection to Pusher.
*/
private connect(): void {
const camera = this.cameras.main;
const camera = this.cameraManager.getCamera();
connectionManager
.connectToRoomSocket(
@ -779,6 +787,42 @@ export class GameScene extends DirtyScene {
iframeListener.sendLeaveLayerEvent(layer.name);
});
});
this.gameMap.onEnterZone((zones) => {
for (const zone of zones) {
const focusable = zone.properties?.find((property) => property.name === "focusable");
if (focusable && focusable.value === true) {
const zoomMargin = zone.properties?.find((property) => property.name === "zoom_margin");
this.cameraManager.enterFocusMode(
zone,
zoomMargin ? Math.max(0, Number(zoomMargin.value)) : undefined
);
break;
}
}
zones.forEach((zone) => {
iframeListener.sendEnterZoneEvent(zone.name);
});
});
this.gameMap.onLeaveZone((zones) => {
for (const zone of zones) {
const focusable = zone.properties?.find((property) => property.name === "focusable");
if (focusable && focusable.value === true) {
this.cameraManager.leaveFocusMode(this.CurrentPlayer);
break;
}
}
zones.forEach((zone) => {
iframeListener.sendLeaveZoneEvent(zone.name);
});
});
// this.gameMap.onLeaveLayer((layers) => {
// layers.forEach((layer) => {
// iframeListener.sendLeaveLayerEvent(layer.name);
// });
// });
});
}
@ -1370,6 +1414,7 @@ ${escapedMessage}
this.userInputManager.destroy();
this.pinchManager?.destroy();
this.emoteManager.destroy();
this.cameraManager.destroy();
this.peerStoreUnsubscribe();
this.emoteUnsubscribe();
this.emoteMenuUnsubscribe();
@ -1459,13 +1504,6 @@ ${escapedMessage}
}
}
//todo: in a dedicated class/function?
initCamera() {
this.cameras.main.setBounds(0, 0, this.Map.widthInPixels, this.Map.heightInPixels);
this.cameras.main.startFollow(this.CurrentPlayer, true);
biggestAvailableAreaStore.recompute();
}
createCollisionWithPlayer() {
//add collision layer
for (const phaserLayer of this.gameMap.phaserLayers) {
@ -1857,23 +1895,6 @@ ${escapedMessage}
biggestAvailableAreaStore.recompute();
}
/**
* Updates the offset of the character compared to the center of the screen according to the layout manager
* (tries to put the character in the center of the remaining space if there is a discussion going on.
*/
private updateCameraOffset(array: Box): void {
const xCenter = (array.xEnd - array.xStart) / 2 + array.xStart;
const yCenter = (array.yEnd - array.yStart) / 2 + array.yStart;
const game = HtmlUtils.querySelectorOrFail<HTMLCanvasElement>("#game canvas");
// Let's put this in Game coordinates by applying the zoom level:
this.cameras.main.setFollowOffset(
((xCenter - game.offsetWidth / 2) * window.devicePixelRatio) / this.scale.zoom,
((yCenter - game.offsetHeight / 2) * window.devicePixelRatio) / this.scale.zoom
);
}
public startJitsi(roomName: string, jwt?: string): void {
const allProps = this.gameMap.getCurrentProperties();
const jitsiConfig = this.safeParseJSONstring(
@ -1942,6 +1963,9 @@ ${escapedMessage}
}
zoomByFactor(zoomFactor: number) {
if (this.cameraManager.isCameraLocked()) {
return;
}
waScaleManager.zoomModifier *= zoomFactor;
biggestAvailableAreaStore.recompute();
}

View file

@ -1,7 +1,7 @@
import type { RoomConnection } from "../../Connexion/RoomConnection";
import { iframeListener } from "../../Api/IframeListener";
import type { GameMap } from "./GameMap";
import type { ITiledMapLayer, ITiledMapObject, ITiledMapObjectLayer } from "../Map/ITiledMap";
import type { ITiledMapLayer, ITiledMapObject } from "../Map/ITiledMap";
import { GameMapProperties } from "./GameMapProperties";
interface Variable {