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

excaliburjs / Excalibur / 20140052979

11 Dec 2025 04:26PM UTC coverage: 87.965% (-0.7%) from 88.636%
20140052979

Pull #3617

github

web-flow
Merge 9d6baeeab into 548f5e4e7
Pull Request #3617: [feat] extend the components class to add json/serialize/deserialize to component properties

5435 of 7543 branches covered (72.05%)

223 of 379 new or added lines in 6 files covered. (58.84%)

1 existing line in 1 file now uncovered.

14940 of 16984 relevant lines covered (87.97%)

24133.72 hits per line

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

83.64
/src/engine/Graphics/GraphicsComponent.ts
1
import { Vector, vec } from '../Math/vector';
2
import { Graphic } from './Graphic';
3
import type { HasTick } from './Animation';
4
import type { ExcaliburGraphicsContext } from './Context/ExcaliburGraphicsContext';
5
import { BoundingBox } from '../Collision/Index';
6
import { Component } from '../EntityComponentSystem/Component';
7
import type { Material } from './Context/material';
8
import { Logger } from '../Util/Log';
9
import { WatchVector } from '../Math/watch-vector';
10
import { TransformComponent } from '../EntityComponentSystem';
11
import { GraphicsGroup } from '../Graphics/GraphicsGroup';
12
import { Color } from '../Color';
13
import { Raster } from './Raster';
14
import { Text } from './Text';
15

16
/**
17
 * Type guard for checking if a Graphic HasTick (used for graphics that change over time like animations)
18
 * @param graphic
19
 */
20
export function hasGraphicsTick(graphic: Graphic): graphic is Graphic & HasTick {
21
  return !!(graphic as unknown as HasTick).tick;
1,786✔
22
}
23
export interface GraphicsShowOptions {
24
  offset?: Vector;
25
  anchor?: Vector;
26
}
27
// ============================================================================
28
// GraphicsComponent Serialization Data Structure
29
// ============================================================================
30

31
export interface GraphicsComponentData {
32
  type: string;
33
  current: string;
34
  graphicRefs: string[]; // List of graphic IDs used by this component
35
  options: {
36
    [name: string]:
37
      | {
38
          offset?: { x: number; y: number };
39
          anchor?: { x: number; y: number };
40
        }
41
      | undefined;
42
  };
43
  isVisible: boolean;
44
  opacity: number;
45
  offset: { x: number; y: number };
46
  anchor: { x: number; y: number };
47
  color?: { r: number; g: number; b: number; a: number };
48
  flipHorizontal: boolean;
49
  flipVertical: boolean;
50
  copyGraphics: boolean;
51
  forceOnScreen: boolean;
52
  tint?: { r: number; g: number; b: number; a: number };
53
}
54
export interface GraphicsComponentOptions {
55
  onPostDraw?: (ex: ExcaliburGraphicsContext, elapsed: number) => void;
56
  onPreDraw?: (ex: ExcaliburGraphicsContext, elapsed: number) => void;
57
  onPreTransformDraw?: (ex: ExcaliburGraphicsContext, elapsed: number) => void;
58
  onPostTransformDraw?: (ex: ExcaliburGraphicsContext, elapsed: number) => void;
59

60
  /**
61
   * Name of current graphic to use
62
   */
63
  current?: string;
64

65
  /**
66
   * Optionally set the color of the graphics component
67
   */
68
  color?: Color;
69

70
  /**
71
   * Optionally set a material to use on the graphic
72
   */
73
  material?: Material;
74

75
  /**
76
   * Optionally copy instances of graphics by calling .clone(), you may set this to false to avoid sharing graphics when added to the
77
   * component for performance reasons. By default graphics are not copied and are shared when added to the component.
78
   */
79
  copyGraphics?: boolean;
80

81
  /**
82
   * Optional visible flag, if the graphics component is not visible it will not be displayed
83
   */
84
  visible?: boolean;
85

86
  /**
87
   * Optional opacity
88
   */
89
  opacity?: number;
90

91
  /**
92
   * List of graphics and optionally the options per graphic
93
   */
94
  graphics?: { [graphicName: string]: Graphic | { graphic: Graphic; options?: GraphicsShowOptions | undefined } };
95

96
  /**
97
   * Optional offset in absolute pixels to shift all graphics in this component from each graphic's anchor (default is top left corner)
98
   */
99
  offset?: Vector;
100

101
  /**
102
   * Optional anchor
103
   */
104
  anchor?: Vector;
105
}
106

107
/**
108
 * Component to manage drawings, using with the position component
109
 */
110
export class GraphicsComponent extends Component {
111
  private _logger = Logger.getInstance();
7,389✔
112

113
  private _current: string = 'default';
7,389✔
114
  private _graphics: Record<string, Graphic> = {};
7,389✔
115
  private _options: Record<string, GraphicsShowOptions | undefined> = {};
7,389✔
116

117
  public material: Material | null = null;
7,389✔
118

119
  /**
120
   * Draws after the entity transform has been applied, but before graphics component graphics have been drawn
121
   */
122
  public onPreDraw?: (ctx: ExcaliburGraphicsContext, elapsed: number) => void;
123

124
  /**
125
   * Draws after the entity transform has been applied, and after graphics component graphics has been drawn
126
   */
127
  public onPostDraw?: (ctx: ExcaliburGraphicsContext, elapsed: number) => void;
128

129
  /**
130
   * Draws before the entity transform has been applied before any any graphics component drawing
131
   */
132
  public onPreTransformDraw?: (ctx: ExcaliburGraphicsContext, elapsed: number) => void;
133

134
  /**
135
   * Draws after the entity transform has been applied, and after all graphics component drawing
136
   */
137
  public onPostTransformDraw?: (ctx: ExcaliburGraphicsContext, elapsed: number) => void;
138
  private _color?: Color;
139

140
  /**
141
   * Sets or gets wether any drawing should be visible in this component
142
   * @deprecated use isVisible
143
   */
144
  public get visible(): boolean {
145
    return this.isVisible;
15✔
146
  }
147

148
  /**
149
   * Sets or gets wether any drawing should be visible in this component
150
   * @deprecated use isVisible
151
   */
152
  public set visible(val: boolean) {
153
    this.isVisible = val;
7✔
154
  }
155

156
  /**
157
   * Sets or gets wether any drawing should be visible in this component
158
   */
159
  public isVisible: boolean = true;
7,389✔
160

161
  /**
162
   * Optionally force the graphic onscreen, default false. Not recommend to use for perf reasons, only if you known what you're doing.
163
   */
164
  public forceOnScreen: boolean = false;
7,389✔
165

166
  /**
167
   * Sets or gets wither all drawings should have an opacity applied
168
   */
169
  public opacity: number = 1;
7,389✔
170

171
  private _offset: Vector = new WatchVector(Vector.Zero, () => this.recalculateBounds());
7,389✔
172

173
  /**
174
   * Offset to apply to graphics by default
175
   */
176
  public get offset(): Vector {
177
    return this._offset;
4,928✔
178
  }
179
  public set offset(value: Vector) {
180
    this._offset = new WatchVector(value, () => this.recalculateBounds());
7,394✔
181
    this.recalculateBounds();
7,394✔
182
  }
183

184
  private _anchor: Vector = new WatchVector(Vector.Half, () => this.recalculateBounds());
7,389✔
185

186
  /**
187
   * Anchor to apply to graphics by default
188
   */
189
  public get anchor(): Vector {
190
    return this._anchor;
7,780✔
191
  }
192
  public set anchor(value: Vector) {
193
    this._anchor = new WatchVector(value, () => this.recalculateBounds());
7,438✔
194
    this.recalculateBounds();
7,438✔
195
  }
196

197
  /**
198
   * Sets the color of the actor's current graphic
199
   */
200
  public get color(): Color | undefined {
201
    return this._color;
7,415✔
202
  }
203
  public set color(v: Color | undefined) {
204
    if (v) {
7,562✔
205
      this._color = v.clone();
173✔
206
      const currentGraphic = this.current;
173✔
207
      if (currentGraphic instanceof Raster || currentGraphic instanceof Text) {
173✔
208
        currentGraphic.color = this._color;
3✔
209
      }
210
    }
211
  }
212

213
  /**
214
   * Flip all graphics horizontally along the y-axis
215
   */
216
  public flipHorizontal: boolean = false;
7,389✔
217

218
  /**
219
   * Flip all graphics vertically along the x-axis
220
   */
221
  public flipVertical: boolean = false;
7,389✔
222

223
  /**
224
   * If set to true graphics added to the component will be copied. This can effect performance, but is useful if you don't want
225
   * changes to a graphic to effect all the places it is used.
226
   */
227
  public copyGraphics: boolean = false;
7,389✔
228

229
  constructor(options?: GraphicsComponentOptions) {
230
    super();
7,389✔
231
    // Defaults
232
    options = {
7,389✔
233
      visible: this.isVisible,
234
      graphics: {},
235
      ...options
236
    };
237

238
    const {
239
      current,
240
      anchor,
241
      color,
242
      opacity,
243
      visible,
244
      graphics,
245
      offset,
246
      copyGraphics,
247
      onPreDraw,
248
      onPostDraw,
249
      onPreTransformDraw,
250
      onPostTransformDraw
251
    } = options;
7,389✔
252

253
    for (const [key, graphicOrOptions] of Object.entries(graphics as GraphicsComponentOptions)) {
7,389✔
254
      if (graphicOrOptions instanceof Graphic) {
4!
255
        this._graphics[key] = graphicOrOptions;
4✔
256
      } else {
257
        this._graphics[key] = graphicOrOptions.graphic;
×
258
        this._options[key] = graphicOrOptions.options;
×
259
      }
260
    }
261

262
    this.offset = offset ?? this.offset;
7,389✔
263
    this.opacity = opacity ?? this.opacity;
7,389✔
264
    this.anchor = anchor ?? this.anchor;
7,389✔
265
    this.color = color ?? this.color;
7,389!
266
    this.copyGraphics = copyGraphics ?? this.copyGraphics;
7,389✔
267
    this.onPreDraw = onPreDraw ?? this.onPreDraw;
7,389!
268
    this.onPostDraw = onPostDraw ?? this.onPostDraw;
7,389✔
269
    this.onPreDraw = onPreTransformDraw ?? this.onPreTransformDraw;
7,389!
270
    this.onPostTransformDraw = onPostTransformDraw ?? this.onPostTransformDraw;
7,389!
271
    this.isVisible = !!visible;
7,389✔
272
    this._current = current ?? this._current;
7,389✔
273
    if (current && this._graphics[current]) {
7,389✔
274
      this.use(current);
1✔
275
    }
276
  }
277

278
  public getGraphic(name: string): Graphic | undefined {
279
    return this._graphics[name];
1✔
280
  }
281
  public getOptions(name: string): GraphicsShowOptions | undefined {
282
    return this._options[name];
×
283
  }
284

285
  /**
286
   * Get registered graphics names
287
   */
288
  public getNames(): string[] {
289
    return Object.keys(this._graphics);
3✔
290
  }
291

292
  /**
293
   * Returns the currently displayed graphic
294
   */
295
  public get current(): Graphic | undefined {
296
    return this._graphics[this._current];
4,995✔
297
  }
298

299
  /**
300
   * Returns the currently displayed graphic offsets
301
   */
302
  public get currentOptions(): GraphicsShowOptions | undefined {
303
    return this._options[this._current];
2,306✔
304
  }
305

306
  /**
307
   * Returns all graphics associated with this component
308
   */
309
  public get graphics(): { [graphicName: string]: Graphic } {
310
    return this._graphics;
4✔
311
  }
312

313
  /**
314
   * Returns all graphics options associated with this component
315
   */
316
  public get options(): { [graphicName: string]: GraphicsShowOptions | undefined } {
317
    return this._options;
×
318
  }
319

320
  /**
321
   * Adds a named graphic to this component, if the name is "default" or not specified, it will be shown by default without needing to call
322
   * @param graphic
323
   */
324
  public add(graphic: Graphic, options?: GraphicsShowOptions): Graphic;
325
  public add(name: string, graphic: Graphic, options?: GraphicsShowOptions): Graphic;
326
  public add(nameOrGraphic: string | Graphic, graphicOrOptions?: Graphic | GraphicsShowOptions, options?: GraphicsShowOptions): Graphic {
327
    let name = 'default';
149✔
328
    let graphicToSet: Graphic | null = null;
149✔
329
    let optionsToSet: GraphicsShowOptions | undefined = undefined;
149✔
330
    if (typeof nameOrGraphic === 'string' && graphicOrOptions instanceof Graphic) {
149✔
331
      name = nameOrGraphic;
2✔
332
      graphicToSet = graphicOrOptions;
2✔
333
      optionsToSet = options;
2✔
334
    }
335
    if (nameOrGraphic instanceof Graphic && !(graphicOrOptions instanceof Graphic)) {
149✔
336
      graphicToSet = nameOrGraphic;
147✔
337
      optionsToSet = graphicOrOptions;
147✔
338
    }
339

340
    if (!graphicToSet) {
149!
341
      throw new Error('Need to provide a graphic or valid graphic string');
×
342
    }
343
    this._graphics[name] = this.copyGraphics ? graphicToSet.clone() : graphicToSet;
149!
344
    this._options[name] = this.copyGraphics ? { ...optionsToSet } : optionsToSet;
149!
345
    if (name === 'default') {
149✔
346
      this.use('default');
147✔
347
    }
348
    return graphicToSet;
149✔
349
  }
350

351
  /**
352
   * Removes a registered graphic, if the removed graphic is the current it will switch to the default
353
   * @param name
354
   */
355
  public remove(name: string) {
356
    delete this._graphics[name];
1✔
357
    delete this._options[name];
1✔
358
    if (this._current === name) {
1!
359
      this._current = 'default';
1✔
360
      this.recalculateBounds();
1✔
361
    }
362
  }
363

364
  /**
365
   * Use a graphic only, will set the default graphic. Returns the new {@apilink Graphic}
366
   *
367
   * Optionally override the stored options
368
   * @param nameOrGraphic
369
   * @param options
370
   */
371
  public use<T extends Graphic = Graphic>(nameOrGraphic: string | T, options?: GraphicsShowOptions): T {
372
    if (nameOrGraphic instanceof Graphic) {
201✔
373
      let graphic = nameOrGraphic as Graphic;
48✔
374
      if (this.copyGraphics) {
48✔
375
        graphic = nameOrGraphic.clone();
2✔
376
      }
377
      this._current = 'default';
48✔
378
      this._graphics[this._current] = graphic;
48✔
379
      this._options[this._current] = options;
48✔
380
    } else {
381
      this._current = nameOrGraphic;
153✔
382
      this._options[this._current] = options;
153✔
383
      if (!(this._current in this._graphics)) {
153✔
384
        this._logger.warn(
1✔
385
          `Graphic ${this._current} is not registered with the graphics component owned by ${this.owner?.name}. Nothing will be drawn.`
1!
386
        );
387
      }
388
    }
389
    this.recalculateBounds();
201✔
390
    return this.current as T;
201✔
391
  }
392

393
  /**
394
   * Hide currently shown graphic
395
   */
396
  public hide(): void {
397
    this._current = 'ex.none';
2✔
398
  }
399

400
  private _localBounds?: BoundingBox;
401
  public set localBounds(bounds: BoundingBox) {
402
    this._localBounds = bounds;
7,384✔
403
  }
404

405
  public recalculateBounds() {
406
    let bb = new BoundingBox();
17,027✔
407
    const graphic = this._graphics[this._current];
17,027✔
408
    const options = this._options[this._current];
17,027✔
409

410
    if (!graphic) {
17,027✔
411
      this._localBounds = bb;
16,811✔
412
      return;
16,811✔
413
    }
414

415
    let anchor = this.anchor;
216✔
416
    let offset = this.offset;
216✔
417
    if (options?.anchor) {
216✔
418
      anchor = options.anchor;
5✔
419
    }
420
    if (options?.offset) {
216✔
421
      offset = options.offset;
5✔
422
    }
423
    const bounds = graphic.localBounds;
216✔
424
    const offsetX = -bounds.width * anchor.x + offset.x;
216✔
425
    const offsetY = -bounds.height * anchor.y + offset.y;
216✔
426
    if (graphic instanceof GraphicsGroup && !graphic.useAnchor) {
216!
427
      bb = graphic?.localBounds.combine(bb);
×
428
    } else {
429
      bb = graphic?.localBounds.translate(vec(offsetX, offsetY)).combine(bb);
216!
430
    }
431
    this._localBounds = bb;
216✔
432
  }
433

434
  /**
435
   * Get local bounds of graphics component
436
   */
437
  public get localBounds(): BoundingBox {
438
    if (!this._localBounds || this._localBounds.hasZeroDimensions()) {
5,414✔
439
      this.recalculateBounds();
1,993✔
440
    }
441
    return this._localBounds as BoundingBox; // recalc guarantees type
5,414✔
442
  }
443

444
  /**
445
   * Get world bounds of graphics component
446
   */
447
  public get bounds(): BoundingBox {
448
    let bounds = this.localBounds;
3,053✔
449
    if (this.owner) {
3,053!
450
      const tx = this.owner.get(TransformComponent);
3,053✔
451
      if (tx) {
3,053!
452
        bounds = bounds.transform(tx.get().matrix);
3,053✔
453
      }
454
    }
455
    return bounds;
3,053✔
456
  }
457

458
  /**
459
   * Update underlying graphics if necessary, called internally
460
   * @param elapsed
461
   * @internal
462
   */
463
  public update(elapsed: number, idempotencyToken: number = 0) {
×
464
    const graphic = this.current;
2,299✔
465
    if (graphic && hasGraphicsTick(graphic)) {
2,299✔
466
      graphic.tick(elapsed, idempotencyToken);
2✔
467
    }
468
  }
469

470
  public clone(): GraphicsComponent {
471
    const graphics = new GraphicsComponent();
3✔
472
    graphics._graphics = { ...this._graphics };
3✔
473
    graphics._options = { ...this._options };
3✔
474
    graphics.offset = this.offset.clone();
3✔
475
    if (this.color) {
3✔
476
      graphics.color = this.color.clone();
2✔
477
    }
478
    graphics.opacity = this.opacity;
3✔
479
    graphics.anchor = this.anchor.clone();
3✔
480
    graphics.copyGraphics = this.copyGraphics;
3✔
481
    graphics.onPreDraw = this.onPreDraw;
3✔
482
    graphics.onPostDraw = this.onPostDraw;
3✔
483
    graphics.isVisible = this.isVisible;
3✔
484

485
    return graphics;
3✔
486
  }
487

488
  /**
489
   * Custom serialization - stores graphic references instead of graphic data
490
   */
491
  public serialize(): GraphicsComponentData {
492
    const type = this.constructor.name;
3✔
493
    const data: GraphicsComponentData = {
3✔
494
      type,
495
      current: this._current,
496
      graphicRefs: [],
497
      options: {},
498
      isVisible: this.isVisible,
499
      opacity: this.opacity,
500
      offset: { x: this._offset.x, y: this._offset.y },
501
      anchor: { x: this._anchor.x, y: this._anchor.y },
502
      flipHorizontal: this.flipHorizontal,
503
      flipVertical: this.flipVertical,
504
      copyGraphics: this.copyGraphics,
505
      forceOnScreen: this.forceOnScreen,
506
      tint: undefined
507
    };
508

509
    // Extract graphic IDs/names
510
    data.graphicRefs = Object.keys(this._graphics);
3✔
511

512
    // Serialize options for each graphic
513
    for (const [name, option] of Object.entries(this._options)) {
3✔
NEW
514
      if (option) {
×
NEW
515
        data.options[name] = {
×
516
          offset: option.offset ? { x: option.offset.x, y: option.offset.y } : undefined,
×
517
          anchor: option.anchor ? { x: option.anchor.x, y: option.anchor.y } : undefined
×
518
        };
519
      } else {
NEW
520
        data.options[name] = undefined;
×
521
      }
522
    }
523

524
    if (this._color) {
3!
NEW
525
      data.color = {
×
526
        r: this._color.r,
527
        g: this._color.g,
528
        b: this._color.b,
529
        a: this._color.a
530
      };
531
    }
532

533
    if (this.current?.tint) {
3!
NEW
534
      data.tint = {
×
535
        r: this.current.tint.r,
536
        g: this.current.tint.g,
537
        b: this.current.tint.b,
538
        a: this.current.tint.a
539
      };
540
    }
541

542
    return data;
3✔
543
  }
544

545
  /**
546
   * Custom deserialization
547
   * NOTE - This only restores the component's settings, it does NOT restore the graphics themselves.
548
   */
549
  public deserialize(data: GraphicsComponentData): void {
NEW
550
    this._current = data.current ?? 'default';
×
NEW
551
    this.isVisible = data.isVisible ?? true;
×
NEW
552
    this.opacity = data.opacity ?? 1;
×
NEW
553
    this.flipHorizontal = data.flipHorizontal ?? false;
×
NEW
554
    this.flipVertical = data.flipVertical ?? false;
×
NEW
555
    this.copyGraphics = data.copyGraphics ?? false;
×
NEW
556
    this.forceOnScreen = data.forceOnScreen ?? false;
×
557

558
    // Restore offset and anchor as plain objects (WatchVector setup happens in setter)
NEW
559
    this._offset = { x: data.offset.x, y: data.offset.y } as Vector;
×
NEW
560
    this._anchor = { x: data.anchor.x, y: data.anchor.y } as Vector;
×
561

562
    // Restore color
NEW
563
    if (data.color) {
×
NEW
564
      this._color = Color.fromRGB(data.color.r, data.color.g, data.color.b, data.color.a);
×
565
    }
566

567
    // Restore options (sans graphics themselves)
NEW
568
    this._options = {};
×
NEW
569
    for (const [name, option] of Object.entries(data.options)) {
×
NEW
570
      if (option) {
×
NEW
571
        this._options[name] = {
×
572
          offset: option.offset ? ({ x: option.offset.x, y: option.offset.y } as Vector) : undefined,
×
573
          anchor: option.anchor ? ({ x: option.anchor.x, y: option.anchor.y } as Vector) : undefined
×
574
        };
575
      } else {
NEW
576
        this._options[name] = undefined;
×
577
      }
578
    }
579
  }
580
}
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