Skip to content

Commit

Permalink
feat: Tiling Sprites and Animations (#3309)
Browse files Browse the repository at this point in the history
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
  });
  ```
  • Loading branch information
eonarheim authored Dec 4, 2024
1 parent e589ca1 commit a47b204
Show file tree
Hide file tree
Showing 22 changed files with 665 additions and 2 deletions.
22 changes: 22 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
Binary file added sandbox/tests/tiling/desert.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added sandbox/tests/tiling/ground.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
13 changes: 13 additions & 0 deletions sandbox/tests/tiling/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="ie=edge" />
<title>Tiling</title>
</head>
<body>
<script src="../../lib/excalibur.js"></script>
<script src="index.js"></script>
</body>
</html>
81 changes: 81 additions & 0 deletions sandbox/tests/tiling/index.ts
Original file line number Diff line number Diff line change
@@ -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
});
});
Binary file added sandbox/tests/tiling/kenny-cards.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
5 changes: 5 additions & 0 deletions src/engine/Graphics/Animation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down
4 changes: 2 additions & 2 deletions src/engine/Graphics/Context/texture-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down Expand Up @@ -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;
}
Expand Down
48 changes: 48 additions & 0 deletions src/engine/Graphics/ImageSource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,54 @@ export class ImageSource implements Loadable<HTMLImageElement> {
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);
Expand Down
137 changes: 137 additions & 0 deletions src/engine/Graphics/TiledAnimation.ts
Original file line number Diff line number Diff line change
@@ -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<SourceView>;
/**
* 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<void>();
public ready = this._ready.promise;
private _tiledWidth: number = 0;
private _tiledHeight: number = 0;
private _sourceView: Partial<SourceView> = {};
constructor(options: GraphicOptions & Omit<AnimationOptions, 'frames'> & 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<void>[] = [];
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<TiledAnimationOptions, 'animation'>): 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<SourceView> {
return watch(this._sourceView, () => this._updateSourceView());
}

set sourceView(sourceView: Partial<SourceView>) {
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();
}
}
Loading

0 comments on commit a47b204

Please sign in to comment.