From 73d94d8dc34c52ecaeac5e6095b08e624e4908fd Mon Sep 17 00:00:00 2001 From: Erik Onarheim Date: Mon, 22 Jan 2024 19:39:06 -0600 Subject: [PATCH] feat: Implement SpriteSheet getSprite options (#2901) This PR implements new `ex.SpriteSheet.getSprite(..., options)` You can now influence sprites directly out of the sprite sheet ```typescript const sprite = ss.getSprite(0, 0, { flipHorizontal: true, flipVertical: true, width: 200, height: 201, opacity: .5, scale: ex.vec(2, 2), origin: ex.vec(0, 1), tint: ex.Color.Red, rotation: 4 }); ``` Also the animation helper `ex.Animation.fromSpriteSheetCoordinates()` you can now pass any valid `ex.GraphicOptions` to influence the sprite per frame ```typescript const anim = ex.Animation.fromSpriteSheetCoordinates({ spriteSheet: ss, frameCoordinates: [ {x: 0, y: 0, duration: 100, options: { flipHorizontal: true }}, {x: 1, y: 0, duration: 100, options: { flipVertical: true }}, {x: 2, y: 0, duration: 100}, {x: 3, y: 0, duration: 100} ], strategy: ex.AnimationStrategy.Freeze }); ``` --- CHANGELOG.md | 28 +++++++++++++- src/engine/Graphics/Animation.ts | 10 ++--- src/engine/Graphics/Graphic.ts | 4 +- src/engine/Graphics/SpriteSheet.ts | 35 ++++++++++++----- src/spec/AnimationSpec.ts | 6 ++- src/spec/SpriteSheetSpec.ts | 61 +++++++++++++++++++++++++----- 6 files changed, 115 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f9a131140..fc95887ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ This project adheres to [Semantic Versioning](http://semver.org/). * `class MyComponent extends Component<'ex.component'>` becomes `class MyComponent extends Component` * `ex.System.update(elapsedMs: number)` is only passed an elapsed time - Prevent people from inadvertently overriding `update()` in `ex.Scene` and `ex.Actor`. This method can still be overridden with the `//@ts-ignore` pragma +- `ex.SpriteSheet.getSprite(...)` will now throw on invalid sprite coordinates, this is likely always an error and a warning is inappropriate. This also has the side benefit that you will always get a definite type out of the method. ### Deprecated @@ -25,6 +26,32 @@ This project adheres to [Semantic Versioning](http://semver.org/). ### Added +- Added additional options to `ex.Animation.fromSpriteSheetCoordinates()` you can now pass any valid `ex.GraphicOptions` to influence the sprite per frame + ```typescript + const anim = ex.Animation.fromSpriteSheetCoordinates({ + spriteSheet: ss, + frameCoordinates: [ + {x: 0, y: 0, duration: 100, options: { flipHorizontal: true }}, + {x: 1, y: 0, duration: 100, options: { flipVertical: true }}, + {x: 2, y: 0, duration: 100}, + {x: 3, y: 0, duration: 100} + ], + strategy: ex.AnimationStrategy.Freeze + }); + ``` +- Added additional options to `ex.SpriteSheet.getSprite(..., options)`. You can pass any valid `ex.GraphicOptions` to modify a copy of the sprite from the spritesheet. + ```typescript + const sprite = ss.getSprite(0, 0, { + flipHorizontal: true, + flipVertical: true, + width: 200, + height: 201, + opacity: .5, + scale: ex.vec(2, 2), + origin: ex.vec(0, 1), + tint: ex.Color.Red, + rotation: 4 + }); - New simplified way to query entities `ex.World.query([MyComponentA, MyComponentB])` - New way to query for tags on entities `ex.World.queryTags(['A', 'B'])` - Systems can be added as a constructor to a world, if they are the world will construct and pass a world instance to them @@ -41,7 +68,6 @@ This project adheres to [Semantic Versioning](http://semver.org/). update } - ``` - Added `RayCastHit`as part of every raycast not just the physics world query! * Additionally added the ray distance and the contact normal for the surface diff --git a/src/engine/Graphics/Animation.ts b/src/engine/Graphics/Animation.ts index 315ffb4da..0c4a60b84 100644 --- a/src/engine/Graphics/Animation.ts +++ b/src/engine/Graphics/Animation.ts @@ -1,6 +1,6 @@ import { Graphic, GraphicOptions } from './Graphic'; import { ExcaliburGraphicsContext } from './Context/ExcaliburGraphicsContext'; -import { SpriteSheet } from './SpriteSheet'; +import { GetSpriteOptions, SpriteSheet } from './SpriteSheet'; import { Logger } from '../Util/Log'; import { clamp } from '../Math/util'; import { EventEmitter } from '../EventEmitter'; @@ -118,7 +118,7 @@ export interface FromSpriteSheetOptions { * You may optionally specify a duration for the frame in milliseconds as well, this will override * the default duration. */ - frameCoordinates: {x: number, y: number, duration?: number}[]; + frameCoordinates: {x: number, y: number, duration?: number, options?: GetSpriteOptions}[]; /** * Optionally specify a default duration for frames in milliseconds */ @@ -250,7 +250,7 @@ export class Animation extends Graphic implements HasTick { * const anim = Animation.fromSpriteSheetCoordinates({ * spriteSheet, * frameCoordinates: [ - * {x: 0, y: 5, duration: 100}, + * {x: 0, y: 5, duration: 100, options { flipHorizontal: true }}, * {x: 1, y: 5, duration: 200}, * {x: 2, y: 5, duration: 100}, * {x: 3, y: 5, duration: 500} @@ -266,8 +266,8 @@ export class Animation extends Graphic implements HasTick { const defaultDuration = durationPerFrameMs ?? 100; const frames: Frame[] = []; for (const coord of frameCoordinates) { - const {x, y, duration} = coord; - const sprite = spriteSheet.getSprite(x, y); + const {x, y, duration, options } = coord; + const sprite = spriteSheet.getSprite(x, y, options); if (sprite) { frames.push({ graphic: sprite, diff --git a/src/engine/Graphics/Graphic.ts b/src/engine/Graphics/Graphic.ts index 7ca4156fc..7c24e6dbf 100644 --- a/src/engine/Graphics/Graphic.ts +++ b/src/engine/Graphics/Graphic.ts @@ -31,7 +31,7 @@ export interface GraphicOptions { */ scale?: Vector; /** - * The opacity of the graphic + * The opacity of the graphic between (0 -1) */ opacity?: number; /** @@ -39,7 +39,7 @@ export interface GraphicOptions { */ tint?: Color; /** - * The origin of the drawing in pixels to use when applying transforms, by default it will be the center of the image + * The origin of the drawing in pixels to use when applying transforms, by default it will be the center of the image in pixels */ origin?: Vector; } diff --git a/src/engine/Graphics/SpriteSheet.ts b/src/engine/Graphics/SpriteSheet.ts index 1c9af25cc..8bd1ba0c6 100644 --- a/src/engine/Graphics/SpriteSheet.ts +++ b/src/engine/Graphics/SpriteSheet.ts @@ -1,6 +1,6 @@ import { ImageSource } from './ImageSource'; import { SourceView, Sprite } from './Sprite'; -import { Logger } from '../Util/Log'; +import { GraphicOptions } from './Graphic'; /** * Specify sprite sheet spacing options, useful if your sprites are not tightly packed @@ -81,11 +81,12 @@ export interface SpriteSheetOptions { columns?: number; } +export interface GetSpriteOptions extends GraphicOptions {} + /** * Represents a collection of sprites from a source image with some organization in a grid */ export class SpriteSheet { - private _logger = Logger.getInstance(); public readonly sprites: Sprite[] = []; public readonly rows: number; public readonly columns: number; @@ -104,21 +105,37 @@ export class SpriteSheet { } /** - * Find a sprite by their x/y position in the SpriteSheet, for example `getSprite(0, 0)` is the [[Sprite]] in the top-left + * Find a sprite by their x/y integer coordinates in the SpriteSheet, for example `getSprite(0, 0)` is the [[Sprite]] in the top-left + * and `getSprite(1, 0)` is the sprite one to the right. * @param x * @param y */ - public getSprite(x: number, y: number): Sprite | null { + public getSprite(x: number, y: number, options?: GetSpriteOptions): Sprite { if (x >= this.columns || x < 0) { - this._logger.warn(`No sprite exists in the SpriteSheet at (${x}, ${y}), x: ${x} should be between 0 and ${this.columns - 1}`); - return null; + throw Error(`No sprite exists in the SpriteSheet at (${x}, ${y}), x: ${x} should be between 0 and ${this.columns - 1}`); } if (y >= this.rows || y < 0) { - this._logger.warn(`No sprite exists in the SpriteSheet at (${x}, ${y}), y: ${y} should be between 0 and ${this.rows - 1}`); - return null; + throw Error(`No sprite exists in the SpriteSheet at (${x}, ${y}), y: ${y} should be between 0 and ${this.rows - 1}`); } const spriteIndex = x + y * this.columns; - return this.sprites[spriteIndex]; + const sprite = this.sprites[spriteIndex]; + if (sprite) { + if (options) { + const spriteWithOptions = sprite.clone(); + spriteWithOptions.flipHorizontal = options.flipHorizontal ?? spriteWithOptions.flipHorizontal; + spriteWithOptions.flipVertical = options.flipVertical ?? spriteWithOptions.flipVertical; + spriteWithOptions.width = options.width ?? spriteWithOptions.width; + spriteWithOptions.height = options.height ?? spriteWithOptions.height; + spriteWithOptions.rotation = options.rotation ?? spriteWithOptions.rotation; + spriteWithOptions.scale = options.scale ?? spriteWithOptions.scale; + spriteWithOptions.opacity = options.opacity ?? spriteWithOptions.opacity; + spriteWithOptions.tint = options.tint ?? spriteWithOptions.tint; + spriteWithOptions.origin = options.origin ?? spriteWithOptions.origin; + return spriteWithOptions; + } + return sprite; + } + throw Error(`Invalid sprite coordinates (${x}, ${y}`); } /** diff --git a/src/spec/AnimationSpec.ts b/src/spec/AnimationSpec.ts index aed13d877..63bd1c4ad 100644 --- a/src/spec/AnimationSpec.ts +++ b/src/spec/AnimationSpec.ts @@ -86,8 +86,8 @@ describe('A Graphics Animation', () => { const anim = ex.Animation.fromSpriteSheetCoordinates({ spriteSheet: ss, frameCoordinates: [ - {x: 0, y: 0, duration: 100}, - {x: 1, y: 0, duration: 100}, + {x: 0, y: 0, duration: 100, options: { flipHorizontal: true }}, + {x: 1, y: 0, duration: 100, options: { flipVertical: true }}, {x: 2, y: 0, duration: 100}, {x: 3, y: 0, duration: 100} ], @@ -96,6 +96,8 @@ describe('A Graphics Animation', () => { expect(anim.strategy).toBe(ex.AnimationStrategy.Freeze); expect(anim.frames[0].duration).toBe(100); + expect(anim.frames[0].graphic.flipHorizontal).toBe(true); + expect(anim.frames[1].graphic.flipVertical).toBe(true); expect(anim.frames.length).toBe(4); }); diff --git a/src/spec/SpriteSheetSpec.ts b/src/spec/SpriteSheetSpec.ts index 1ac1ec0f7..b78be1c84 100644 --- a/src/spec/SpriteSheetSpec.ts +++ b/src/spec/SpriteSheetSpec.ts @@ -118,23 +118,64 @@ describe('A SpriteSheet for Graphics', () => { margin: { x: 23, y: 5 } } }); - const logger = ex.Logger.getInstance(); - spyOn(logger, 'warn'); expect(ss.getSprite(0, 0)).withContext('top left sprite').toEqual(ss.sprites[0]); expect(ss.getSprite(13, 3)).withContext('bottom right sprite').not.toBeNull(); - expect(ss.getSprite(13, 4)).toBeNull(); - expect(logger.warn).toHaveBeenCalledWith('No sprite exists in the SpriteSheet at (13, 4), y: 4 should be between 0 and 3'); + expect(() => ss.getSprite(13, 4)).toThrowError('No sprite exists in the SpriteSheet at (13, 4), y: 4 should be between 0 and 3'); - expect(ss.getSprite(14, 3)).toBeNull(); - expect(logger.warn).toHaveBeenCalledWith('No sprite exists in the SpriteSheet at (14, 3), x: 14 should be between 0 and 13'); + expect(() => ss.getSprite(14, 3)).toThrowError('No sprite exists in the SpriteSheet at (14, 3), x: 14 should be between 0 and 13'); - expect(ss.getSprite(-1, 3)).toBeNull(); - expect(logger.warn).toHaveBeenCalledWith('No sprite exists in the SpriteSheet at (-1, 3), x: -1 should be between 0 and 13'); + expect(() => ss.getSprite(-1, 3)).toThrowError('No sprite exists in the SpriteSheet at (-1, 3), x: -1 should be between 0 and 13'); - expect(ss.getSprite(1, -1)).toBeNull(); - expect(logger.warn).toHaveBeenCalledWith('No sprite exists in the SpriteSheet at (1, -1), y: -1 should be between 0 and 3'); + expect(() => ss.getSprite(1, -1)).toThrowError('No sprite exists in the SpriteSheet at (1, -1), y: -1 should be between 0 and 3'); + }); + + it('can retrieve a sprite by x,y, with options', async () => { + const image = new ex.ImageSource('src/spec/images/SpriteSheetSpec/kenny-cards.png'); + + await image.load(); + + const ss = ex.SpriteSheet.fromImageSource({ + image, + grid: { + rows: 4, + columns: 14, + spriteWidth: 42, + spriteHeight: 60 + }, + spacing: { + originOffset: { x: 11, y: 2 }, + margin: { x: 23, y: 5 } + } + }); + + const sprite = ss.getSprite(0, 0, { + flipHorizontal: true, + flipVertical: true, + width: 200, + height: 201, + opacity: .5, + scale: ex.vec(2, 2), + origin: ex.vec(0, 1), + tint: ex.Color.Red, + rotation: 4 + }); + + const spriteNoOptions = ss.getSprite(0, 0); + + expect(sprite).not.toBe(spriteNoOptions); + + expect(sprite.flipHorizontal).toBe(true); + expect(sprite.flipVertical).toBe(true); + expect(sprite.width).toBe(400); + expect(sprite.height).toBe(402); + expect(sprite.opacity).toBe(.5); + expect(sprite.scale).toBeVector(ex.vec(2, 2)); + expect(sprite.origin).toBeVector(ex.vec(0, 1)); + expect(sprite.origin).toBeVector(ex.vec(0, 1)); + expect(sprite.tint).toEqual(ex.Color.Red); + expect(sprite.rotation).toBe(4); }); it('can be cloned', async () => {