Skip to content

Commit

Permalink
feat: Implement SpriteSheet getSprite options (#2901)
Browse files Browse the repository at this point in the history
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
  });
  ```
  • Loading branch information
eonarheim authored Jan 23, 2024
1 parent 8f4d990 commit 73d94d8
Show file tree
Hide file tree
Showing 6 changed files with 115 additions and 29 deletions.
28 changes: 27 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down
10 changes: 5 additions & 5 deletions src/engine/Graphics/Animation.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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
*/
Expand Down Expand Up @@ -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}
Expand All @@ -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,
Expand Down
4 changes: 2 additions & 2 deletions src/engine/Graphics/Graphic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,15 +31,15 @@ export interface GraphicOptions {
*/
scale?: Vector;
/**
* The opacity of the graphic
* The opacity of the graphic between (0 -1)
*/
opacity?: number;
/**
* The tint of the graphic, this color will be multiplied by the original pixel colors
*/
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;
}
Expand Down
35 changes: 26 additions & 9 deletions src/engine/Graphics/SpriteSheet.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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;
Expand All @@ -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}`);
}

/**
Expand Down
6 changes: 4 additions & 2 deletions src/spec/AnimationSpec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}
],
Expand All @@ -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);
});

Expand Down
61 changes: 51 additions & 10 deletions src/spec/SpriteSheetSpec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down

0 comments on commit 73d94d8

Please sign in to comment.