@caperjs/plugin-crunch 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/Entity.ts ADDED
@@ -0,0 +1,762 @@
1
+ import {
2
+ Application,
3
+ bindAllMethods,
4
+ defaultFactoryMethods,
5
+ IApplication,
6
+ PointLike,
7
+ randomUUID,
8
+ resolvePointLike,
9
+ SignalConnection,
10
+ SignalConnections,
11
+ } from '@caperjs/core';
12
+ import CrunchPhysicsPlugin from './CrunchPhysicsPlugin';
13
+ import { Group } from './Group';
14
+ import { System } from './System';
15
+ import {
16
+ CollisionLayer,
17
+ EntityData,
18
+ PhysicsEntityConfig,
19
+ PhysicsEntityType,
20
+ PhysicsEntityView,
21
+ Rectangle,
22
+ } from './types';
23
+ import { resolveEntityPosition, resolveEntitySize } from './utils';
24
+
25
+ /**
26
+ * Base class for all physics entities in the Crunch physics system.
27
+ * Provides common functionality for position, size, view management, and lifecycle.
28
+ *
29
+ * Entity is the foundation for:
30
+ * - Actors (dynamic objects)
31
+ * - Solids (static objects)
32
+ * - Sensors (trigger zones)
33
+ *
34
+ * It handles:
35
+ * - Position and size management
36
+ * - View (sprite) management and updates
37
+ * - Group membership and relative positioning
38
+ * - Culling and lifecycle states
39
+ * - Signal connections for event handling
40
+ *
41
+ * @typeParam A - Application type, defaults to base Application
42
+ * @typeParam D - Entity data type, defaults to base EntityData
43
+ *
44
+ * @example
45
+ * ```typescript
46
+ * // Create a custom entity
47
+ * class CustomEntity extends Entity {
48
+ * constructor() {
49
+ * super({
50
+ * type: 'Custom',
51
+ * position: [100, 100],
52
+ * size: [32, 32],
53
+ * view: sprite
54
+ * });
55
+ * }
56
+ *
57
+ * // Override update for custom behavior
58
+ * update(dt: number) {
59
+ * super.update(dt);
60
+ * // Custom update logic
61
+ * }
62
+ * }
63
+ * ```
64
+ */
65
+ export class Entity<D extends EntityData = EntityData> {
66
+ public readonly entityType: PhysicsEntityType;
67
+ protected _id: string;
68
+ /** Unique type identifier for this entity */
69
+ public type!: string;
70
+
71
+ /** Color to use when rendering debug visuals */
72
+ public debugColor: number;
73
+
74
+ /** Whether the entity should be removed when culled (out of bounds) */
75
+ public shouldRemoveOnCull: boolean;
76
+
77
+ /** Entity width in pixels */
78
+ public width: number;
79
+
80
+ /** Entity height in pixels */
81
+ public height: number;
82
+
83
+ /** Visual representation (sprite/graphics) of this entity */
84
+ public view!: PhysicsEntityView;
85
+
86
+ /** Whether this entity is active and should be updated */
87
+
88
+ /** Collision layer this entity belongs to (bitwise) */
89
+ public collisionLayer: number = CollisionLayer.NONE;
90
+
91
+ /** Collision mask defining which layers this entity collides with (bitwise) */
92
+ public collisionMask: number = CollisionLayer.NONE;
93
+
94
+ protected _data: Partial<D>;
95
+ protected _group: Group | null;
96
+ protected _groupOffset: { x: number; y: number };
97
+ protected _isCulled: boolean;
98
+ protected _isDestroyed: boolean;
99
+ protected _isInitialized: boolean;
100
+ protected _xRemainder: number;
101
+ protected _yRemainder: number;
102
+ protected _x: number;
103
+ protected _y: number;
104
+ protected signalConnections: SignalConnections;
105
+ protected _following: Entity | null;
106
+ protected _followOffset: { x: number; y: number };
107
+ protected _config: PhysicsEntityConfig<D> | undefined;
108
+ protected _active: boolean = true;
109
+
110
+ public updatedFollowPosition: boolean = false;
111
+ public updatedGroupPosition: boolean = false;
112
+
113
+ set active(value: boolean) {
114
+ this._active = value;
115
+ }
116
+
117
+ get active(): boolean {
118
+ return this._active;
119
+ }
120
+
121
+ set id(value: string) {
122
+ this._id = value;
123
+ }
124
+
125
+ get id(): string {
126
+ return this._id || this.type;
127
+ }
128
+
129
+ get make(): typeof defaultFactoryMethods {
130
+ return this.app.make;
131
+ }
132
+
133
+ /**
134
+ * Custom data associated with this entity
135
+ */
136
+ set data(value: Partial<D>) {
137
+ this._data = value;
138
+ }
139
+
140
+ get data(): Partial<D> {
141
+ return this._data;
142
+ }
143
+
144
+ setFollowing(entityToFollow: Entity | null, offset: PointLike = { x: 0, y: 0 }) {
145
+ this._followOffset = resolvePointLike(offset);
146
+ if (this._following) {
147
+ this.system.removeFollower(this);
148
+ }
149
+ this._following = entityToFollow;
150
+ if (entityToFollow) {
151
+ this.system.addFollower(entityToFollow, this);
152
+ }
153
+ }
154
+
155
+ get followOffset(): { x: number; y: number } {
156
+ return this._followOffset || { x: 0, y: 0 };
157
+ }
158
+
159
+ get following(): Entity | null {
160
+ return this._following;
161
+ }
162
+
163
+ get followers(): Entity[] {
164
+ return this.system.getFollowersOf(this);
165
+ }
166
+
167
+ /**
168
+ * The group this entity belongs to, if any.
169
+ * Groups allow for collective movement and management of entities.
170
+ */
171
+ get group(): Group | null {
172
+ return this._group;
173
+ }
174
+
175
+ set groupOffset(value: { x: number; y: number }) {
176
+ this._groupOffset = resolvePointLike(value);
177
+ }
178
+
179
+ get groupOffset(): { x: number; y: number } {
180
+ return this._groupOffset || { x: 0, y: 0 };
181
+ }
182
+
183
+ setGroup(group: Group | null, offset: PointLike = { x: 0, y: 0 }) {
184
+ this._groupOffset = resolvePointLike(offset);
185
+ // if we're already in a group, remove ourselves first
186
+ if (this._group) {
187
+ this.system.removeFromGroup(this);
188
+ this.onRemovedFromGroup();
189
+ }
190
+ this._group = group;
191
+ if (group) {
192
+ this.system.addToGroup(group, this);
193
+ this.onAddedToGroup();
194
+ }
195
+ }
196
+
197
+ set position(value: PointLike) {
198
+ const { x, y } = resolvePointLike(value);
199
+ this.setPosition(x, y);
200
+ }
201
+
202
+ get position(): PointLike {
203
+ return { x: this.x, y: this.y };
204
+ }
205
+
206
+ /**
207
+ * Entity's X position in world space.
208
+ * If the entity belongs to a group, returns position relative to group.
209
+ */
210
+ set x(value: number) {
211
+ this._x = value;
212
+ }
213
+
214
+ get x(): number {
215
+ if (this._group) {
216
+ return Math.round(this._x + this._group.getChildOffset(this).x); // Return world position
217
+ }
218
+ return Math.round(this._x);
219
+ }
220
+
221
+ /**
222
+ * Entity's Y position in world space.
223
+ * If the entity belongs to a group, returns position relative to group.
224
+ */
225
+ set y(value: number) {
226
+ this._y = value;
227
+ }
228
+
229
+ get y(): number {
230
+ if (this._group) {
231
+ return Math.round(this._y + this._group.getChildOffset(this).y); // Return world position
232
+ }
233
+ return Math.round(this._y);
234
+ }
235
+
236
+ /** Whether this entity is currently culled (out of bounds) */
237
+ get isCulled(): boolean {
238
+ return this._isCulled;
239
+ }
240
+
241
+ /** Whether this entity has been destroyed */
242
+ get isDestroyed(): boolean {
243
+ return this._isDestroyed;
244
+ }
245
+
246
+ /** Reference to the main application instance */
247
+ get app(): IApplication {
248
+ return Application.getInstance();
249
+ }
250
+
251
+ /** Reference to the physics plugin */
252
+ get physics(): CrunchPhysicsPlugin {
253
+ return this.app.getPlugin('crunch') as CrunchPhysicsPlugin;
254
+ }
255
+
256
+ /**
257
+ * Creates a new Entity instance.
258
+ *
259
+ * @param config - Optional configuration for the entity
260
+ */
261
+ constructor(config?: PhysicsEntityConfig<D>) {
262
+ bindAllMethods(this);
263
+
264
+ this._config = config;
265
+
266
+ this.signalConnections = new SignalConnections();
267
+ this.shouldRemoveOnCull = false;
268
+ this.width = 0;
269
+ this.height = 0;
270
+
271
+ this._data = {};
272
+ this._group = null;
273
+ this._isCulled = false;
274
+ this._isDestroyed = false;
275
+ this._isInitialized = false;
276
+ this._xRemainder = 0;
277
+ this._yRemainder = 0;
278
+
279
+ this._x = 0;
280
+ this._y = 0;
281
+
282
+ if (config) {
283
+ this.init(config);
284
+ }
285
+
286
+ this.initialize();
287
+ this.addView();
288
+ }
289
+
290
+ /**
291
+ * Called after construction to perform additional initialization.
292
+ * Override this in subclasses to add custom initialization logic.
293
+ */
294
+ protected initialize() {
295
+ // Override in subclass
296
+ }
297
+
298
+ /**
299
+ * Called before update to prepare for the next frame.
300
+ * Override this in subclasses to add pre-update logic.
301
+ */
302
+ public preUpdate(): void {
303
+ // Override in subclass
304
+ }
305
+
306
+ /**
307
+ * Called every frame to update the entity's state.
308
+ * Override this in subclasses to add update logic.
309
+ *
310
+ * @param dt - Delta time in seconds since last update
311
+ */
312
+ public update(dt: number): void {
313
+ // Override in subclass
314
+ void dt;
315
+ }
316
+
317
+ /**
318
+ * Called after update to finalize the frame.
319
+ * Override this in subclasses to add post-update logic.
320
+ */
321
+ public postUpdate(): void {
322
+ // Override in subclass
323
+ }
324
+
325
+ /**
326
+ * Excludes collision types for this entity
327
+ * @deprecated Use setCollisionMask instead
328
+ */
329
+ excludeCollisionType() {
330
+ console.warn('excludeCollisionType is deprecated. Use setCollisionMask instead.');
331
+ // No-op as we're removing this functionality
332
+ }
333
+
334
+ /**
335
+ * Includes collision types for this entity
336
+ * @deprecated Use setCollisionMask instead
337
+ */
338
+ includeCollisionType() {
339
+ console.warn('includeCollisionType is deprecated. Use setCollisionMask instead.');
340
+ // No-op as we're removing this functionality
341
+ }
342
+
343
+ /**
344
+ * Removes collision types for this entity
345
+ * @deprecated Use removeCollisionMask instead
346
+ */
347
+ removeCollisionType() {
348
+ console.warn('removeCollisionType is deprecated. Use removeCollisionMask instead.');
349
+ // No-op as we're removing this functionality
350
+ }
351
+
352
+ /**
353
+ * Adds collision types for this entity
354
+ * @deprecated Use addCollisionMask instead
355
+ */
356
+ addCollisionType() {
357
+ console.warn('addCollisionType is deprecated. Use addCollisionMask instead.');
358
+ // No-op as we're removing this functionality
359
+ }
360
+
361
+ /**
362
+ * Checks if this entity can collide with a specific type
363
+ */
364
+ canCollideWith(): boolean {
365
+ // Always return true as we're now using only collision layers/masks
366
+ return true;
367
+ }
368
+
369
+ /**
370
+ * Adds the entity's view to the physics container and updates its position.
371
+ */
372
+ protected addView() {
373
+ if (this.view) {
374
+ this.view.visible = true;
375
+ this.view.label = this.id || this.type;
376
+ if (this.system.container) {
377
+ this.system.container.addChild(this.view);
378
+ this.updateView();
379
+ }
380
+ }
381
+ }
382
+
383
+ /**
384
+ * Initializes or reinitializes the entity with new configuration.
385
+ * Used by object pools when recycling entities.
386
+ *
387
+ * @param config - New configuration to apply
388
+ */
389
+ public init(config: PhysicsEntityConfig<D>): void {
390
+ if (!config) return;
391
+ this._config = config as PhysicsEntityConfig<D>;
392
+
393
+ if (config.id) {
394
+ this._id = config.id;
395
+ } else {
396
+ this._id = randomUUID();
397
+ }
398
+
399
+ if (config.type) {
400
+ this.type = config.type;
401
+ }
402
+
403
+ if (config.data) {
404
+ this._data = config.data as Partial<D>;
405
+ }
406
+
407
+ const position = resolveEntityPosition(config);
408
+ this._x = position.x;
409
+ this._y = position.y;
410
+
411
+ const size = resolveEntitySize(config);
412
+ this.width = size.width;
413
+ this.height = size.height;
414
+
415
+ // Initialize collision layers
416
+ if (config.collisionLayer !== undefined) {
417
+ this.setCollisionLayer(config.collisionLayer);
418
+ }
419
+
420
+ if (config.collisionMask !== undefined) {
421
+ this.setCollisionMask(config.collisionMask);
422
+ }
423
+
424
+ // Reset physics properties
425
+ this._xRemainder = 0;
426
+ this._yRemainder = 0;
427
+
428
+ if (config.view) {
429
+ this.setView(config.view);
430
+ }
431
+
432
+ if (config.group) {
433
+ this.setGroup(config.group ?? null, config.groupOffset ? resolvePointLike(config.groupOffset) : { x: 0, y: 0 });
434
+ }
435
+
436
+ if (config.follows) {
437
+ this.setFollowing(
438
+ config.follows ?? null,
439
+ config.followOffset ? resolvePointLike(config.followOffset) : { x: 0, y: 0 },
440
+ );
441
+ }
442
+ // Show and update view if it exists
443
+ this.addView();
444
+ }
445
+
446
+ /**
447
+ * Resets the entity to its initial state for reuse in object pools.
448
+ * Override this to handle custom reset logic.
449
+ */
450
+ public reset(): void {
451
+ // Reset culling state
452
+ this._isCulled = false;
453
+ this._isDestroyed = false;
454
+
455
+ // Reset remainders
456
+ this._xRemainder = 0;
457
+ this._yRemainder = 0;
458
+
459
+ this._followOffset = { x: 0, y: 0 };
460
+ this._following = null;
461
+
462
+ this._groupOffset = { x: 0, y: 0 };
463
+ this._group = null;
464
+
465
+ this._data = {};
466
+
467
+ this._x = -Number.MAX_SAFE_INTEGER;
468
+ this._y = -Number.MAX_SAFE_INTEGER;
469
+
470
+ if (this.view) {
471
+ this.view.visible = false;
472
+ }
473
+
474
+ this.system.removeEntity(this);
475
+ }
476
+
477
+ /** Reference to the physics system */
478
+ get system(): System {
479
+ return this.physics.system;
480
+ }
481
+
482
+ /**
483
+ * Called when the entity is added to a group.
484
+ * Override this to handle custom group addition logic.
485
+ */
486
+ public onAddedToGroup(): void {
487
+ // Override in subclass
488
+ }
489
+
490
+ /**
491
+ * Called when the entity is removed from a group.
492
+ * Override this to handle custom group removal logic.
493
+ */
494
+ public onRemovedFromGroup(): void {
495
+ // Override in subclass
496
+ }
497
+
498
+ /**
499
+ * Updates the entity's position and view.
500
+ */
501
+ public updatePosition(): void {
502
+ this.x = this._x;
503
+ this.y = this._y;
504
+ this.updateView();
505
+ }
506
+
507
+ /**
508
+ * Called when the entity is culled (goes out of bounds).
509
+ * Override this to handle culling differently.
510
+ */
511
+ public onCull(): void {
512
+ this._isCulled = true;
513
+ // Default behavior: hide the view
514
+ if (this.view) {
515
+ this.view.visible = false;
516
+ }
517
+ }
518
+
519
+ /**
520
+ * Called when the entity is brought back after being culled.
521
+ * Override this to handle unculling differently.
522
+ */
523
+ public onUncull(): void {
524
+ this._isCulled = false;
525
+ // Default behavior: show the view
526
+ if (this.view) {
527
+ this.view.visible = true;
528
+ }
529
+ }
530
+
531
+ /**
532
+ * Prepares the entity for removal/recycling.
533
+ * Override this to handle custom cleanup.
534
+ */
535
+ public destroy(): void {
536
+ if (this._isDestroyed) return;
537
+
538
+ this._isDestroyed = true;
539
+ this._isCulled = false;
540
+
541
+ this.signalConnections.disconnectAll();
542
+
543
+ // Don't destroy the view - it will be reused
544
+ if (this.view) {
545
+ this.view.visible = false;
546
+ this.view.removeFromParent();
547
+ }
548
+
549
+ this.system.removeFollower(this);
550
+ }
551
+
552
+ /**
553
+ * Called when the entity is removed from the physics system.
554
+ * Override this to handle custom removal logic.
555
+ */
556
+ public onRemoved(): void {
557
+ if (!this._isDestroyed) {
558
+ this.destroy();
559
+ }
560
+ }
561
+
562
+ /**
563
+ * Sets a new view for the entity and updates its position.
564
+ *
565
+ * @param view - The new view to use
566
+ */
567
+ public setView(view: PhysicsEntityView): void {
568
+ this.view = view;
569
+ this.updateView();
570
+ }
571
+
572
+ /**
573
+ * Updates the view's position to match the entity's position.
574
+ */
575
+ public updateView(): void {
576
+ if (this.view && this.view.visible && this.view.position) {
577
+ this.view.position.set(this.x, this.y);
578
+ }
579
+ }
580
+
581
+ /**
582
+ * Gets the entity's bounding rectangle.
583
+ *
584
+ * @returns Rectangle representing the entity's bounds
585
+ */
586
+ public getBounds(): Rectangle {
587
+ return {
588
+ x: this.x,
589
+ y: this.y,
590
+ width: this.width,
591
+ height: this.height,
592
+ };
593
+ }
594
+
595
+ /**
596
+ * Sets the entity's position, resetting any movement remainders.
597
+ *
598
+ * @param x - New X position
599
+ * @param y - New Y position
600
+ */
601
+ public setPosition(x: number, y: number): void {
602
+ this._x = x;
603
+ this._y = y;
604
+ this._xRemainder = 0;
605
+ this._yRemainder = 0;
606
+ this.updateView();
607
+ }
608
+
609
+ /**
610
+ * Alias for setPosition.
611
+ *
612
+ * @param x - New X position
613
+ * @param y - New Y position
614
+ */
615
+ public moveTo(x: number, y: number): void {
616
+ this.setPosition(x, y);
617
+ }
618
+
619
+ /**
620
+ * Adds signal connections to the entity.
621
+ *
622
+ * @param args - Signal connections to add
623
+ */
624
+ public addSignalConnection(...args: SignalConnection[]) {
625
+ for (const connection of args) {
626
+ this.signalConnections.add(connection);
627
+ }
628
+ }
629
+
630
+ /**
631
+ * Alias for addSignalConnection.
632
+ *
633
+ * @param args - Signal connections to add
634
+ */
635
+ public connectSignal(...args: SignalConnection[]) {
636
+ for (const connection of args) {
637
+ this.signalConnections.add(connection);
638
+ }
639
+ }
640
+
641
+ /**
642
+ * Alias for addSignalConnection, specifically for action signals.
643
+ *
644
+ * @param args - Action signal connections to add
645
+ */
646
+ public connectAction(...args: SignalConnection[]) {
647
+ for (const connection of args) {
648
+ this.signalConnections.add(connection);
649
+ }
650
+ }
651
+
652
+ /**
653
+ * Checks if this entity can collide with another entity
654
+ */
655
+ public canCollideWithEntity(entity: Entity): boolean {
656
+ // Check if the entities can collide based on their collision layers and masks
657
+ // A collision occurs when (A.layer & B.mask) !== 0 && (B.layer & A.mask) !== 0
658
+ return (this.collisionLayer & entity.collisionMask) !== 0 && (entity.collisionLayer & this.collisionMask) !== 0;
659
+ }
660
+
661
+ /**
662
+ * Sets the collision layer for this entity
663
+ *
664
+ * @param layer The collision layer or layers (can be combined with bitwise OR)
665
+ */
666
+ public setCollisionLayer(layer: number): void {
667
+ this.collisionLayer = layer;
668
+ }
669
+
670
+ /**
671
+ * Adds the specified layers to this entity's collision layer
672
+ *
673
+ * @param layers The layers to add (can be combined with bitwise OR)
674
+ */
675
+ public addCollisionLayer(layers: number): void {
676
+ this.collisionLayer |= layers;
677
+ }
678
+
679
+ /**
680
+ * Removes the specified layers from this entity's collision layer
681
+ *
682
+ * @param layers The layers to remove (can be combined with bitwise OR)
683
+ */
684
+ public removeCollisionLayer(layers: number): void {
685
+ this.collisionLayer &= ~layers;
686
+ }
687
+
688
+ /**
689
+ * Sets the collision mask for this entity
690
+ *
691
+ * @param mask The collision mask (can be combined with bitwise OR)
692
+ */
693
+ public setCollisionMask(...mask: number[]): void {
694
+ this.collisionMask = this.physics.createCollisionMask(...mask);
695
+ }
696
+
697
+ /**
698
+ * Adds the specified layers to this entity's collision mask
699
+ *
700
+ * @param layers The layers to add to the mask (can be combined with bitwise OR)
701
+ */
702
+ public addCollisionMask(layers: number): void {
703
+ this.collisionMask |= layers;
704
+ }
705
+
706
+ /**
707
+ * Removes the specified layers from this entity's collision mask
708
+ *
709
+ * @param layers The layers to remove from the mask (can be combined with bitwise OR)
710
+ */
711
+ public removeCollisionMask(layers: number): void {
712
+ this.collisionMask &= ~layers;
713
+ }
714
+
715
+ /**
716
+ * Checks if this entity belongs to a specific collision layer
717
+ *
718
+ * @param layer The layer to check
719
+ * @returns True if the entity belongs to the specified layer
720
+ *
721
+ * @example
722
+ * ```typescript
723
+ * // Check if entity is on the PLAYER layer
724
+ * if (entity.hasCollisionLayer(CollisionLayer.PLAYER)) {
725
+ * console.log('Entity is a player');
726
+ * }
727
+ *
728
+ * // Check if entity is on a custom layer
729
+ * const WATER_LAYER = CollisionLayers.createLayer(0);
730
+ * if (entity.hasCollisionLayer(WATER_LAYER)) {
731
+ * console.log('Entity is water');
732
+ * }
733
+ * ```
734
+ */
735
+ public hasCollisionLayer(layer: number): boolean {
736
+ return (this.collisionLayer & layer) !== 0;
737
+ }
738
+
739
+ /**
740
+ * Checks if this entity can collide with a specific collision layer
741
+ *
742
+ * @param layer The layer to check
743
+ * @returns True if the entity can collide with the specified layer
744
+ *
745
+ * @example
746
+ * ```typescript
747
+ * // Check if entity can collide with players
748
+ * if (entity.canCollideWithLayer(CollisionLayer.PLAYER)) {
749
+ * console.log('Entity can collide with players');
750
+ * }
751
+ *
752
+ * // Check if entity can collide with a custom layer
753
+ * const WATER_LAYER = CollisionLayers.createLayer(0);
754
+ * if (entity.canCollideWithLayer(WATER_LAYER)) {
755
+ * console.log('Entity can collide with water');
756
+ * }
757
+ * ```
758
+ */
759
+ public canCollideWithLayer(layer: number): boolean {
760
+ return (this.collisionMask & layer) !== 0;
761
+ }
762
+ }