diff --git a/CHANGELOG.md b/CHANGELOG.md index 5dc1e3da..bdd87ab6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ This project adheres to [Semantic Versioning](http://semver.org/). ### Added +- Added new `ex.Camera.setStrategies()` and `ex.Camera.strategies` for additional control of strategy order - Fixed ex.Font measureText always using 10px sans-serif on the first call on some browsers - Added a new `ex.Sound({...})` option back constructor to set all the same props available on sound - Added a new `ex.SoundManager` type for managing groups of audio/sound effects/music volume in an easier way @@ -125,6 +126,7 @@ const query = new Query({}) ### Changed +- Updated `ex.Camera.addStrategy()` to accept multiple strategies - Changed the behavior of `fromSpriteSheet(...)`, `fromSpriteSheetCoordinates({...})` and `clone()` of `ex.Animation` to return the subclass if called from there - Optimized BoundingBox.rayCast and BoundingBox.rayCastTime - Optimized BoundingBox.intersect(otherBoundingBox) diff --git a/site/docs/02-fundamentals/04-cameras.mdx b/site/docs/02-fundamentals/04-cameras.mdx index 21f7341c..3c9a7f60 100644 --- a/site/docs/02-fundamentals/04-cameras.mdx +++ b/site/docs/02-fundamentals/04-cameras.mdx @@ -29,7 +29,7 @@ This can be useful as a way to scale up your game. Cameras can implement a number of strategies to track, follow, or exhibit custom behavior in relation to a target. A common reason to use a strategy is to have the [[Camera]] follow an [[Actor]]. -In order to user the different built-in strategies, you can access [[Camera.strategy]] +In order to use the different built-in strategies, you can access [[Camera.strategy]]. :::warning @@ -61,7 +61,7 @@ Keep the actor within a circle around the focus game.currentScene.camera.strategy.radiusAroundActor(actor, radius); ``` -Keep the camera limited within camera constraints. +Keep the camera limited within the given constraints. Make sure that the camera bounds are at least as large as the viewport size. ```typescript @@ -69,6 +69,17 @@ let boundingBox = new BoundingBox(leftBorder, topBorder, rightBorder, bottomBord game.currentScene.camera.strategy.limitCameraBounds(boundingBox); ``` +#### Multiple strategies + +Multiple strategies can be applied to the camera. Strategies are applied in the order they were added. + +For example: Let's say a `lockToActor` strategy and a `limitCameraBounds` strategy is added, in that order. When the +strategies are processed, the camera will first lock to the actor, then, as the camera approaches the configured bounds +of `limitCameraBounds` it is limited to those contraints. + +In this example, the `limitCameraBounds` applies its effect on _top_ of the earlier `lockToActor` strategy. + + #### Custom strategies Custom strategies can be implemented by extending the [[CameraStrategy]] interface and added to cameras to build novel behavior with `ex.Camera.addStrategy(new MyCameraStrategy())`. @@ -88,10 +99,55 @@ export interface CameraStrategy { /** * Camera strategies perform an action to calculate a new focus returned out of the strategy */ - action: (target: T, camera: Camera, engine: Engine, delta: number) => Vector; + action: (target: T, camera: Camera, engine: Engine, elapsed: number) => Vector; +} +``` + +When implementing custom strategies consider referencing the camera's current position with `camera.getFocus()` and +calculate the next position from that vector. This allows strategies to be composed gracefully. + +LockCameraToActorAxisStrategy sample: + +```typescript +export class LockCameraToActorAxisStrategy implements CameraStrategy { + constructor( + public target: Actor, + public axis: Axis + ) {} + public action = (target: Actor, cam: Camera, _eng: Engine, elapsed: number) => { + const center = target.center; + const currentFocus = cam.getFocus(); + if (this.axis === Axis.X) { + return new Vector(center.x, currentFocus.y); + } else { + return new Vector(currentFocus.x, center.y); + } + }; } ``` +When configuring your custom strategy the first argument, `target`, can be of any type. While [[Actor]] is commonly the +target, it can anything, such as a `BoundingBox`. + +#### Adding and removing strategies + +In the above examples we used convenience helpers, such as `game.currentScene.camera.strategy.lockToActor(actor)`, to +add strategies to our scene. Occassionally you may need a bit more control over the strategy array. This can be done +via the following methods and properties: + + - `addStrategy[]>(...cameraStrategies: T)` + - Adds the given strategy to the end of the strategy array. The built in convenience helpers are wrappers for `addStrategy()`. + - Multiple strategies can be passed as arguments and they will be appended to the end of the strategy array. + - `removeStrategy(cameraStrategy: CameraStrategy)` + - Removes the given strategy from the strategy array. + - `clearAllStrategies()` + - Clears all camera strategies from the camera. + - `setStrategies[]>(cameraStrategies: T)` + - Overwrites the current array of strategies with the provided set. + - `strategies` + - The camera's strategy array + + ### Camera shake To add some fun effects to your game, the [[Camera.shake]] method diff --git a/src/engine/Camera.ts b/src/engine/Camera.ts index c530abc8..1e0978d0 100644 --- a/src/engine/Camera.ts +++ b/src/engine/Camera.ts @@ -269,6 +269,9 @@ export class Camera implements CanUpdate, CanInitialize { protected _follow: Actor; private _cameraStrategies: CameraStrategy[] = []; + public get strategies(): CameraStrategy[] { + return this._cameraStrategies; + } public strategy: StrategyContainer = new StrategyContainer(this); @@ -559,11 +562,19 @@ export class Camera implements CanUpdate, CanInitialize { } /** - * Adds a new camera strategy to this camera + * Adds one or more new camera strategies to this camera * @param cameraStrategy Instance of an {@apilink CameraStrategy} */ - public addStrategy(cameraStrategy: CameraStrategy) { - this._cameraStrategies.push(cameraStrategy); + public addStrategy[]>(...cameraStrategies: T) { + this._cameraStrategies.push(...cameraStrategies); + } + + /** + * Sets the strategies of this camera, replacing all existing strategies + * @param cameraStrategies Array of {@apilink CameraStrategy} + */ + public setStrategies[]>(cameraStrategies: T) { + this._cameraStrategies = [...cameraStrategies]; } /** diff --git a/src/spec/vitest/CameraSpec.ts b/src/spec/vitest/CameraSpec.ts index d46161cf..6acd09d8 100644 --- a/src/spec/vitest/CameraSpec.ts +++ b/src/spec/vitest/CameraSpec.ts @@ -371,6 +371,60 @@ describe('A camera', () => { expect(engine.currentScene.camera.pos.y).toBe(750); }); + it('can add a single strategy', () => { + const strategy = new ex.LockCameraToActorStrategy(actor); + engine.currentScene.camera = new ex.Camera(); + + engine.currentScene.camera.addStrategy(strategy); + + expect(engine.currentScene.camera.strategies).toEqual([strategy]); + }); + + it('can add multiple strategies', () => { + const strategyA = new ex.LockCameraToActorStrategy(actor); + const strategyB = new ex.LimitCameraBoundsStrategy(new ex.BoundingBox(10, 10, 1000, 1000)); + const strategyC = new ex.ElasticToActorStrategy(actor, 0.5, 0.2); + engine.currentScene.camera = new ex.Camera(); + + engine.currentScene.camera.addStrategy(strategyA, strategyB, strategyC); + + expect(engine.currentScene.camera.strategies).toEqual([strategyA, strategyB, strategyC]); + }); + + it('can set strategies', () => { + const strategyA = new ex.LockCameraToActorStrategy(actor); + const strategyB = new ex.LimitCameraBoundsStrategy(new ex.BoundingBox(10, 10, 1000, 1000)); + const strategyC = new ex.ElasticToActorStrategy(actor, 0.5, 0.2); + engine.currentScene.camera = new ex.Camera(); + + engine.currentScene.camera.addStrategy(strategyA); + engine.currentScene.camera.setStrategies([strategyB, strategyC]); + + expect(engine.currentScene.camera.strategies).toEqual([strategyB, strategyC]); + }); + + it('can remove strategies', () => { + const strategyA = new ex.LockCameraToActorStrategy(actor); + const strategyB = new ex.LimitCameraBoundsStrategy(new ex.BoundingBox(10, 10, 1000, 1000)); + engine.currentScene.camera = new ex.Camera(); + + engine.currentScene.camera.setStrategies([strategyA, strategyB]); + engine.currentScene.camera.removeStrategy(strategyA); + + expect(engine.currentScene.camera.strategies).toEqual([strategyB]); + }); + + it('can clear all strategies', () => { + const strategyA = new ex.LockCameraToActorStrategy(actor); + const strategyB = new ex.LimitCameraBoundsStrategy(new ex.BoundingBox(10, 10, 1000, 1000)); + engine.currentScene.camera = new ex.Camera(); + + engine.currentScene.camera.setStrategies([strategyA, strategyB]); + engine.currentScene.camera.clearAllStrategies(); + + expect(engine.currentScene.camera.strategies).toEqual([]); + }); + it('can lerp over time', () => new Promise((done) => { engine.currentScene.camera.move(new ex.Vector(100, 100), 1000, ex.EasingFunctions.EaseOutCubic).then(() => {