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 000000000..184c4a062 Binary files /dev/null and b/sandbox/tests/tiling/desert.png differ diff --git a/sandbox/tests/tiling/ground.png b/sandbox/tests/tiling/ground.png new file mode 100644 index 000000000..37a5c1620 Binary files /dev/null and b/sandbox/tests/tiling/ground.png differ diff --git a/sandbox/tests/tiling/index.html b/sandbox/tests/tiling/index.html new file mode 100644 index 000000000..44b922c69 --- /dev/null +++ b/sandbox/tests/tiling/index.html @@ -0,0 +1,13 @@ + + + + + + + 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 000000000..697735968 Binary files /dev/null and b/sandbox/tests/tiling/kenny-cards.png differ diff --git a/src/engine/Graphics/Animation.ts b/src/engine/Graphics/Animation.ts index 9ae033d35..9286d0759 100644 --- a/src/engine/Graphics/Animation.ts +++ b/src/engine/Graphics/Animation.ts @@ -344,6 +344,11 @@ export class Animation extends Graphic implements HasTick { } private _reversed = false; + + public get isReversed() { + return this._reversed; + } + /** * Reverses the play direction of the Animation, this preserves the current frame */ diff --git a/src/engine/Graphics/Context/texture-loader.ts b/src/engine/Graphics/Context/texture-loader.ts index 61c04cbc9..14bb09c66 100644 --- a/src/engine/Graphics/Context/texture-loader.ts +++ b/src/engine/Graphics/Context/texture-loader.ts @@ -20,7 +20,7 @@ export class TextureLoader { ) { this._gl = gl; TextureLoader._MAX_TEXTURE_SIZE = gl.getParameter(gl.MAX_TEXTURE_SIZE); - if (_garbageCollector) { + if (this._garbageCollector) { TextureLoader._LOGGER.debug('WebGL Texture collection interval:', this._garbageCollector.collectionInterval); this._garbageCollector.garbageCollector?.registerCollector('texture', this._garbageCollector.collectionInterval, this._collect); } @@ -153,7 +153,7 @@ export class TextureLoader { gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image); - this._textureMap.set(image, tex); + this._textureMap.set(image, tex!); this._garbageCollector?.garbageCollector.addCollectableResource('texture', image); return tex; } diff --git a/src/engine/Graphics/ImageSource.ts b/src/engine/Graphics/ImageSource.ts index 09a1375b0..5f843fef4 100644 --- a/src/engine/Graphics/ImageSource.ts +++ b/src/engine/Graphics/ImageSource.ts @@ -154,6 +154,54 @@ export class ImageSource implements Loadable { 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 000000000..6887234fd Binary files /dev/null and b/src/spec/images/GraphicsImageSourceSpec/canvas-image.png differ diff --git a/src/spec/images/TiledAnimationSpec/kenny-cards.png b/src/spec/images/TiledAnimationSpec/kenny-cards.png new file mode 100644 index 000000000..697735968 Binary files /dev/null and b/src/spec/images/TiledAnimationSpec/kenny-cards.png differ diff --git a/src/spec/images/TiledAnimationSpec/tiled-2.png b/src/spec/images/TiledAnimationSpec/tiled-2.png new file mode 100644 index 000000000..2a24fb673 Binary files /dev/null and b/src/spec/images/TiledAnimationSpec/tiled-2.png differ diff --git a/src/spec/images/TiledAnimationSpec/tiled.png b/src/spec/images/TiledAnimationSpec/tiled.png new file mode 100644 index 000000000..374c8854f Binary files /dev/null and b/src/spec/images/TiledAnimationSpec/tiled.png differ diff --git a/src/spec/images/TiledSpriteSpec/ground.png b/src/spec/images/TiledSpriteSpec/ground.png new file mode 100644 index 000000000..37a5c1620 Binary files /dev/null and b/src/spec/images/TiledSpriteSpec/ground.png differ diff --git a/src/spec/images/TiledSpriteSpec/tiled.png b/src/spec/images/TiledSpriteSpec/tiled.png new file mode 100644 index 000000000..35285a444 Binary files /dev/null and b/src/spec/images/TiledSpriteSpec/tiled.png differ