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

excaliburjs / Excalibur / 20009271139

07 Dec 2025 07:33PM UTC coverage: 87.821% (-0.8%) from 88.636%
20009271139

Pull #3617

github

web-flow
Merge 6656ac6c0 into 0f899e40c
Pull Request #3617: [feat] extend the components class to add json/serialize/deserialize to component properties

5316 of 7441 branches covered (71.44%)

0 of 154 new or added lines in 5 files covered. (0.0%)

193 existing lines in 5 files now uncovered.

14718 of 16759 relevant lines covered (87.82%)

24483.22 hits per line

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

80.86
/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: 'GraphicsComponent';
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
}
53
export interface GraphicsComponentOptions {
54
  onPostDraw?: (ex: ExcaliburGraphicsContext, elapsed: number) => void;
55
  onPreDraw?: (ex: ExcaliburGraphicsContext, elapsed: number) => void;
56
  onPreTransformDraw?: (ex: ExcaliburGraphicsContext, elapsed: number) => void;
57
  onPostTransformDraw?: (ex: ExcaliburGraphicsContext, elapsed: number) => void;
58

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

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

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

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

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

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

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

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

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

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

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

116
  public material: Material | null = null;
7,383✔
117

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

319
  /**
320
   * 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
321
   * @param graphic
322
   */
323
  public add(graphic: Graphic, options?: GraphicsShowOptions): Graphic;
324
  public add(name: string, graphic: Graphic, options?: GraphicsShowOptions): Graphic;
325
  public add(nameOrGraphic: string | Graphic, graphicOrOptions?: Graphic | GraphicsShowOptions, options?: GraphicsShowOptions): Graphic {
326
    let name = 'default';
149✔
327
    let graphicToSet: Graphic | null = null;
149✔
328
    let optionsToSet: GraphicsShowOptions | undefined = undefined;
149✔
329
    if (typeof nameOrGraphic === 'string' && graphicOrOptions instanceof Graphic) {
149✔
330
      name = nameOrGraphic;
2✔
331
      graphicToSet = graphicOrOptions;
2✔
332
      optionsToSet = options;
2✔
333
    }
334
    if (nameOrGraphic instanceof Graphic && !(graphicOrOptions instanceof Graphic)) {
149✔
335
      graphicToSet = nameOrGraphic;
147✔
336
      optionsToSet = graphicOrOptions;
147✔
337
    }
338

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

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

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

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

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

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

409
    if (!graphic) {
17,015✔
410
      this._localBounds = bb;
16,799✔
411
      return;
16,799✔
412
    }
413

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

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

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

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

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

484
    return graphics;
3✔
485
  }
486

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

506
    // Extract graphic IDs/names
NEW
UNCOV
507
    data.graphicRefs = Object.keys(this._graphics);
×
508

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

NEW
UNCOV
521
    if (this._color) {
×
NEW
UNCOV
522
      data.color = {
×
523
        r: this._color.r,
524
        g: this._color.g,
525
        b: this._color.b,
526
        a: this._color.a
527
      };
528
    }
529

NEW
UNCOV
530
    return data;
×
531
  }
532

533
  /**
534
   * Custom deserialization
535
   * NOTE - This only restores the component's settings, it does NOT restore the graphics themselves.
536
   */
537
  public deserialize(data: GraphicsComponentData): void {
NEW
UNCOV
538
    this._current = data.current ?? 'default';
×
NEW
UNCOV
539
    this.isVisible = data.isVisible ?? true;
×
NEW
UNCOV
540
    this.opacity = data.opacity ?? 1;
×
NEW
UNCOV
541
    this.flipHorizontal = data.flipHorizontal ?? false;
×
NEW
UNCOV
542
    this.flipVertical = data.flipVertical ?? false;
×
NEW
UNCOV
543
    this.copyGraphics = data.copyGraphics ?? false;
×
NEW
UNCOV
544
    this.forceOnScreen = data.forceOnScreen ?? false;
×
545

546
    // Restore offset and anchor as plain objects (WatchVector setup happens in setter)
NEW
UNCOV
547
    this._offset = { x: data.offset.x, y: data.offset.y } as Vector;
×
NEW
UNCOV
548
    this._anchor = { x: data.anchor.x, y: data.anchor.y } as Vector;
×
549

550
    // Restore color
NEW
UNCOV
551
    if (data.color) {
×
NEW
UNCOV
552
      this._color = Color.fromRGB(data.color.r, data.color.g, data.color.b, data.color.a);
×
553
    }
554

555
    // Restore options (sans graphics themselves)
NEW
UNCOV
556
    this._options = {};
×
NEW
UNCOV
557
    for (const [name, option] of Object.entries(data.options)) {
×
NEW
UNCOV
558
      if (option) {
×
NEW
UNCOV
559
        this._options[name] = {
×
560
          offset: option.offset ? ({ x: option.offset.x, y: option.offset.y } as Vector) : undefined,
×
561
          anchor: option.anchor ? ({ x: option.anchor.x, y: option.anchor.y } as Vector) : undefined
×
562
        };
563
      } else {
NEW
UNCOV
564
        this._options[name] = undefined;
×
565
      }
566
    }
567
  }
568
}
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