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