• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

excaliburjs / Excalibur / 19990896186

06 Dec 2025 04:03PM UTC coverage: 88.636% (-0.02%) from 88.653%
19990896186

Pull #3598

github

web-flow
Merge 8e811c0e8 into 0f899e40c
Pull Request #3598: Docs migrate playground

5316 of 7259 branches covered (73.23%)

14718 of 16605 relevant lines covered (88.64%)

24703.63 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

86.6
/src/engine/Graphics/SpriteSheet.ts
1
import { ImageSource } from './ImageSource';
2
import type { SourceView } from './Sprite';
3
import { Sprite } from './Sprite';
4
import type { GraphicOptions } from './Graphic';
5
import type { TiledSpriteOptions } from './TiledSprite';
6
import { TiledSprite } from './TiledSprite';
7
import { Vector } from '../Math/vector';
8

9
/**
10
 * Specify sprite sheet spacing options, useful if your sprites are not tightly packed
11
 * and have space between them.
12
 */
13
export interface SpriteSheetSpacingDimensions {
14
  /**
15
   * The starting point to offset and start slicing the sprite sheet from the top left of the image.
16
   * Default is (0, 0)
17
   */
18
  originOffset?: { x?: number; y?: number } | Vector;
19

20
  /**
21
   * The margin between sprites.
22
   * Default is (0, 0)
23
   */
24
  margin?: { x?: number; y?: number } | Vector;
25
}
26

27
/**
28
 * Sprite sheet options for slicing up images
29
 */
30
export interface SpriteSheetGridOptions {
31
  /**
32
   * Source image to use for each sprite
33
   */
34
  image: ImageSource;
35
  /**
36
   * Grid definition for the sprite sheet
37
   */
38
  grid: {
39
    /**
40
     * Number of rows in the sprite sheet
41
     */
42
    rows: number;
43
    /**
44
     * Number of columns in the sprite sheet
45
     */
46
    columns: number;
47
    /**
48
     * Width of each individual sprite
49
     */
50
    spriteWidth: number;
51
    /**
52
     * Height of each individual sprite
53
     */
54
    spriteHeight: number;
55
  };
56
  /**
57
   * Optionally specify any spacing information between sprites
58
   */
59
  spacing?: SpriteSheetSpacingDimensions;
60
}
61

62
export interface SpriteSheetSparseOptions {
63
  /**
64
   * Source image to use for each sprite
65
   */
66
  image: ImageSource;
67
  /**
68
   * List of source view rectangles to create a sprite sheet from
69
   */
70
  sourceViews: SourceView[];
71
}
72

73
export interface SpriteSheetOptions {
74
  /**
75
   * Source sprites for the sprite sheet
76
   */
77
  sprites: Sprite[];
78
  /**
79
   * Optionally specify the number of rows in a sprite sheet (default 1 row)
80
   */
81
  rows?: number;
82
  /**
83
   * Optionally specify the number of columns in a sprite sheet (default sprites.length)
84
   */
85
  columns?: number;
86
}
87

88
export interface GetSpriteOptions extends GraphicOptions {}
89

90
/**
91
 * Represents a collection of sprites from a source image with some organization in a grid
92
 */
93
export class SpriteSheet {
94
  public readonly sprites: Sprite[] = [];
1,038✔
95
  public readonly rows: number;
96
  public readonly columns: number;
97

98
  /**
99
   * Build a new sprite sheet from a list of sprites
100
   *
101
   * Use {@apilink SpriteSheet.fromImageSource} to create a SpriteSheet from an {@apilink ImageSource} organized in a grid
102
   * @param options
103
   */
104
  constructor(options: SpriteSheetOptions) {
105
    const { sprites, rows, columns } = options;
1,038✔
106
    this.sprites = sprites;
1,038✔
107
    this.rows = rows ?? 1;
1,038✔
108
    this.columns = columns ?? this.sprites.length;
1,038✔
109
  }
110

111
  /**
112
   * Find a sprite by their x/y integer coordinates in the SpriteSheet, for example `getSprite(0, 0)` is the {@apilink Sprite} in the top-left
113
   * and `getSprite(1, 0)` is the sprite one to the right.
114
   * @param x
115
   * @param y
116
   */
117
  public getSprite(x: number, y: number, options?: GetSpriteOptions): Sprite {
118
    if (x >= this.columns || x < 0) {
825✔
119
      throw Error(`No sprite exists in the SpriteSheet at (${x}, ${y}), x: ${x} should be between 0 and ${this.columns - 1} columns`);
2✔
120
    }
121
    if (y >= this.rows || y < 0) {
823✔
122
      throw Error(`No sprite exists in the SpriteSheet at (${x}, ${y}), y: ${y} should be between 0 and ${this.rows - 1} rows`);
2✔
123
    }
124
    const spriteIndex = x + y * this.columns;
821✔
125
    const sprite = this.sprites[spriteIndex];
821✔
126
    if (sprite) {
821!
127
      if (options) {
821✔
128
        const spriteWithOptions = sprite.clone();
3✔
129
        spriteWithOptions.flipHorizontal = options.flipHorizontal ?? spriteWithOptions.flipHorizontal;
3✔
130
        spriteWithOptions.flipVertical = options.flipVertical ?? spriteWithOptions.flipVertical;
3✔
131
        spriteWithOptions.width = options.width ?? spriteWithOptions.width;
3✔
132
        spriteWithOptions.height = options.height ?? spriteWithOptions.height;
3✔
133
        spriteWithOptions.rotation = options.rotation ?? spriteWithOptions.rotation;
3✔
134
        spriteWithOptions.scale = options.scale ?? spriteWithOptions.scale;
3✔
135
        spriteWithOptions.opacity = options.opacity ?? spriteWithOptions.opacity;
3✔
136
        spriteWithOptions.tint = options.tint ?? spriteWithOptions.tint;
3✔
137
        spriteWithOptions.origin = options.origin ?? spriteWithOptions.origin;
3✔
138
        return spriteWithOptions;
3✔
139
      }
140
      return sprite;
818✔
141
    }
142
    throw Error(`Invalid sprite coordinates (${x}, ${y})`);
×
143
  }
144

145
  /**
146
   * Find a sprite by their x/y integer coordinates in the SpriteSheet and configures tiling to repeat by default,
147
   * for example `getTiledSprite(0, 0)` is the {@apilink TiledSprite} in the top-left
148
   * and `getTiledSprite(1, 0)` is the sprite one to the right.
149
   *
150
   * Example:
151
   *
152
   * ```typescript
153
   * spriteSheet.getTiledSprite(1, 0, {
154
   * width: game.screen.width,
155
   * height: 200,
156
   * wrapping: {
157
   * x: ex.ImageWrapping.Repeat,
158
   * y: ex.ImageWrapping.Clamp
159
   * }
160
   * });
161
   * ```
162
   * @param x
163
   * @param y
164
   * @param options
165
   */
166
  public getTiledSprite(x: number, y: number, options?: Partial<Omit<TiledSpriteOptions & GraphicOptions, 'image'>>): TiledSprite {
167
    if (x >= this.columns || x < 0) {
1!
168
      throw Error(`No sprite exists in the SpriteSheet at (${x}, ${y}), x: ${x} should be between 0 and ${this.columns - 1} columns`);
×
169
    }
170
    if (y >= this.rows || y < 0) {
1!
171
      throw Error(`No sprite exists in the SpriteSheet at (${x}, ${y}), y: ${y} should be between 0 and ${this.rows - 1} rows`);
×
172
    }
173
    const spriteIndex = x + y * this.columns;
1✔
174
    const sprite = this.sprites[spriteIndex];
1✔
175
    if (sprite) {
1!
176
      return TiledSprite.fromSprite(sprite, options);
1✔
177
    }
178
    throw Error(`Invalid sprite coordinates (${x}, ${y})`);
×
179
  }
180

181
  /**
182
   * Returns a sprite that has a new backing image the exact size of the sprite that tha is a copy of the original sprite slice.
183
   *
184
   * Useful if you need to apply effects, manipulate, or mutate the image and you don't want to disturb the original sprite sheet.
185
   *
186
   */
187
  public async getSpriteAsStandalone(x: number, y: number): Promise<Sprite> {
188
    if (x >= this.columns || x < 0) {
1!
189
      throw Error(`No sprite exists in the SpriteSheet at (${x}, ${y}), x: ${x} should be between 0 and ${this.columns - 1} columns`);
×
190
    }
191
    if (y >= this.rows || y < 0) {
1!
192
      throw Error(`No sprite exists in the SpriteSheet at (${x}, ${y}), y: ${y} should be between 0 and ${this.rows - 1} rows`);
×
193
    }
194
    const spriteIndex = x + y * this.columns;
1✔
195
    const sprite = this.sprites[spriteIndex];
1✔
196
    const cnv = document.createElement('canvas');
1✔
197
    const ctx = cnv.getContext('2d');
1✔
198
    cnv.width = sprite.width;
1✔
199
    cnv.height = sprite.height;
1✔
200

201
    if (!sprite) {
1!
202
      throw Error(`Invalid sprite coordinates (${x}, ${y})`);
×
203
    }
204
    if (!ctx) {
1!
205
      throw Error('Unable to create canvas context');
×
206
    }
207

208
    ctx.drawImage(
1✔
209
      sprite.image.image,
210
      sprite.sourceView.x,
211
      sprite.sourceView.y,
212
      sprite.sourceView.width,
213
      sprite.sourceView.height,
214
      0,
215
      0,
216
      sprite.sourceView.width,
217
      sprite.sourceView.height
218
    );
219

220
    const imgSrc = new ImageSource(cnv.toDataURL());
1✔
221
    await imgSrc.load();
1✔
222

223
    return new Sprite({
1✔
224
      image: imgSrc,
225
      sourceView: {
226
        x: 0,
227
        y: 0,
228
        width: sprite.width,
229
        height: sprite.height
230
      },
231
      destSize: {
232
        width: sprite.width,
233
        height: sprite.height
234
      }
235
    });
236
  }
237

238
  /**
239
   * Returns a new image exact size and copy of the original sprite slice.
240
   *
241
   * Useful if you need to apply effects, manipulate, or mutate the image and you don't want to disturb the original sprite sheet.
242
   */
243
  public async getSpriteAsImage(x: number, y: number): Promise<HTMLImageElement> {
244
    if (x >= this.columns || x < 0) {
1!
245
      throw Error(`No sprite exists in the SpriteSheet at (${x}, ${y}), x: ${x} should be between 0 and ${this.columns - 1} columns`);
×
246
    }
247
    if (y >= this.rows || y < 0) {
1!
248
      throw Error(`No sprite exists in the SpriteSheet at (${x}, ${y}), y: ${y} should be between 0 and ${this.rows - 1} rows`);
×
249
    }
250
    const spriteIndex = x + y * this.columns;
1✔
251
    const sprite = this.sprites[spriteIndex];
1✔
252
    const cnv = document.createElement('canvas');
1✔
253
    const ctx = cnv.getContext('2d');
1✔
254
    cnv.width = sprite.width;
1✔
255
    cnv.height = sprite.height;
1✔
256

257
    if (!sprite) {
1!
258
      throw Error(`Invalid sprite coordinates (${x}, ${y})`);
×
259
    }
260
    if (!ctx) {
1!
261
      throw Error('Unable to create canvas context');
×
262
    }
263

264
    ctx.drawImage(
1✔
265
      sprite.image.image,
266
      sprite.sourceView.x,
267
      sprite.sourceView.y,
268
      sprite.sourceView.width,
269
      sprite.sourceView.height,
270
      0,
271
      0,
272
      sprite.sourceView.width,
273
      sprite.sourceView.height
274
    );
275

276
    const imgSrc = new Image(sprite.width, sprite.height);
1✔
277
    imgSrc.src = cnv.toDataURL();
1✔
278

279
    return await new Promise((resolve, reject) => {
1✔
280
      imgSrc.onload = () => {
1✔
281
        resolve(imgSrc);
1✔
282
      };
283
      imgSrc.onerror = (e) => {
1✔
284
        reject(e);
×
285
      };
286
    });
287
  }
288

289
  /**
290
   * Create a sprite sheet from a sparse set of {@apilink SourceView} rectangles
291
   * @param options
292
   */
293
  public static fromImageSourceWithSourceViews(options: SpriteSheetSparseOptions): SpriteSheet {
294
    const sprites: Sprite[] = options.sourceViews.map((sourceView) => {
1✔
295
      return new Sprite({
2✔
296
        image: options.image,
297
        sourceView
298
      });
299
    });
300
    return new SpriteSheet({ sprites });
1✔
301
  }
302

303
  /**
304
   * Create a SpriteSheet from an {@apilink ImageSource} organized in a grid
305
   *
306
   * Example:
307
   * ```
308
   * const spriteSheet = SpriteSheet.fromImageSource({
309
   *   image: imageSource,
310
   *   grid: {
311
   *     rows: 5,
312
   *     columns: 2,
313
   *     spriteWidth: 32, // pixels
314
   *     spriteHeight: 32 // pixels
315
   *   },
316
   *   // Optionally specify spacing
317
   *   spacing: {
318
   *     // pixels from the top left to start the sprite parsing
319
   *     originOffset: {
320
   *       x: 5,
321
   *       y: 5
322
   *     },
323
   *     // pixels between each sprite while parsing
324
   *     margin: {
325
   *       x: 1,
326
   *       y: 1
327
   *     }
328
   *   }
329
   * })
330
   * ```
331
   * @param options
332
   */
333
  public static fromImageSource(options: SpriteSheetGridOptions): SpriteSheet {
334
    const sprites: Sprite[] = [];
1,033✔
335
    options.spacing = options.spacing ?? {};
1,033✔
336
    const {
337
      image,
338
      grid: { rows, columns: cols, spriteWidth, spriteHeight },
339
      spacing: { originOffset, margin }
340
    } = options;
1,033✔
341
    let newmargin: { x: number; y: number } | undefined;
342
    let neworiginOffset: { x: number; y: number } | undefined;
343

344
    if (originOffset instanceof Vector) {
1,033✔
345
      neworiginOffset = { x: originOffset.x, y: originOffset.y };
1✔
346
    } else {
347
      if (originOffset) {
1,032✔
348
        neworiginOffset = { x: originOffset.x as number, y: originOffset.y as number };
1,001✔
349
      }
350
    }
351

352
    if (margin instanceof Vector) {
1,033✔
353
      newmargin = { x: margin.x, y: margin.y };
1✔
354
    } else {
355
      if (margin) {
1,032✔
356
        newmargin = { x: margin.x as number, y: margin.y as number };
1,001✔
357
      }
358
    }
359

360
    const offsetDefaults = { x: 0, y: 0, ...neworiginOffset };
1,033✔
361
    const marginDefaults = { x: 0, y: 0, ...newmargin };
1,033✔
362
    for (let x = 0; x < cols; x++) {
1,033✔
363
      for (let y = 0; y < rows; y++) {
16,401✔
364
        sprites[x + y * cols] = new Sprite({
130,185✔
365
          image: image,
366
          sourceView: {
367
            x: x * spriteWidth + marginDefaults.x * x + offsetDefaults.x,
368
            y: y * spriteHeight + marginDefaults.y * y + offsetDefaults.y,
369
            width: spriteWidth,
370
            height: spriteHeight
371
          },
372
          destSize: { height: spriteHeight, width: spriteWidth }
373
        });
374
      }
375
    }
376
    return new SpriteSheet({
1,033✔
377
      sprites: sprites,
378
      rows: rows,
379
      columns: cols
380
    });
381
  }
382

383
  public clone(): SpriteSheet {
384
    return new SpriteSheet({
1✔
385
      sprites: this.sprites.map((sprite) => sprite.clone()),
1✔
386
      rows: this.rows,
387
      columns: this.columns
388
    });
389
  }
390
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc