From a47b2047849c22ee8dbad49ed1a2050282676d9f Mon Sep 17 00:00:00 2001 From: Erik Onarheim Date: Wed, 4 Dec 2024 11:55:15 -0600 Subject: [PATCH] feat: Tiling Sprites and Animations (#3309) https://github.com/user-attachments/assets/1c957f33-2088-4a77-981d-78e1db2e3d83 Added convenience types `ex.TiledSprite` and `ex.TiledAnimation` for Tiling Sprites and Animations ```typescript const tiledGroundSprite = new ex.TiledSprite({ image: groundImage, width: game.screen.width, height: 200, wrapping: { x: ex.ImageWrapping.Repeat, y: ex.ImageWrapping.Clamp } }); const tilingAnimation = new ex.TiledAnimation({ animation: cardAnimation, sourceView: {x: 20, y: 20}, width: 200, height: 200, wrapping: ex.ImageWrapping.Repeat }); ``` --- CHANGELOG.md | 22 +++ sandbox/tests/tiling/desert.png | Bin 0 -> 204 bytes sandbox/tests/tiling/ground.png | Bin 0 -> 223 bytes sandbox/tests/tiling/index.html | 13 ++ sandbox/tests/tiling/index.ts | 81 +++++++++++ sandbox/tests/tiling/kenny-cards.png | Bin 0 -> 3512 bytes src/engine/Graphics/Animation.ts | 5 + src/engine/Graphics/Context/texture-loader.ts | 4 +- src/engine/Graphics/ImageSource.ts | 48 ++++++ src/engine/Graphics/TiledAnimation.ts | 137 ++++++++++++++++++ src/engine/Graphics/TiledSprite.ts | 92 ++++++++++++ src/engine/Graphics/index.ts | 2 + src/engine/Screen.ts | 17 +++ src/spec/ImageSourceSpec.ts | 79 ++++++++++ src/spec/TiledAnimationSpec.ts | 73 ++++++++++ src/spec/TiledSpriteSpec.ts | 94 ++++++++++++ .../GraphicsImageSourceSpec/canvas-image.png | Bin 0 -> 608 bytes .../images/TiledAnimationSpec/kenny-cards.png | Bin 0 -> 3512 bytes .../images/TiledAnimationSpec/tiled-2.png | Bin 0 -> 11516 bytes src/spec/images/TiledAnimationSpec/tiled.png | Bin 0 -> 10904 bytes src/spec/images/TiledSpriteSpec/ground.png | Bin 0 -> 223 bytes src/spec/images/TiledSpriteSpec/tiled.png | Bin 0 -> 4337 bytes 22 files changed, 665 insertions(+), 2 deletions(-) create mode 100644 sandbox/tests/tiling/desert.png create mode 100644 sandbox/tests/tiling/ground.png create mode 100644 sandbox/tests/tiling/index.html create mode 100644 sandbox/tests/tiling/index.ts create mode 100644 sandbox/tests/tiling/kenny-cards.png create mode 100644 src/engine/Graphics/TiledAnimation.ts create mode 100644 src/engine/Graphics/TiledSprite.ts create mode 100644 src/spec/TiledAnimationSpec.ts create mode 100644 src/spec/TiledSpriteSpec.ts create mode 100644 src/spec/images/GraphicsImageSourceSpec/canvas-image.png create mode 100644 src/spec/images/TiledAnimationSpec/kenny-cards.png create mode 100644 src/spec/images/TiledAnimationSpec/tiled-2.png create mode 100644 src/spec/images/TiledAnimationSpec/tiled.png create mode 100644 src/spec/images/TiledSpriteSpec/ground.png create mode 100644 src/spec/images/TiledSpriteSpec/tiled.png diff --git a/CHANGELOG.md b/CHANGELOG.md index bc032aee3..11f920258 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -78,6 +78,28 @@ This project adheres to [Semantic Versioning](http://semver.org/). ### Added +- Alias the `engine.screen.drawWidth/drawHeight` with `engine.screen.width/height`; +- Added convenience types `ex.TiledSprite` and `ex.TiledAnimation` for Tiling Sprites and Animations + ```typescript + const tiledGroundSprite = new ex.TiledSprite({ + image: groundImage, + width: game.screen.width, + height: 200, + wrapping: { + x: ex.ImageWrapping.Repeat, + y: ex.ImageWrapping.Clamp + } + }); + + const tilingAnimation = new ex.TiledAnimation({ + animation: cardAnimation, + sourceView: {x: 20, y: 20}, + width: 200, + height: 200, + wrapping: ex.ImageWrapping.Repeat + }); + ``` +- Added new static builder for making images from canvases `ex.ImageSource.fromHtmlCanvasElement(image: HTMLCanvasElement, options?: ImageSourceOptions)` - Added GPU particle implementation for MANY MANY particles in the simulation, similar to the existing CPU particle implementation. Note `maxParticles` is new for GPU particles. ```typescript var particles = new ex.GpuParticleEmitter({ diff --git a/sandbox/tests/tiling/desert.png b/sandbox/tests/tiling/desert.png new file mode 100644 index 0000000000000000000000000000000000000000..184c4a0626db502902769d1eed4a52e158c81129 GIT binary patch literal 204 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`lRaG=Lo9le_v}=7YtJmVA@_DD z8wlv@=dLNz`C(#cU|_)Fu|Ms{()SI)ylspx%>F4o&~kH%Q(&2)agoQI;pX`l3AeYc zu6WAcDc-@jV^33JL(m0Q5xd(*nJa{a-K^&{RnSV05NBw}Zr$O=TEM8C6B=aXSCd$6<}DT#Sbko|vugjotKG*-R?PdqTR!$7Q$g$JfBXrn3+6H1u!><=S1`}u z&bfQ}74QAe?UTxm4cfEh8rO#XK + + + + + + Tiling + + + + + + diff --git a/sandbox/tests/tiling/index.ts b/sandbox/tests/tiling/index.ts new file mode 100644 index 000000000..4d3542122 --- /dev/null +++ b/sandbox/tests/tiling/index.ts @@ -0,0 +1,81 @@ +var game = new ex.Engine({ + width: 800, + height: 800, + displayMode: ex.DisplayMode.FitScreenAndFill, + pixelArt: true, + pixelRatio: 2 +}); + +var cards = new ex.ImageSource('./kenny-cards.png'); +var cardSpriteSheet = ex.SpriteSheet.fromImageSource({ + image: cards, + grid: { + rows: 4, + columns: 14, + spriteWidth: 42, + spriteHeight: 60 + }, + spacing: { + originOffset: { x: 11, y: 2 }, + margin: { x: 23, y: 5 } + } +}); + +cardSpriteSheet.sprites.forEach((s) => (s.scale = ex.vec(2, 2))); +var cardAnimation = ex.Animation.fromSpriteSheet(cardSpriteSheet, ex.range(0, 14 * 4), 200); + +var groundImage = new ex.ImageSource('./ground.png'); +var desertImage = new ex.ImageSource('./desert.png'); +var loader = new ex.Loader([cards, groundImage, desertImage]); +var groundSprite = groundImage.toSprite(); + +// var tiledGroundSprite = new ex.TiledSprite({ +// image: groundImage, +// width: game.screen.width, +// height: 200, +// wrapping: { +// x: ex.ImageWrapping.Repeat, +// y: ex.ImageWrapping.Clamp +// } +// }); +var tiledGroundSprite = ex.TiledSprite.fromSprite(groundSprite, { + width: game.screen.width, + height: 200, + wrapping: { + x: ex.ImageWrapping.Repeat, + y: ex.ImageWrapping.Clamp + } +}); + +var tilingAnimation = new ex.TiledAnimation({ + animation: cardAnimation, + sourceView: { x: 20, y: 20 }, + width: 200, + height: 200, + wrapping: ex.ImageWrapping.Repeat +}); + +// tilingAnimation.sourceView = {x: 0, y: 0}; + +game.start(loader).then(() => { + var cardActor = new ex.Actor({ + pos: ex.vec(400, 400) + }); + cardActor.graphics.use(tilingAnimation); + game.add(cardActor); + + var actor = new ex.Actor({ + pos: ex.vec(game.screen.unsafeArea.left, 700), + anchor: ex.vec(0, 0) + }); + actor.graphics.use(tiledGroundSprite); + game.add(actor); + + game.input.pointers.primary.on('wheel', (ev) => { + game.currentScene.camera.zoom += ev.deltaY / 1000; + game.currentScene.camera.zoom = ex.clamp(game.currentScene.camera.zoom, 0.05, 100); + tiledGroundSprite.width = game.screen.width; + // game.screen.center // TODO this doesn't seem right when the screen is narrow in Fit&Fill + actor.pos.x = game.screen.unsafeArea.left; // TODO unsafe area doesn't update on camera zoom + }); +}); diff --git a/sandbox/tests/tiling/kenny-cards.png b/sandbox/tests/tiling/kenny-cards.png new file mode 100644 index 0000000000000000000000000000000000000000..69773596829c04d7db5cde0213b3cb20d592bf6e GIT binary patch literal 3512 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 { return imageSource; } + static fromHtmlCanvasElement(image: HTMLCanvasElement, options?: ImageSourceOptions): ImageSource { + const imageSource = new ImageSource(''); + imageSource._src = 'canvas-element-blob'; + imageSource.data.setAttribute('data-original-src', 'canvas-element-blob'); + + if (options?.filtering) { + imageSource.data.setAttribute(ImageSourceAttributeConstants.Filtering, options?.filtering); + } else { + imageSource.data.setAttribute(ImageSourceAttributeConstants.Filtering, ImageFiltering.Blended); + } + + if (options?.wrapping) { + let wrapping: ImageWrapConfiguration; + if (typeof options.wrapping === 'string') { + wrapping = { + x: options.wrapping, + y: options.wrapping + }; + } else { + wrapping = { + x: options.wrapping.x, + y: options.wrapping.y + }; + } + imageSource.data.setAttribute(ImageSourceAttributeConstants.WrappingX, wrapping.x); + imageSource.data.setAttribute(ImageSourceAttributeConstants.WrappingY, wrapping.y); + } else { + imageSource.data.setAttribute(ImageSourceAttributeConstants.WrappingX, ImageWrapping.Clamp); + imageSource.data.setAttribute(ImageSourceAttributeConstants.WrappingY, ImageWrapping.Clamp); + } + + TextureLoader.checkImageSizeSupportedAndLog(image); + + image.toBlob((blob) => { + // TODO throw? if blob null? + const url = URL.createObjectURL(blob!); + imageSource.image.onload = () => { + // no longer need to read the blob so it's revoked + URL.revokeObjectURL(url); + imageSource.data = imageSource.image; + imageSource._readyFuture.resolve(imageSource.image); + }; + imageSource.image.src = url; + }); + + return imageSource; + } + static fromSvgString(svgSource: string, options?: ImageSourceOptions) { const blob = new Blob([svgSource], { type: 'image/svg+xml' }); const url = URL.createObjectURL(blob); diff --git a/src/engine/Graphics/TiledAnimation.ts b/src/engine/Graphics/TiledAnimation.ts new file mode 100644 index 000000000..cdddda72a --- /dev/null +++ b/src/engine/Graphics/TiledAnimation.ts @@ -0,0 +1,137 @@ +import { ImageFiltering } from './Filtering'; +import { ImageWrapConfiguration } from './ImageSource'; +import { SourceView, Sprite } from './Sprite'; +import { ImageWrapping } from './Wrapping'; +import { Animation, AnimationOptions } from './Animation'; +import { GraphicOptions } from './Graphic'; +import { TiledSprite } from './TiledSprite'; +import { watch } from '../Util/Watch'; +import { Future } from '../Util/Future'; + +export interface TiledAnimationOptions { + /** + * Animation to tile + */ + animation: Animation; + /** + * Optionally override source view on frame graphics + */ + sourceView?: Partial; + /** + * Optionally override filtering options + */ + filtering?: ImageFiltering; + /** + * Default wrapping is Repeat for TiledAnimation + */ + wrapping?: ImageWrapConfiguration | ImageWrapping; + /** + * Total width in pixels for the tiling to take place + */ + width: number; + /** + * Total height in pixels for the tiling to take place + */ + height: number; +} + +export class TiledAnimation extends Animation { + private _ready = new Future(); + public ready = this._ready.promise; + private _tiledWidth: number = 0; + private _tiledHeight: number = 0; + private _sourceView: Partial = {}; + constructor(options: GraphicOptions & Omit & TiledAnimationOptions) { + super({ + ...options, + frames: options.animation.frames.slice(), + strategy: options.animation.strategy, + frameDuration: options.animation.frameDuration, + speed: options.animation.speed, + reverse: options.animation.isReversed + }); + this._sourceView = { ...options.sourceView }; + this._tiledWidth = options.width; + this._tiledHeight = options.height; + + const promises: Promise[] = []; + for (let i = 0; i < this.frames.length; i++) { + const graphic = this.frames[i].graphic; + if (graphic && graphic instanceof Sprite) { + const tiledSprite = new TiledSprite({ + image: graphic.image, + width: options.width, + height: options.height, + sourceView: { ...graphic.sourceView }, + wrapping: options.wrapping, + filtering: options.filtering + }); + this.frames[i].graphic = tiledSprite; + + // There is a new calc'd sourceView when ready + tiledSprite.ready.then(() => { + tiledSprite.sourceView = { ...tiledSprite.sourceView, ...this._sourceView }; + }); + promises.push(tiledSprite.ready); + } + } + Promise.allSettled(promises).then(() => this._ready.resolve()); + } + + public static fromAnimation(animation: Animation, options?: Omit): TiledAnimation { + return new TiledAnimation({ + width: animation.width, + height: animation.height, + ...options, + animation + }); + } + + private _updateSourceView() { + for (let i = 0; i < this.frames.length; i++) { + const graphic = this.frames[i].graphic; + if (graphic && graphic instanceof Sprite) { + graphic.sourceView = { ...graphic.sourceView, ...this._sourceView }; + } + } + } + + get sourceView(): Partial { + return watch(this._sourceView, () => this._updateSourceView()); + } + + set sourceView(sourceView: Partial) { + this._sourceView = watch(sourceView, () => this._updateSourceView()); + this._updateSourceView(); + } + + private _updateWidthHeight() { + for (let i = 0; i < this.frames.length; i++) { + const graphic = this.frames[i].graphic; + if (graphic && graphic instanceof Sprite) { + graphic.sourceView.height = this._tiledHeight || graphic.height; + graphic.destSize.height = this._tiledHeight || graphic.height; + graphic.sourceView.width = this._tiledWidth || graphic.width; + graphic.destSize.width = this._tiledWidth || graphic.width; + } + } + } + + get width() { + return this._tiledWidth; + } + + get height() { + return this._tiledHeight; + } + + override set width(width: number) { + this._tiledWidth = width; + this._updateWidthHeight(); + } + + override set height(height: number) { + this._tiledHeight = height; + this._updateWidthHeight(); + } +} diff --git a/src/engine/Graphics/TiledSprite.ts b/src/engine/Graphics/TiledSprite.ts new file mode 100644 index 000000000..86445e597 --- /dev/null +++ b/src/engine/Graphics/TiledSprite.ts @@ -0,0 +1,92 @@ +import { Future } from '../Util/Future'; +import { ImageFiltering } from './Filtering'; +import { ImageSource, ImageWrapConfiguration } from './ImageSource'; +import { SourceView, Sprite } from './Sprite'; +import { ImageWrapping } from './Wrapping'; + +export interface TiledSpriteOptions { + image: ImageSource; + /** + * Source view into the {@link ImageSource image} + */ + sourceView?: SourceView; + /** + * Optionally override {@link ImageFiltering filtering} + */ + filtering?: ImageFiltering; + /** + * Optionally override {@link ImageWrapping wrapping} , default wrapping is Repeat for TiledSprite + */ + wrapping?: ImageWrapConfiguration | ImageWrapping; + /** + * Total width in pixels for the tiling to take place over + */ + width: number; + /** + * Total height in pixels for the tiling to take place over + */ + height: number; +} + +export class TiledSprite extends Sprite { + private _ready = new Future(); + public ready = this._ready.promise; + private _options: TiledSpriteOptions; + constructor(options: TiledSpriteOptions) { + super({ + image: options.image, + sourceView: options.sourceView, + destSize: { width: options.width, height: options.height } + }); + this._options = options; + + if (this.image.isLoaded()) { + this._applyTiling(); + } else { + this.image.ready.then(() => this._applyTiling()); + } + } + + public static fromSprite(sprite: Sprite, options?: Partial>): TiledSprite { + return new TiledSprite({ + width: sprite.width, + height: sprite.height, + ...options, + image: sprite.image + }); + } + + private _applyTiling() { + const { width, height, filtering, wrapping } = { ...this._options }; + const spriteCanvas = document.createElement('canvas')!; + spriteCanvas.width = this.sourceView.width; + spriteCanvas.height = this.sourceView.height; + const spriteCtx = spriteCanvas.getContext('2d')!; + // prettier-ignore + spriteCtx.drawImage( + this.image.image, + this.sourceView.x, this.sourceView.y, + this.sourceView.width, this.sourceView.height, + 0, 0, + this.sourceView.width, this.sourceView.height); + + // prettier-ignore + const tiledImageSource = ImageSource.fromHtmlCanvasElement(spriteCanvas, { + wrapping: wrapping ?? ImageWrapping.Repeat, + filtering + }); + if (width) { + this.destSize.width = width; + this.sourceView.width = width; + } + if (height) { + this.destSize.height = height; + this.sourceView.height = height; + } + this.sourceView.x = 0; + this.sourceView.y = 0; + this.image = tiledImageSource; + // this._ready.resolve(); + this.image.ready.then(() => this._ready.resolve()); + } +} diff --git a/src/engine/Graphics/index.ts b/src/engine/Graphics/index.ts index 8e44f558c..4a99d82fc 100644 --- a/src/engine/Graphics/index.ts +++ b/src/engine/Graphics/index.ts @@ -26,6 +26,8 @@ export * from './FontCache'; export * from './SpriteFont'; export * from './Canvas'; export * from './NineSlice'; +export * from './TiledSprite'; +export * from './TiledAnimation'; export * from './Context/ExcaliburGraphicsContext'; export * from './Context/ExcaliburGraphicsContext2DCanvas'; diff --git a/src/engine/Screen.ts b/src/engine/Screen.ts index 6022e6f19..3f9383100 100644 --- a/src/engine/Screen.ts +++ b/src/engine/Screen.ts @@ -854,6 +854,16 @@ export class Screen { return this.resolution.width; } + /** + * Returns the width of the engine's visible drawing surface in pixels including zoom and device pixel ratio. + */ + public get width(): number { + if (this._camera) { + return this.resolution.width / this._camera.zoom; + } + return this.resolution.width; + } + /** * Returns half the width of the engine's visible drawing surface in pixels including zoom and device pixel ratio. */ @@ -871,6 +881,13 @@ export class Screen { return this.resolution.height; } + public get height(): number { + if (this._camera) { + return this.resolution.height / this._camera.zoom; + } + return this.resolution.height; + } + /** * Returns half the height of the engine's visible drawing surface in pixels including zoom and device pixel ratio. */ diff --git a/src/spec/ImageSourceSpec.ts b/src/spec/ImageSourceSpec.ts index 0662230f0..988df4e6a 100644 --- a/src/spec/ImageSourceSpec.ts +++ b/src/spec/ImageSourceSpec.ts @@ -1,7 +1,29 @@ import * as ex from '@excalibur'; import { ImageRenderer } from '../engine/Graphics/Context/image-renderer/image-renderer'; +import { ExcaliburAsyncMatchers, ExcaliburMatchers } from 'excalibur-jasmine'; describe('A ImageSource', () => { + let canvasElement: HTMLCanvasElement; + let ctx: ex.ExcaliburGraphicsContext; + beforeAll(() => { + jasmine.addMatchers(ExcaliburMatchers); + jasmine.addAsyncMatchers(ExcaliburAsyncMatchers); + }); + + beforeEach(() => { + ex.TextureLoader.filtering = ex.ImageFiltering.Pixel; + canvasElement = document.createElement('canvas'); + canvasElement.width = 100; + canvasElement.height = 100; + ctx = new ex.ExcaliburGraphicsContextWebGL({ + canvasElement, + uvPadding: 0.01, + antialiasing: false, + snapToPixel: false, + pixelArtSampler: false + }); + }); + it('exists', () => { expect(ex.ImageSource).toBeDefined(); }); @@ -363,4 +385,61 @@ describe('A ImageSource', () => { await expectAsync(spriteFontImage.load()).toBeRejectedWith("Error loading ImageSource from path '42.png' with error [Not Found]"); }); + + it('can be built from canvas elements', async () => { + const sutCanvas = document.createElement('canvas')!; + sutCanvas.width = 100; + sutCanvas.height = 100; + const sutCtx = sutCanvas.getContext('2d')!; + sutCtx.fillStyle = ex.Color.Black.toRGBA(); + sutCtx.fillRect(20, 20, 50, 50); + + const img = ex.ImageSource.fromHtmlCanvasElement(sutCanvas); + const sprite = img.toSprite(); + await img.ready; + + ctx.clear(); + sprite.draw(ctx, 0, 0); + ctx.flush(); + + await expectAsync(canvasElement).toEqualImage('src/spec/images/GraphicsImageSourceSpec/canvas-image.png'); + }); + + it('can be built from canvas elements with wrapping/filtering specified', async () => { + const sutCanvas = document.createElement('canvas')!; + sutCanvas.width = 100; + sutCanvas.height = 100; + const sutCtx = sutCanvas.getContext('2d')!; + sutCtx.fillStyle = ex.Color.Black.toRGBA(); + sutCtx.fillRect(20, 20, 50, 50); + + const img = ex.ImageSource.fromHtmlCanvasElement(sutCanvas, { + wrapping: ex.ImageWrapping.Repeat, + filtering: ex.ImageFiltering.Pixel + }); + const sprite = img.toSprite(); + await img.ready; + + expect(img.image.getAttribute(ex.ImageSourceAttributeConstants.Filtering)).toBe(ex.ImageFiltering.Pixel); + expect(img.image.getAttribute(ex.ImageSourceAttributeConstants.WrappingX)).toBe(ex.ImageWrapping.Repeat); + expect(img.image.getAttribute(ex.ImageSourceAttributeConstants.WrappingY)).toBe(ex.ImageWrapping.Repeat); + + const img2 = ex.ImageSource.fromHtmlCanvasElement(sutCanvas, { + wrapping: { + x: ex.ImageWrapping.Repeat, + y: ex.ImageWrapping.Clamp + }, + filtering: ex.ImageFiltering.Blended + }); + + expect(img2.image.getAttribute(ex.ImageSourceAttributeConstants.Filtering)).toBe(ex.ImageFiltering.Blended); + expect(img2.image.getAttribute(ex.ImageSourceAttributeConstants.WrappingX)).toBe(ex.ImageWrapping.Repeat); + expect(img2.image.getAttribute(ex.ImageSourceAttributeConstants.WrappingY)).toBe(ex.ImageWrapping.Clamp); + + ctx.clear(); + sprite.draw(ctx, 0, 0); + ctx.flush(); + + await expectAsync(canvasElement).toEqualImage('src/spec/images/GraphicsImageSourceSpec/canvas-image.png'); + }); }); diff --git a/src/spec/TiledAnimationSpec.ts b/src/spec/TiledAnimationSpec.ts new file mode 100644 index 000000000..77b2caaa6 --- /dev/null +++ b/src/spec/TiledAnimationSpec.ts @@ -0,0 +1,73 @@ +import * as ex from '@excalibur'; +import { ExcaliburAsyncMatchers, ExcaliburMatchers } from 'excalibur-jasmine'; + +describe('A TiledAnimation', () => { + let canvasElement: HTMLCanvasElement; + let ctx: ex.ExcaliburGraphicsContext; + beforeAll(() => { + jasmine.addMatchers(ExcaliburMatchers); + jasmine.addAsyncMatchers(ExcaliburAsyncMatchers); + }); + + beforeEach(() => { + ex.TextureLoader.filtering = ex.ImageFiltering.Pixel; + canvasElement = document.createElement('canvas'); + canvasElement.width = 400; + canvasElement.height = 400; + ctx = new ex.ExcaliburGraphicsContextWebGL({ + canvasElement, + uvPadding: 0.01, + antialiasing: false, + snapToPixel: false, + pixelArtSampler: false + }); + }); + + it('exists', () => { + expect(ex.TiledAnimation).toBeDefined(); + }); + + it('can be created', async () => { + const cardsImage = new ex.ImageSource('src/spec/images/TiledAnimationSpec/kenny-cards.png'); + await cardsImage.load(); + const cardSpriteSheet = ex.SpriteSheet.fromImageSource({ + image: cardsImage, + grid: { + rows: 4, + columns: 14, + spriteWidth: 42, + spriteHeight: 60 + }, + spacing: { + originOffset: { x: 11, y: 2 }, + margin: { x: 23, y: 5 } + } + }); + + cardSpriteSheet.sprites.forEach((s) => (s.scale = ex.vec(2, 2))); + const cardAnimation = ex.Animation.fromSpriteSheet(cardSpriteSheet, ex.range(0, 14 * 4), 200); + + const sut = new ex.TiledAnimation({ + animation: cardAnimation, + sourceView: { x: 20, y: 20 }, + width: 200, + height: 200, + wrapping: ex.ImageWrapping.Repeat + }); + + await sut.ready; + + ctx.clear(); + sut.draw(ctx, 20, 20); + ctx.flush(); + + await expectAsync(canvasElement).toEqualImage('src/spec/images/TiledAnimationSpec/tiled.png'); + + sut.tick(200); + ctx.clear(); + sut.draw(ctx, 20, 20); + ctx.flush(); + + await expectAsync(canvasElement).toEqualImage('src/spec/images/TiledAnimationSpec/tiled-2.png'); + }); +}); diff --git a/src/spec/TiledSpriteSpec.ts b/src/spec/TiledSpriteSpec.ts new file mode 100644 index 000000000..3be72b8b8 --- /dev/null +++ b/src/spec/TiledSpriteSpec.ts @@ -0,0 +1,94 @@ +import * as ex from '@excalibur'; +import { ExcaliburAsyncMatchers, ExcaliburMatchers } from 'excalibur-jasmine'; + +describe('A TiledSprite', () => { + let canvasElement: HTMLCanvasElement; + let ctx: ex.ExcaliburGraphicsContext; + beforeAll(() => { + jasmine.addMatchers(ExcaliburMatchers); + jasmine.addAsyncMatchers(ExcaliburAsyncMatchers); + }); + + beforeEach(() => { + ex.TextureLoader.filtering = ex.ImageFiltering.Pixel; + canvasElement = document.createElement('canvas'); + canvasElement.width = 400; + canvasElement.height = 400; + ctx = new ex.ExcaliburGraphicsContextWebGL({ + canvasElement, + uvPadding: 0.01, + antialiasing: false, + snapToPixel: false, + pixelArtSampler: false + }); + }); + + it('exists', () => { + expect(ex.TiledSprite).toBeDefined(); + }); + + it('can be created after load', async () => { + const image = new ex.ImageSource('src/spec/images/TiledSpriteSpec/ground.png'); + + await image.load(); + const sut = new ex.TiledSprite({ + image, + width: 400, + height: 400, + wrapping: { + x: ex.ImageWrapping.Repeat, + y: ex.ImageWrapping.Clamp + } + }); + await sut.ready; + + ctx.clear(); + sut.draw(ctx, 0, 20); + ctx.flush(); + + await expectAsync(canvasElement).toEqualImage('src/spec/images/TiledSpriteSpec/tiled.png'); + }); + + it('can be created before load', async () => { + const image = new ex.ImageSource('src/spec/images/TiledSpriteSpec/ground.png'); + + const sut = new ex.TiledSprite({ + image, + width: 400, + height: 400, + wrapping: { + x: ex.ImageWrapping.Repeat, + y: ex.ImageWrapping.Clamp + } + }); + await image.load(); + await sut.ready; + + ctx.clear(); + sut.draw(ctx, 0, 20); + ctx.flush(); + + await expectAsync(canvasElement).toEqualImage('src/spec/images/TiledSpriteSpec/tiled.png'); + }); + + it('can be from a sprite', async () => { + const image = new ex.ImageSource('src/spec/images/TiledSpriteSpec/ground.png'); + const sourceSprite = image.toSprite(); + sourceSprite.width = 400; + sourceSprite.height = 400; + const sut = ex.TiledSprite.fromSprite(sourceSprite, { + wrapping: { + x: ex.ImageWrapping.Repeat, + y: ex.ImageWrapping.Clamp + } + }); + await image.load(); + await sut.ready; + + ctx.clear(); + sut.draw(ctx, 0, 20); + ctx.flush(); + + await expectAsync(canvasElement).toEqualImage('src/spec/images/TiledSpriteSpec/tiled.png'); + }); +}); diff --git a/src/spec/images/GraphicsImageSourceSpec/canvas-image.png b/src/spec/images/GraphicsImageSourceSpec/canvas-image.png new file mode 100644 index 0000000000000000000000000000000000000000..6887234fdb3a5e90c1cbf747936139436b49c28c GIT binary patch literal 608 zcmeAS@N?(olHy`uVBq!ia0vp^DImV3P85aSW-5 zdpqYKZ&QE(>ryVS%^bfsvcEl}Wl&^Zta!Ms=X&i!UE@C$3W<^O@84d_=V??`OpbZe zt&%dWzlG=X#0f1v$_6>}|2Yd8^&HqxYNg02>6UQAYVL#<9-#PKKM$uiL5XdC>MDwd zIe4O%3s0Jm$kMj&IVv18je7s@U7| z$8m~4P1zj>rDaaw1ts$d0@Ka6sj>*}Y;4)FXQQrS#|d7QBTsvC zIUQZ{6cqLHbG$i}ikLYIE0d$01SU#*bexvHE!5O82#s2&n6m$Dcg>^sE$dJIeIfhX z)w1Rtmq25SfMVr50ZEIisx2HRKewDXuu@Oa#bchj3!7glr=rs*8%3jnRo)zelb&-< zYz&Td>X=~T-@!BIm5@`*lS-$Q2O-l1lvK_OD={zMMXpi%f-J5%6ojw)223&xp00i_ I>zopr03KuWn*aa+ literal 0 HcmV?d00001 diff --git a/src/spec/images/TiledAnimationSpec/kenny-cards.png b/src/spec/images/TiledAnimationSpec/kenny-cards.png new file mode 100644 index 0000000000000000000000000000000000000000..69773596829c04d7db5cde0213b3cb20d592bf6e GIT binary patch literal 3512 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 lp9uUDf&VZ9Z%a#H0iw<6$KRt5Sj!~igZPq z(v=#z6a}QI1VZmE)OW}Gne*X%IM2NE%$)Pioclv&XVzY8?X~w@>wo>$f30wRUG*cZ zCt2y}=#FS$F@|(>^my72#sro;tds2kFZ3RU>MC^ay7*@3=m0tmjPi9~tA!N34!)+O zXZe)&8Ts2%nWPm>eTS$k|K~6l0 zcuZ@Gro*{%&{{qG3_xJEa9NJO`-Ijb|J;Q
lx!7DJZ1|8TjJY>5vpMt8}m_*R)Ew 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 JJC$3H{tL?TCD{M~ literal 0 HcmV?d00001 diff --git a/src/spec/images/TiledSpriteSpec/ground.png b/src/spec/images/TiledSpriteSpec/ground.png new file mode 100644 index 0000000000000000000000000000000000000000..37a5c16207f61df7093cf32a60095439f20d8246 GIT binary patch literal 223 zcmeAS@N?(olHy`uVBq!ia0vp^3P9|@!3HF&`%2dVDaPU;cPEB*=VV?2IWs+7978f1 z-(FtGcgR4b_2K2EVp}=IQktD7>^&{RnSV05NBw}Zr$O=TEM8C6B=aXSCd$6<}DT#Sbko|vugjotKG*-R?PdqTR!$7Q$g$JfBXrn3+6H1u!><=S1`}u z&bfQ}74QAe?UTxm4cfEh8rO#XKGZx^prw85jgsd%8G= zRK&f#wQ*t?t4Q0!YHkD3Bw6VTY&i`l3^y~GZe_|naOMD$47WkB_Xeg8#bphp2Tn+S zd-vg;!MYlLO_{Xwo1gF3|Nm)z|GuAJpMB_NU}R$9*t7J2rb52vvwmR(hlT^$jEsdG zEF1y~6IfVOHcBcvG%x@~n1_nUIrsSgMU&Nd1wnqf z-8cV#|Cj9XkAGes{r=vf@3?$?&Hpo>i|wcTXRM!ZZf}1szy9B!Z}opTk00h+&b}WE z|F<64&3At`vy#lAhq9-iHlMV%dtb4h?@6rv4*64m|5_^?Qj`HAaVGw!2TN~H-|^`| zoyPC~!U7Kk_&{h!sDs7*KYgG7{OebrF5j@T$@=``ijuGMUzh*sx%cP)9iTJZzjK^l z?SJh~`oF8I@9wu)Z!h!l(aY1P{Uh!@{r>3Y>Br9=aVGxb`~UsU`#*11`@j3elKQ)0 zgC;Pt1uE9Fo|E6d`-jN2>b<+30^`}?fgKZ5k-Y6@prnMDMg9Mm-1dj0>=;%9lfwco z4v(qE?hOo$g^Y}n_6)B_#YV$mG#!lQh0*e0v`ieW7e?#G(e}Y;8)>vnINBy0Z4-{R f2~paFe|T-26c%>0ek%ZuVK8{Q`njxgN@xNA(rwIN literal 0 HcmV?d00001