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

excaliburjs / Excalibur / 20856281813

09 Jan 2026 03:11PM UTC coverage: 88.077% (-0.7%) from 88.745%
20856281813

Pull #3617

github

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

5503 of 7618 branches covered (72.24%)

222 of 379 new or added lines in 6 files covered. (58.58%)

1 existing line in 1 file now uncovered.

15070 of 17110 relevant lines covered (88.08%)

23980.27 hits per line

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

68.42
/src/engine/entity-component-system/component.ts
1
import type { Entity } from './entity';
2

3
/**
4
 * Component Constructor Types
5
 */
6
export declare type ComponentCtor<TComponent extends Component = Component> = new (...args: any[]) => TComponent;
7

8
/**
9
 *
10
 */
11
export function isComponentCtor(value: any): value is ComponentCtor<Component> {
12
  return !!value && !!value.prototype && !!value.prototype.constructor;
76✔
13
}
14

15
/**
16
 * Type guard to check if a component implements clone
17
 * @param x
18
 */
19
function hasClone(x: any): x is { clone(): any } {
20
  return !!x?.clone;
181✔
21
}
22

23
/**
24
 * Components are containers for state in Excalibur, the are meant to convey capabilities that an Entity possesses
25
 *
26
 * Implementations of Component must have a zero-arg constructor to support dependencies
27
 *
28
 * ```typescript
29
 * class MyComponent extends ex.Component {
30
 *   // zero arg support required if you want to use component dependencies
31
 *   constructor(public optionalPos?: ex.Vector) {}
32
 * }
33
 * ```
34
 */
35
export abstract class Component {
36
  // TODO maybe generate a unique id?
37

38
  /**
39
   * Optionally list any component types this component depends on
40
   * If the owner entity does not have these components, new components will be added to the entity
41
   *
42
   * Only components with zero-arg constructors are supported as automatic component dependencies
43
   */
44
  readonly dependencies?: ComponentCtor[];
45

46
  /**
47
   * Current owning {@apilink Entity}, if any, of this component. Null if not added to any {@apilink Entity}
48
   */
49
  owner?: Entity = undefined;
29,693✔
50

51
  /**
52
   * Clones any properties on this component, if that property value has a `clone()` method it will be called
53
   */
54
  clone(): Component {
55
    const newComponent = new (this.constructor as any)();
27✔
56
    for (const prop in this) {
27✔
57
      if (this.hasOwnProperty(prop)) {
181!
58
        const val = this[prop];
181✔
59
        if (hasClone(val) && prop !== 'owner' && prop !== 'clone') {
181✔
60
          newComponent[prop] = val.clone();
53✔
61
        } else {
62
          newComponent[prop] = val;
128✔
63
        }
64
      }
65
    }
66
    return newComponent;
27✔
67
  }
68

69
  /**
70
   * Optional callback called when a component is added to an entity
71
   */
72
  onAdd?(owner: Entity): void;
73

74
  /**
75
   * Optional callback called when a component is removed from an entity
76
   */
77
  onRemove?(previousOwner: Entity): void;
78

79
  /*
80
   * Serialization, Deserialization, and toJSONhandled
81
   */
82

83
  toJSON(pretty: boolean = false): string {
×
NEW
84
    return JSON.stringify(this.serialize(), null, pretty ? 2 : 0);
×
85
  }
86

87
  serialize(): Record<string, any> {
88
    const data: Record<string, any> = {
9✔
89
      type: this.constructor.name
90
    };
91

92
    for (const prop in this) {
9✔
93
      if (!this.hasOwnProperty(prop)) {
48!
NEW
94
        continue;
×
95
      }
96

97
      // Skip owner, private fields, functions, and non-serializable objects
98
      if (
48✔
99
        prop === 'owner' ||
189✔
100
        prop === 'dependencies' ||
101
        prop.startsWith('_') ||
102
        typeof this[prop] === 'function' ||
103
        !this._isSerializable(this[prop])
104
      ) {
105
        continue;
15✔
106
      }
107

108
      data[prop] = this._serializeValue(this[prop]);
33✔
109
    }
110

111
    return data;
9✔
112
  }
113

114
  deserialize(data: Record<string, any>): void {
NEW
115
    for (const key in data) {
×
NEW
116
      if (key === 'type' || key === 'owner' || key === 'dependencies') {
×
NEW
117
        continue;
×
118
      }
119

NEW
120
      if (this.hasOwnProperty(key)) {
×
NEW
121
        (this as any)[key] = this._deserializeValue(data[key], (this as any)[key]);
×
122
      }
123
    }
124
  }
125

126
  protected _serializeValue(value: any): any {
127
    if (value === null || value === undefined) {
36✔
128
      return value;
3✔
129
    }
130

131
    // Handle Vector-like objects (any object with x/y numeric properties)
132
    if (value && typeof value === 'object' && 'x' in value && 'y' in value && typeof value.x === 'number' && typeof value.y === 'number') {
33✔
133
      return { x: value.x, y: value.y };
12✔
134
    }
135

136
    // Handle arrays
137
    if (Array.isArray(value)) {
21!
NEW
138
      return value.map((v) => this._serializeValue(v));
×
139
    }
140

141
    // Handle plain objects
142
    if (value && typeof value === 'object' && value.constructor === Object) {
21✔
143
      const obj: Record<string, any> = {};
3✔
144
      for (const k in value) {
3✔
145
        if (value.hasOwnProperty(k)) {
3!
146
          obj[k] = this._serializeValue(value[k]);
3✔
147
        }
148
      }
149
      return obj;
3✔
150
    }
151

152
    // Primitives (string, number, boolean) and other types
153
    if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
18!
154
      return value;
18✔
155
    }
156

157
    // Skip non-serializable objects
NEW
158
    return undefined;
×
159
  }
160

161
  protected _deserializeValue(data: any, existingValue?: any): any {
NEW
162
    if (data === null || data === undefined) {
×
NEW
163
      return data;
×
164
    }
165

166
    // If existing value has x/y (Vector-like), update it in place
NEW
167
    if (
×
168
      existingValue &&
×
169
      typeof existingValue === 'object' &&
170
      'x' in existingValue &&
171
      'y' in existingValue &&
172
      data &&
173
      typeof data === 'object' &&
174
      'x' in data &&
175
      'y' in data
176
    ) {
NEW
177
      existingValue.x = data.x;
×
NEW
178
      existingValue.y = data.y;
×
NEW
179
      return existingValue;
×
180
    }
181

182
    // Otherwise return the deserialized data as-is
NEW
183
    return data;
×
184
  }
185

186
  private _isSerializable(value: any): boolean {
187
    if (value === null || value === undefined) {
33✔
188
      return true;
3✔
189
    }
190

191
    const type = typeof value;
30✔
192
    if (type === 'string' || type === 'number' || type === 'boolean') {
30✔
193
      return true;
15✔
194
    }
195

196
    // Check for Observable or other non-serializable objects
197
    if (type === 'object' && value.subscribe) {
15!
NEW
198
      return false;
×
199
    }
200
    if (type === 'function') {
15!
NEW
201
      return false;
×
202
    }
203

204
    return true;
15✔
205
  }
206
}
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