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

excaliburjs / Excalibur / 19931642720

04 Dec 2025 02:02PM UTC coverage: 88.44% (-0.2%) from 88.653%
19931642720

Pull #3614

github

web-flow
Merge dbf04111a into 1be15c84b
Pull Request #3614: [feat] Add methods to retrieve parsed sprite and image

5299 of 7257 branches covered (73.02%)

0 of 40 new or added lines in 1 file covered. (0.0%)

1 existing line in 1 file now uncovered.

14673 of 16591 relevant lines covered (88.44%)

24556.28 hits per line

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

54.64
/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,035✔
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,035✔
106
    this.sprites = sprites;
1,035✔
107
    this.rows = rows ?? 1;
1,035✔
108
    this.columns = columns ?? this.sprites.length;
1,035✔
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
  public async getParsedSprite(x: number, y: number): Promise<Sprite> {
NEW
182
    if (x >= this.columns || x < 0) {
×
NEW
183
      throw Error(`No sprite exists in the SpriteSheet at (${x}, ${y}), x: ${x} should be between 0 and ${this.columns - 1} columns`);
×
184
    }
NEW
185
    if (y >= this.rows || y < 0) {
×
NEW
186
      throw Error(`No sprite exists in the SpriteSheet at (${x}, ${y}), y: ${y} should be between 0 and ${this.rows - 1} rows`);
×
187
    }
NEW
188
    const spriteIndex = x + y * this.columns;
×
NEW
189
    const sprite = this.sprites[spriteIndex];
×
NEW
190
    const cnv = document.createElement('canvas');
×
NEW
191
    const ctx = cnv.getContext('2d');
×
NEW
192
    cnv.width = sprite.width;
×
NEW
193
    cnv.height = sprite.height;
×
194

NEW
195
    if (!sprite) {
×
NEW
196
      throw Error(`Invalid sprite coordinates (${x}, ${y})`);
×
197
    }
NEW
198
    if (!ctx) {
×
NEW
199
      throw Error('Unable to create canvas context');
×
200
    }
201

NEW
202
    ctx.drawImage(
×
203
      sprite.image.image,
204
      sprite.sourceView.x,
205
      sprite.sourceView.y,
206
      sprite.sourceView.width,
207
      sprite.sourceView.height,
208
      0,
209
      0,
210
      sprite.sourceView.width,
211
      sprite.sourceView.height
212
    );
213

NEW
214
    const imgSrc = new ImageSource(cnv.toDataURL());
×
NEW
215
    await imgSrc.load();
×
216

NEW
217
    return new Sprite({
×
218
      image: imgSrc,
219
      sourceView: {
220
        x: 0,
221
        y: 0,
222
        width: sprite.width,
223
        height: sprite.height
224
      },
225
      destSize: {
226
        width: sprite.width,
227
        height: sprite.height
228
      }
229
    });
230
  }
231

232
  public async getParsedImage(x: number, y: number): Promise<HTMLImageElement> {
NEW
233
    if (x >= this.columns || x < 0) {
×
NEW
234
      throw Error(`No sprite exists in the SpriteSheet at (${x}, ${y}), x: ${x} should be between 0 and ${this.columns - 1} columns`);
×
235
    }
NEW
236
    if (y >= this.rows || y < 0) {
×
NEW
237
      throw Error(`No sprite exists in the SpriteSheet at (${x}, ${y}), y: ${y} should be between 0 and ${this.rows - 1} rows`);
×
238
    }
NEW
239
    const spriteIndex = x + y * this.columns;
×
NEW
240
    const sprite = this.sprites[spriteIndex];
×
NEW
241
    const cnv = document.createElement('canvas');
×
NEW
242
    const ctx = cnv.getContext('2d');
×
NEW
243
    cnv.width = sprite.width;
×
NEW
244
    cnv.height = sprite.height;
×
245

NEW
246
    if (!sprite) {
×
NEW
247
      throw Error(`Invalid sprite coordinates (${x}, ${y})`);
×
248
    }
NEW
249
    if (!ctx) {
×
NEW
250
      throw Error('Unable to create canvas context');
×
251
    }
252

NEW
253
    ctx.drawImage(
×
254
      sprite.image.image,
255
      sprite.sourceView.x,
256
      sprite.sourceView.y,
257
      sprite.sourceView.width,
258
      sprite.sourceView.height,
259
      0,
260
      0,
261
      sprite.sourceView.width,
262
      sprite.sourceView.height
263
    );
264

NEW
265
    const imgSrc = new Image(sprite.width, sprite.height);
×
NEW
266
    imgSrc.src = cnv.toDataURL();
×
267

NEW
268
    return new Promise((resolve, reject) => {
×
NEW
269
      imgSrc.onload = () => {
×
NEW
270
        resolve(imgSrc);
×
271
      };
NEW
272
      imgSrc.onerror = (e) => {
×
NEW
273
        reject(e);
×
274
      };
275
    });
276
  }
277

278
  /**
279
   * Create a sprite sheet from a sparse set of {@apilink SourceView} rectangles
280
   * @param options
281
   */
282
  public static fromImageSourceWithSourceViews(options: SpriteSheetSparseOptions): SpriteSheet {
283
    const sprites: Sprite[] = options.sourceViews.map((sourceView) => {
1✔
284
      return new Sprite({
2✔
285
        image: options.image,
286
        sourceView
287
      });
288
    });
289
    return new SpriteSheet({ sprites });
1✔
290
  }
291

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

333
    if (originOffset instanceof Vector) {
1,030✔
334
      neworiginOffset = { x: originOffset.x, y: originOffset.y };
1✔
335
    } else {
336
      if (originOffset) {
1,029✔
337
        neworiginOffset = { x: originOffset.x as number, y: originOffset.y as number };
998✔
338
      }
339
    }
340

341
    if (margin instanceof Vector) {
1,030✔
342
      newmargin = { x: margin.x, y: margin.y };
1✔
343
    } else {
344
      if (margin) {
1,029✔
345
        newmargin = { x: margin.x as number, y: margin.y as number };
998✔
346
      }
347
    }
348

349
    const offsetDefaults = { x: 0, y: 0, ...neworiginOffset };
1,030✔
350
    const marginDefaults = { x: 0, y: 0, ...newmargin };
1,030✔
351
    for (let x = 0; x < cols; x++) {
1,030✔
352
      for (let y = 0; y < rows; y++) {
16,357✔
353
        sprites[x + y * cols] = new Sprite({
129,945✔
354
          image: image,
355
          sourceView: {
356
            x: x * spriteWidth + marginDefaults.x * x + offsetDefaults.x,
357
            y: y * spriteHeight + marginDefaults.y * y + offsetDefaults.y,
358
            width: spriteWidth,
359
            height: spriteHeight
360
          },
361
          destSize: { height: spriteHeight, width: spriteWidth }
362
        });
363
      }
364
    }
365
    return new SpriteSheet({
1,030✔
366
      sprites: sprites,
367
      rows: rows,
368
      columns: cols
369
    });
370
  }
371

372
  public clone(): SpriteSheet {
373
    return new SpriteSheet({
1✔
374
      sprites: this.sprites.map((sprite) => sprite.clone()),
1✔
375
      rows: this.rows,
376
      columns: this.columns
377
    });
378
  }
379
}
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