@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/Actor.ts ADDED
@@ -0,0 +1,726 @@
1
+ import { Entity } from './Entity';
2
+ import { Solid } from './Solid';
3
+ import {
4
+ ActorCollisionResult,
5
+ CollisionResult,
6
+ EntityData,
7
+ PhysicsEntityConfig,
8
+ PhysicsEntityType,
9
+ Vector2,
10
+ } from './types';
11
+
12
+ /**
13
+ * Dynamic physics entity that can move and collide with other entities.
14
+ * Actors are typically used for players, enemies, projectiles, and other moving game objects.
15
+ *
16
+ * Features:
17
+ * - Velocity-based movement with gravity
18
+ * - Collision detection and response
19
+ * - Solid surface detection (riding)
20
+ * - Automatic culling when out of bounds
21
+ * - Actor-to-actor collision detection
22
+ *
23
+ * @typeParam T - Application type, defaults to base Application
24
+ *
25
+ * @example
26
+ * ```typescript
27
+ * // Create a player actor
28
+ * class Player extends Actor {
29
+ * constructor() {
30
+ * super({
31
+ * type: 'Player',
32
+ * position: [100, 100],
33
+ * size: [32, 64],
34
+ * view: playerSprite
35
+ * });
36
+ * }
37
+ *
38
+ * // Handle collisions
39
+ * onCollide(result: CollisionResult) {
40
+ * if (result.solid.type === 'Spike') {
41
+ * this.die();
42
+ * }
43
+ * }
44
+ *
45
+ * // Handle actor-to-actor collisions
46
+ * onActorCollide(result: ActorCollisionResult) {
47
+ * if (result.actor.type === 'Enemy') {
48
+ * this.takeDamage(10);
49
+ * }
50
+ * }
51
+ *
52
+ * // Custom movement
53
+ * update(dt: number) {
54
+ * super.update(dt);
55
+ *
56
+ * // Move left/right
57
+ * if (this.app.input.isKeyDown('ArrowLeft')) {
58
+ * this.velocity.x = -200;
59
+ * } else if (this.app.input.isKeyDown('ArrowRight')) {
60
+ * this.velocity.x = 200;
61
+ * }
62
+ *
63
+ * // Jump when on ground
64
+ * if (this.app.input.isKeyPressed('Space') && this.isRidingSolid()) {
65
+ * this.velocity.y = -400;
66
+ * }
67
+ * }
68
+ * }
69
+ * ```
70
+ */
71
+ export class Actor<D extends EntityData = EntityData> extends Entity<D> {
72
+ public readonly entityType: PhysicsEntityType = 'Actor';
73
+
74
+ /** Current velocity in pixels per second */
75
+ public velocity: Vector2 = { x: 0, y: 0 };
76
+
77
+ /** Whether actor-to-actor collisions are disabled for this actor */
78
+ public disableActorCollisions: boolean = false;
79
+
80
+ /** Whether the actor should be removed when culled (out of bounds) */
81
+ public shouldRemoveOnCull: boolean = true;
82
+
83
+ /** List of current frame collisions */
84
+ public collisions: CollisionResult[] = [];
85
+
86
+ /** List of current frame actor-to-actor collisions */
87
+ public actorCollisions: ActorCollisionResult[] = [];
88
+
89
+ /** Cache for isRidingSolid check */
90
+ private _isRidingSolidCache: boolean | null = null;
91
+
92
+ /** Tracks which solid is currently carrying this actor in the current frame */
93
+ private _carriedBy: Solid | null = null;
94
+ private _carriedByOverlap: number = 0;
95
+
96
+ /** Tracks the grid cells this actor currently occupies */
97
+ private _currentGridCells: string[] = [];
98
+
99
+ /**
100
+ * Initialize or reinitialize the actor with new configuration.
101
+ *
102
+ * @param config - Configuration for the actor
103
+ */
104
+ public init(config: PhysicsEntityConfig<D>): void {
105
+ super.init(config);
106
+ // Reset velocity and carried state
107
+ this.velocity = { x: 0, y: 0 };
108
+ this._isRidingSolidCache = null;
109
+ this._carriedBy = null;
110
+ this._carriedByOverlap = 0;
111
+ this.actorCollisions = [];
112
+ this._currentGridCells = [];
113
+
114
+ if (config.disableActorCollisions !== undefined) {
115
+ this.disableActorCollisions = config.disableActorCollisions;
116
+ }
117
+
118
+ // Add actor to grid initially if actor collisions are enabled
119
+ if (this.system.enableActorCollisions && !this.disableActorCollisions) {
120
+ this.updateGridCells();
121
+ }
122
+ }
123
+
124
+ /**
125
+ * Called at the start of each update to prepare for collision checks.
126
+ */
127
+ public preUpdate(): void {
128
+ if (!this.active) return;
129
+
130
+ this.collisions = [];
131
+ this.actorCollisions = [];
132
+ // Reset the cache at the start of each update
133
+ this._isRidingSolidCache = null;
134
+ this._carriedBy = null;
135
+ this._carriedByOverlap = 0;
136
+ }
137
+
138
+ /**
139
+ * Updates the actor's position based on velocity and handles collisions.
140
+ *
141
+ * @param dt - Delta time in seconds
142
+ */
143
+ public update(dt: number): void {
144
+ if (!this.active) return;
145
+
146
+ // Ensure velocity is valid
147
+ if (!this.isRidingSolid()) {
148
+ this.velocity.y += this.system.gravity * dt;
149
+ }
150
+
151
+ // Clamp velocity
152
+ this.velocity.x = Math.min(Math.max(this.velocity.x, -this.system.maxVelocity), this.system.maxVelocity);
153
+ this.velocity.y = Math.min(Math.max(this.velocity.y, -this.system.maxVelocity), this.system.maxVelocity);
154
+
155
+ // Move horizontally
156
+ if (this.velocity.x !== 0) {
157
+ this.moveX(this.velocity.x * dt);
158
+ }
159
+
160
+ // Move vertically
161
+ if (this.velocity.y !== 0) {
162
+ this.moveY(this.velocity.y * dt);
163
+ }
164
+
165
+ if (this.system.enableActorCollisions) {
166
+ this.updateGridCells();
167
+ }
168
+
169
+ // Update view
170
+ this.updateView();
171
+ }
172
+
173
+ /**
174
+ * Called after update to handle post-movement effects.
175
+ */
176
+ public postUpdate(): void {
177
+ if (!this.active) return;
178
+
179
+ if (this.isRidingSolid()) {
180
+ this.velocity.y = 0;
181
+ }
182
+ }
183
+
184
+ /**
185
+ * Resets the actor to its initial state.
186
+ */
187
+ public reset(): void {
188
+ super.reset();
189
+
190
+ this._isRidingSolidCache = null;
191
+ this._carriedBy = null;
192
+ this._carriedByOverlap = 0;
193
+ this.velocity = { x: 0, y: 0 };
194
+
195
+ this.updatePosition();
196
+ }
197
+
198
+ /**
199
+ * Called when the actor is culled (goes out of bounds).
200
+ * Override this to handle culling differently.
201
+ */
202
+ public onCull(): void {
203
+ // Default behavior: destroy the view
204
+ this.view?.destroy();
205
+ }
206
+
207
+ /**
208
+ * Called when this actor collides with a solid.
209
+ * Override this method to implement custom collision response.
210
+ *
211
+ * @param result - Information about the collision
212
+ */
213
+ public onCollide(result: CollisionResult): void {
214
+ // Default implementation does nothing
215
+ // Override this in your actor subclass to handle collisions
216
+ void result;
217
+ }
218
+
219
+ /**
220
+ * Called when this actor collides with another actor.
221
+ * Override this method to implement custom actor-to-actor collision response.
222
+ *
223
+ * @param result - Information about the actor collision
224
+ */
225
+ public onActorCollide(result: ActorCollisionResult): void {
226
+ // Default implementation does nothing
227
+ // Override this in your actor subclass to handle actor-to-actor collisions
228
+ void result;
229
+ }
230
+
231
+ /**
232
+ * Checks if this actor is riding the given solid.
233
+ * An actor is riding if it's directly above the solid.
234
+ *
235
+ * @param solid - The solid to check against
236
+ * @returns True if riding the solid
237
+ */
238
+ public isRiding(solid: Solid): boolean {
239
+ // Skip if solid has no collisions
240
+ if (!solid.collideable) return false;
241
+
242
+ // Check collision layers and masks
243
+ // An actor can only ride a solid if their collision layers/masks allow interaction
244
+ if ((this.collisionLayer & solid.collisionMask) === 0 || (solid.collisionLayer & this.collisionMask) === 0) {
245
+ return false;
246
+ }
247
+
248
+ // If we're already being carried by a different solid this frame,
249
+ // we can't be riding this one
250
+ if (this._carriedBy && this._carriedBy !== solid) {
251
+ return false;
252
+ }
253
+
254
+ // Must be directly above the solid (within 1 pixel)
255
+ const actorBottom = this.y + this.height;
256
+ const onTop = Math.abs(actorBottom - solid.y) <= 1;
257
+
258
+ // Must be horizontally overlapping
259
+ const overlap = this.x + this.width > solid.x && this.x < solid.x + solid.width;
260
+ const overlapWidth = Math.min(this.x + this.width, solid.x + solid.width) - Math.max(this.x, solid.x);
261
+
262
+ const isRiding = onTop && overlap;
263
+
264
+ if (isRiding && overlapWidth > this._carriedByOverlap) {
265
+ this._carriedBy = solid;
266
+ this._carriedByOverlap = overlapWidth;
267
+ }
268
+ return isRiding;
269
+ }
270
+
271
+ /**
272
+ * Checks if this actor is riding any solid in the physics system.
273
+ * Uses caching to optimize multiple checks per frame.
274
+ *
275
+ * @returns True if riding any solid
276
+ */
277
+ public isRidingSolid(): boolean {
278
+ // Return cached value if available
279
+ if (this._isRidingSolidCache !== null) {
280
+ return this._isRidingSolidCache;
281
+ }
282
+
283
+ // Calculate and cache the result
284
+ const solids = this.getSolidsAt(this.x, this.y + 1);
285
+ this._isRidingSolidCache = solids.some((solid) => this.isRiding(solid));
286
+ return this._isRidingSolidCache;
287
+ }
288
+
289
+ /**
290
+ * Called when the actor is squeezed between solids.
291
+ * Override this to handle squishing differently.
292
+ */
293
+ public squish(result: CollisionResult): void {
294
+ void result;
295
+ // do something
296
+ }
297
+
298
+ /**
299
+ * Updates the actor's grid cells in the spatial partitioning system.
300
+ * This is called when the actor moves or when its size changes.
301
+ */
302
+ public updateGridCells(): void {
303
+ // Skip if actor collisions are disabled system-wide or for this actor specifically
304
+ if (this.disableActorCollisions) return;
305
+
306
+ this.system.updateActorInGrid(this);
307
+ }
308
+
309
+ /**
310
+ * Gets the current grid cells this actor occupies
311
+ */
312
+ public get currentGridCells(): string[] {
313
+ return this._currentGridCells;
314
+ }
315
+
316
+ /**
317
+ * Sets the current grid cells this actor occupies
318
+ */
319
+ public set currentGridCells(cells: string[]) {
320
+ this._currentGridCells = cells;
321
+ }
322
+
323
+ /**
324
+ * Moves the actor horizontally, checking for collisions with solids.
325
+ *
326
+ * @param amount - Distance to move in pixels
327
+ * @param collisionHandler - Optional callback for handling collisions
328
+ * @returns Array of collision results
329
+ */
330
+ public moveX(
331
+ amount: number,
332
+ collisionHandler?: (result: CollisionResult) => void,
333
+ pushingSolid?: Solid,
334
+ ): CollisionResult[] {
335
+ // Early return if inactive or zero movement
336
+ if (!this.active || amount === 0) return [];
337
+
338
+ this._xRemainder += amount;
339
+ const move = Math.round(this._xRemainder);
340
+
341
+ // Early return if rounded movement is zero
342
+ if (move === 0) return [];
343
+
344
+ const collisions: CollisionResult[] = [];
345
+
346
+ // Cache collision layer and mask for faster access
347
+ const actorLayer = this.collisionLayer;
348
+ const actorMask = this.collisionMask;
349
+
350
+ // Skip collision checks if no collision mask
351
+ if (actorMask === 0) {
352
+ // Just move without checking collisions
353
+ this._xRemainder -= move;
354
+ this._x += move;
355
+ this.updateView();
356
+
357
+ return [];
358
+ }
359
+
360
+ this._xRemainder -= move;
361
+ const sign = Math.sign(move);
362
+ let remaining = Math.abs(move);
363
+ const step = sign;
364
+
365
+ // If we're being pushed by a solid, temporarily make it non-collidable
366
+ if (pushingSolid) {
367
+ pushingSolid.collideable = false;
368
+ }
369
+
370
+ // Move one pixel at a time, checking for collisions
371
+ while (remaining > 0) {
372
+ const nextX = this._x + step;
373
+
374
+ // Get solids at the next position
375
+ const solids = this.getSolidsAt(nextX, this._y);
376
+ let collided = false;
377
+
378
+ // Check for collisions with each solid
379
+ for (const solid of solids) {
380
+ // Skip if solid can't collide
381
+ if (!solid.canCollide) continue;
382
+
383
+ // Skip if collision layers don't match
384
+ if ((actorLayer & solid.collisionMask) === 0 || (solid.collisionLayer & actorMask) === 0) {
385
+ continue;
386
+ }
387
+
388
+ // Calculate collision details
389
+ const result: CollisionResult = {
390
+ collided: true,
391
+ solid,
392
+ normal: { x: -sign, y: 0 },
393
+ penetration: step > 0 ? this.x + this.width - solid.x : solid.x + solid.width - this.x,
394
+ pushingSolid,
395
+ };
396
+
397
+ // Add to collisions array
398
+ collisions.push(result);
399
+
400
+ // Call collision handler if provided
401
+ if (collisionHandler) {
402
+ collisionHandler(result);
403
+ }
404
+
405
+ // Call actor's collision handler
406
+ this.onCollide(result);
407
+
408
+ collided = true;
409
+ }
410
+
411
+ if (collided) {
412
+ // Stop movement on collision
413
+ break;
414
+ } else {
415
+ // Move to next position
416
+ this._x = nextX;
417
+ remaining--;
418
+
419
+ // Update view every few pixels for better performance
420
+ // This reduces the number of view updates during movement
421
+ if (remaining % 4 === 0 || remaining === 0) {
422
+ this.updateView();
423
+ }
424
+ }
425
+ }
426
+
427
+ // Restore solid's collidable state
428
+ if (pushingSolid) {
429
+ pushingSolid.collideable = true;
430
+ }
431
+
432
+ // Final view update if we moved
433
+ if (Math.abs(move) - remaining > 0) {
434
+ this.updateView();
435
+ }
436
+
437
+ return collisions;
438
+ }
439
+
440
+ /**
441
+ * Moves the actor vertically, checking for collisions with solids.
442
+ *
443
+ * @param amount - Distance to move in pixels
444
+ * @param collisionHandler - Optional callback for handling collisions
445
+ * @returns Array of collision results
446
+ */
447
+ public moveY(
448
+ amount: number,
449
+ collisionHandler?: (result: CollisionResult) => void,
450
+ pushingSolid?: Solid,
451
+ ): CollisionResult[] {
452
+ // Early return if inactive or zero movement
453
+ if (!this.active || amount === 0) return [];
454
+
455
+ this._yRemainder += amount;
456
+ const move = Math.round(this._yRemainder);
457
+
458
+ // Early return if rounded movement is zero
459
+ if (move === 0) return [];
460
+
461
+ const collisions: CollisionResult[] = [];
462
+
463
+ // Cache collision layer and mask for faster access
464
+ const actorLayer = this.collisionLayer;
465
+ const actorMask = this.collisionMask;
466
+
467
+ // Skip collision checks if no collision mask
468
+ if (actorMask === 0) {
469
+ // Just move without checking collisions
470
+ this._yRemainder -= move;
471
+ this._y += move;
472
+ this.updateView();
473
+
474
+ return [];
475
+ }
476
+
477
+ this._yRemainder -= move;
478
+ const sign = Math.sign(move);
479
+ let remaining = Math.abs(move);
480
+ const step = sign;
481
+
482
+ // If we're being pushed by a solid, temporarily make it non-collidable
483
+ if (pushingSolid) {
484
+ pushingSolid.collideable = false;
485
+ }
486
+
487
+ // Move one pixel at a time, checking for collisions
488
+ while (remaining > 0) {
489
+ const nextY = this._y + step;
490
+
491
+ // Get solids at the next position
492
+ const solids = this.getSolidsAt(this._x, nextY);
493
+ let collided = false;
494
+
495
+ // Check for collisions with each solid
496
+ for (const solid of solids) {
497
+ // Skip if solid can't collide
498
+ if (!solid.canCollide) continue;
499
+
500
+ // Skip if collision layers don't match
501
+ if ((actorLayer & solid.collisionMask) === 0 || (solid.collisionLayer & actorMask) === 0) {
502
+ continue;
503
+ }
504
+
505
+ // Calculate collision details
506
+ const result: CollisionResult = {
507
+ collided: true,
508
+ solid,
509
+ normal: { x: 0, y: -sign },
510
+ penetration: step > 0 ? this.y + this.height - solid.y : solid.y + solid.height - this.y,
511
+ pushingSolid,
512
+ };
513
+
514
+ // Add to collisions array
515
+ collisions.push(result);
516
+
517
+ // Call collision handler if provided
518
+ if (collisionHandler) {
519
+ collisionHandler(result);
520
+ }
521
+
522
+ // Call actor's collision handler
523
+ this.onCollide(result);
524
+
525
+ collided = true;
526
+ }
527
+
528
+ if (collided) {
529
+ // Stop movement on collision
530
+ break;
531
+ } else {
532
+ // Move to next position
533
+ this._y = nextY;
534
+ remaining--;
535
+
536
+ // Update view every few pixels for better performance
537
+ // This reduces the number of view updates during movement
538
+ if (remaining % 4 === 0 || remaining === 0) {
539
+ this.updateView();
540
+ }
541
+ }
542
+ }
543
+
544
+ // Restore solid's collidable state
545
+ if (pushingSolid) {
546
+ pushingSolid.collideable = true;
547
+ }
548
+
549
+ // Final view update if we moved
550
+ if (Math.abs(move) - remaining > 0) {
551
+ this.updateView();
552
+
553
+ // Update grid cells if actor moved and actor collisions are enabled
554
+ }
555
+
556
+ return collisions;
557
+ }
558
+
559
+ /**
560
+ * Updates the actor's view position.
561
+ */
562
+ public updateView(): void {
563
+ if (this.view && this.view.visible) {
564
+ this.view.x = this._x;
565
+ this.view.y = this._y;
566
+ }
567
+ }
568
+
569
+ /**
570
+ * Gets all solids at the specified position that could collide with this actor.
571
+ *
572
+ * @param _x - X position to check
573
+ * @param _y - Y position to check
574
+ * @returns Array of solids at the position
575
+ */
576
+ protected getSolidsAt(_x: number, _y: number): Solid[] {
577
+ return this.system.getSolidsAt(_x, _y, this);
578
+ }
579
+
580
+ /**
581
+ * Checks if this actor is colliding with another actor.
582
+ * The collision will only occur if:
583
+ * 1. Both actors are active
584
+ * 2. Neither actor has disabled actor collisions
585
+ * 3. The collision layers and masks match:
586
+ * - (this.collisionLayer & other.collisionMask) !== 0
587
+ * - (other.collisionLayer & this.collisionMask) !== 0
588
+ *
589
+ * @param actor - The actor to check collision with
590
+ * @returns Collision result with information about the collision
591
+ */
592
+ public checkActorCollision(actor: Actor): ActorCollisionResult {
593
+ // Skip if either actor is not active
594
+ if (!this.active || !actor.active) {
595
+ return { collided: false, actor };
596
+ }
597
+
598
+ // Skip if either actor has disabled actor collisions
599
+ if (this.disableActorCollisions || actor.disableActorCollisions) {
600
+ return { collided: false, actor };
601
+ }
602
+
603
+ // Skip if the actors can't collide based on collision layers
604
+ if ((this.collisionLayer & actor.collisionMask) === 0 || (actor.collisionLayer & this.collisionMask) === 0) {
605
+ return { collided: false, actor };
606
+ }
607
+
608
+ // Simple AABB collision check
609
+ const thisLeft = this.x;
610
+ const thisRight = this.x + this.width;
611
+ const thisTop = this.y;
612
+ const thisBottom = this.y + this.height;
613
+
614
+ const otherLeft = actor.x;
615
+ const otherRight = actor.x + actor.width;
616
+ const otherTop = actor.y;
617
+ const otherBottom = actor.y + actor.height;
618
+
619
+ // Check if the bounding boxes overlap
620
+ if (thisRight > otherLeft && thisLeft < otherRight && thisBottom > otherTop && thisTop < otherBottom) {
621
+ // Calculate penetration and normal
622
+ const overlapX = Math.min(thisRight - otherLeft, otherRight - thisLeft);
623
+ const overlapY = Math.min(thisBottom - otherTop, otherBottom - thisTop);
624
+
625
+ let normal: Vector2;
626
+ let penetration: number;
627
+
628
+ // Determine the collision normal based on the smallest overlap
629
+ if (overlapX < overlapY) {
630
+ penetration = overlapX;
631
+ normal = {
632
+ x: thisLeft < otherLeft ? -1 : 1,
633
+ y: 0,
634
+ };
635
+ } else {
636
+ penetration = overlapY;
637
+ normal = {
638
+ x: 0,
639
+ y: thisTop < otherTop ? -1 : 1,
640
+ };
641
+ }
642
+
643
+ return {
644
+ collided: true,
645
+ actor,
646
+ normal,
647
+ penetration,
648
+ };
649
+ }
650
+
651
+ return { collided: false, actor };
652
+ }
653
+
654
+ /**
655
+ * Resolves a collision with another actor.
656
+ *
657
+ * @param result - The collision result to resolve
658
+ * @param shouldMove - Whether this actor should move to resolve the collision
659
+ * @returns The updated collision result
660
+ */
661
+ public resolveActorCollision(result: ActorCollisionResult): ActorCollisionResult {
662
+ if (!result.collided || !result.normal || !result.penetration) {
663
+ return result;
664
+ }
665
+
666
+ // Call the collision handler
667
+ this.onActorCollide(result);
668
+
669
+ // Example:
670
+ // Move this actor to resolve the collision
671
+ // this.x += result.normal.x * result.penetration * 0.5;
672
+ // this.y += result.normal.y * result.penetration * 0.5;
673
+
674
+ return result;
675
+ }
676
+
677
+ /**
678
+ * Sets the actor's size and updates grid cells if needed.
679
+ *
680
+ * @param width - New width in pixels
681
+ * @param height - New height in pixels
682
+ */
683
+ public setSize(width: number, height: number): void {
684
+ const sizeChanged = this.width !== width || this.height !== height;
685
+
686
+ this.width = width;
687
+ this.height = height;
688
+
689
+ // Update grid cells if size changed and actor collisions are enabled
690
+ if (sizeChanged && this.system.enableActorCollisions) {
691
+ this.updateGridCells();
692
+ }
693
+ }
694
+
695
+ /**
696
+ * Sets the actor's width and updates grid cells if needed.
697
+ *
698
+ * @param value - New width in pixels
699
+ */
700
+ public setWidth(value: number): void {
701
+ if (this.width !== value) {
702
+ this.width = value;
703
+
704
+ // Update grid cells if size changed and actor collisions are enabled
705
+ if (this.system.enableActorCollisions) {
706
+ this.updateGridCells();
707
+ }
708
+ }
709
+ }
710
+
711
+ /**
712
+ * Sets the actor's height and updates grid cells if needed.
713
+ *
714
+ * @param value - New height in pixels
715
+ */
716
+ public setHeight(value: number): void {
717
+ if (this.height !== value) {
718
+ this.height = value;
719
+
720
+ // Update grid cells if size changed and actor collisions are enabled
721
+ if (this.system.enableActorCollisions) {
722
+ this.updateGridCells();
723
+ }
724
+ }
725
+ }
726
+ }