@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/Group.ts ADDED
@@ -0,0 +1,181 @@
1
+ import { Actor } from './Actor';
2
+ import { Entity } from './Entity';
3
+ import { Sensor } from './Sensor';
4
+ import { Solid } from './Solid';
5
+ import { EntityData, PhysicsEntityType } from './types';
6
+
7
+ /**
8
+ * A container for managing collections of physics entities that move together.
9
+ * Groups are useful for creating compound objects, moving platforms with riders,
10
+ * and other scenarios where multiple entities need to move as one unit.
11
+ *
12
+ * Features:
13
+ * - Maintains relative positioning of child entities
14
+ * - Supports actors, solids, and sensors as children
15
+ * - Preserves world positions when adding entities
16
+ * - Provides type-safe access to children by entity type
17
+ *
18
+ * @typeParam T - Application type, defaults to base Application
19
+ *
20
+ * @example
21
+ * ```typescript
22
+ * // Create a moving platform with obstacles
23
+ * const platformGroup = physics.createGroup({
24
+ * type: 'MovingPlatform',
25
+ * position: [100, 300]
26
+ * });
27
+ *
28
+ * // Add platform and spikes
29
+ * const platform = physics.createSolid({
30
+ * type: 'Platform',
31
+ * size: [200, 32]
32
+ * });
33
+ *
34
+ * const spikes = physics.createSensor({
35
+ * type: 'Spikes',
36
+ * position: [0, -32],
37
+ * size: [200, 32]
38
+ * });
39
+ *
40
+ * platformGroup.add(platform);
41
+ * platformGroup.add(spikes);
42
+ *
43
+ * // Move the entire group
44
+ * gsap.to(platformGroup, {
45
+ * x: 500,
46
+ * duration: 2,
47
+ * yoyo: true,
48
+ * repeat: -1
49
+ * });
50
+ * ```
51
+ */
52
+ export class Group<D extends EntityData = EntityData> extends Entity<D> {
53
+ protected _visible: boolean = true;
54
+
55
+ public entityType: PhysicsEntityType = 'Group';
56
+ /** Map of child entities to their relative offsets from the group's position */
57
+ private childOffsets: Map<Entity, { x: number; y: number }> = new Map();
58
+ /** Whether this group's position is static (not affected by physics) */
59
+ public isStatic: boolean = true;
60
+
61
+ /**
62
+ * Gets the relative offset of a child entity from the group's position.
63
+ * @param entity - The child entity to get the offset for
64
+ * @returns The relative {x, y} offset of the entity
65
+ */
66
+ public getChildOffset(entity: Entity): { x: number; y: number } {
67
+ return this.childOffsets.get(entity) ?? { x: 0, y: 0 };
68
+ }
69
+
70
+ set visible(value: boolean) {
71
+ this.system.getEntitiesInGroup(this).forEach((entity) => {
72
+ entity.view.visible = value;
73
+ });
74
+ }
75
+
76
+ get visible(): boolean {
77
+ return this._visible;
78
+ }
79
+
80
+ /**
81
+ * Sets the group's X position, affecting all child entities.
82
+ */
83
+ set x(value: number) {
84
+ this._x = value;
85
+ }
86
+
87
+ get x(): number {
88
+ return this._x;
89
+ }
90
+
91
+ /**
92
+ * Sets the group's Y position, affecting all child entities.
93
+ */
94
+ set y(value: number) {
95
+ this._y = value;
96
+ }
97
+
98
+ get y(): number {
99
+ return this._y;
100
+ }
101
+
102
+ /**
103
+ * Add an entity to this container
104
+ * @param entity The entity to add
105
+ * @param preserveWorldPosition If true, the entity's world position will be preserved
106
+ */
107
+ public add(entity: Entity, offset: { x: number; y: number } = { x: 0, y: 0 }): Group {
108
+ entity.setGroup(this, offset);
109
+ return this;
110
+ }
111
+
112
+ /**
113
+ * Remove an entity from this container
114
+ */
115
+ public remove(entity: Entity): Group {
116
+ entity.setGroup(null);
117
+ return this;
118
+ }
119
+
120
+ /**
121
+ * Move the container and all its children
122
+ */
123
+ public move(x: number, y: number): void {
124
+ // Update container position
125
+ this._x += x;
126
+ this._y += y;
127
+
128
+ this.updateView();
129
+ }
130
+
131
+ /**
132
+ * Force move the container and all its children to an absolute position
133
+ */
134
+ public moveTo(x: number, y: number): void {
135
+ const dx = x - this.x;
136
+ const dy = y - this.y;
137
+ this.move(dx, dy);
138
+ }
139
+
140
+ /**
141
+ * Get all children of a specific type
142
+ */
143
+ public getChildrenByType<E extends Entity>(type: PhysicsEntityType): E[] {
144
+ return this.system
145
+ .getEntitiesInGroup(this)
146
+ .filter((child): child is E => child.entityType === type || child.type === type);
147
+ }
148
+
149
+ /**
150
+ * Get all actors in this container
151
+ */
152
+ public getActors(): Actor[] {
153
+ return this.getChildrenByType('Actor');
154
+ }
155
+
156
+ /**
157
+ * Get all solids in this container
158
+ */
159
+ public getSolids(): Solid[] {
160
+ return this.getChildrenByType('Solid');
161
+ }
162
+
163
+ /**
164
+ * Get all sensors in this container
165
+ */
166
+ public getSensors(): Sensor[] {
167
+ return this.getChildrenByType('Sensor');
168
+ }
169
+
170
+ /**
171
+ * Get all groups in this container
172
+ */
173
+ public getGroups(): Group[] {
174
+ return this.getChildrenByType('Group');
175
+ }
176
+
177
+ public destroy(): void {
178
+ this.system.removeGroup(this);
179
+ super.destroy();
180
+ }
181
+ }
package/src/Sensor.ts ADDED
@@ -0,0 +1,427 @@
1
+ import { Actor } from './Actor';
2
+ import { Entity } from './Entity';
3
+ import { Solid } from './Solid';
4
+ import { EntityData, PhysicsEntityConfig, PhysicsEntityType, SensorOverlap, Vector2 } from './types';
5
+
6
+ /**
7
+ * A trigger zone that can detect overlaps with actors.
8
+ * Sensors are typically used for collectibles, triggers, and detection zones.
9
+ *
10
+ * Features:
11
+ * - Overlap detection with specific actor types
12
+ * - Optional gravity and movement
13
+ * - Can be static or dynamic
14
+ * - Callbacks for enter/exit events
15
+ *
16
+ * @typeParam T - Application type, defaults to base Application
17
+ *
18
+ * @example
19
+ * ```typescript
20
+ * // Create a coin pickup sensor
21
+ * class Coin extends Sensor {
22
+ * constructor() {
23
+ * super({
24
+ * type: 'Coin',
25
+ * position: [100, 100],
26
+ * size: [32, 32],
27
+ * view: coinSprite
28
+ * });
29
+ *
30
+ * // Only detect overlaps with player
31
+ * this.collidableTypes = ['Player'];
32
+ * }
33
+ *
34
+ * // Called when a player enters the coin
35
+ * onActorEnter(actor: Actor) {
36
+ * if (actor.type === 'Player') {
37
+ * increaseScore(10);
38
+ * this.physics.removeSensor(this);
39
+ * }
40
+ * }
41
+ * }
42
+ *
43
+ * // Create a damage zone
44
+ * class Spikes extends Sensor {
45
+ * constructor() {
46
+ * super({
47
+ * type: 'Spikes',
48
+ * position: [300, 500],
49
+ * size: [100, 32],
50
+ * view: spikesSprite
51
+ * });
52
+ *
53
+ * this.collidableTypes = ['Player', 'Enemy'];
54
+ * this.isStatic = true; // Don't move or fall
55
+ * }
56
+ *
57
+ * onActorEnter(actor: Actor) {
58
+ * if (actor.type === 'Player') {
59
+ * actor.damage(10);
60
+ * }
61
+ * }
62
+ * }
63
+ * ```
64
+ */
65
+ export class Sensor<D extends EntityData = EntityData> extends Entity<D> {
66
+ public readonly entityType: PhysicsEntityType = 'Sensor';
67
+ /** Whether this sensor should be removed when culled */
68
+ public shouldRemoveOnCull = false;
69
+
70
+ /** List of actor types this sensor can detect */
71
+ public collidableTypes: string[] = [];
72
+
73
+ /** Current velocity in pixels per second */
74
+ public velocity: Vector2 = { x: 0, y: 0 };
75
+
76
+ /** Whether this sensor should stay in place */
77
+ public isStatic: boolean = false;
78
+
79
+ /** Set of actors currently overlapping this sensor */
80
+ private overlappingActors: Set<Actor> = new Set();
81
+
82
+ /** Cache for isRidingSolid check */
83
+ private _isRidingSolidCache: boolean | null = null;
84
+
85
+ private _currentSensorOverlaps = new Set<SensorOverlap>();
86
+ private _currentOverlaps = new Set<Actor>();
87
+
88
+ public setPosition(x: number, y: number): void {
89
+ if (this.isStatic) {
90
+ this._x = x;
91
+ this._y = y;
92
+ this._xRemainder = 0;
93
+ this._yRemainder = 0;
94
+ this.updateView();
95
+ this.checkActorOverlaps();
96
+ } else {
97
+ super.setPosition(x, y);
98
+ }
99
+ }
100
+
101
+ set x(value: number) {
102
+ super.x = value;
103
+ if (this.isStatic) {
104
+ this._xRemainder = 0;
105
+ this.updateView();
106
+ this.checkActorOverlaps();
107
+ }
108
+ }
109
+
110
+ get x(): number {
111
+ return super.x;
112
+ }
113
+
114
+ set y(value: number) {
115
+ super.y = value;
116
+ if (this.isStatic) {
117
+ this._yRemainder = 0;
118
+ this.updateView();
119
+ this.checkActorOverlaps();
120
+ }
121
+ }
122
+
123
+ get y(): number {
124
+ return super.y;
125
+ }
126
+
127
+ /**
128
+ * Initializes or reinitializes the sensor with new configuration.
129
+ *
130
+ * @param config - Configuration for the sensor
131
+ */
132
+ public init(config: PhysicsEntityConfig<D>): void {
133
+ super.init(config);
134
+ if (!this.velocity) {
135
+ this.velocity = { x: 0, y: 0 };
136
+ }
137
+ this.velocity.x = 0;
138
+ this.velocity.y = 0;
139
+ if (!this.overlappingActors) {
140
+ this.overlappingActors = new Set();
141
+ }
142
+ this._isRidingSolidCache = null;
143
+ }
144
+
145
+ /**
146
+ * Checks if this sensor is riding the given solid.
147
+ * Takes into account gravity direction for proper riding detection.
148
+ *
149
+ * @param solid - The solid to check against
150
+ * @returns True if riding the solid
151
+ */
152
+ public isRiding(solid: Solid): boolean {
153
+ const gravityDirection = Math.sign(this.system.gravity);
154
+ if (gravityDirection > 0) {
155
+ // Normal gravity - check if we're on top of the solid
156
+ const sensorBottom = this.y + this.height;
157
+ const onTop = Math.abs(sensorBottom - solid.y) <= 1;
158
+ const overlap = this.x + this.width > solid.x && this.x < solid.x + solid.width;
159
+ return onTop && overlap;
160
+ } else {
161
+ // Reversed gravity - check if we're on bottom of the solid
162
+ const sensorTop = this.y;
163
+ const onBottom = Math.abs(sensorTop - (solid.y + solid.height)) <= 1;
164
+ const overlap = this.x + this.width > solid.x && this.x < solid.x + solid.width;
165
+ return onBottom && overlap;
166
+ }
167
+ }
168
+
169
+ /**
170
+ * Checks if this sensor is riding any solid.
171
+ * Uses caching to optimize multiple checks per frame.
172
+ *
173
+ * @returns True if riding any solid
174
+ */
175
+ public isRidingSolid(): boolean {
176
+ // Return cached value if available
177
+ if (this._isRidingSolidCache !== null) {
178
+ return this._isRidingSolidCache;
179
+ }
180
+
181
+ // Calculate and cache the result
182
+ const solids = this.getSolidsAt(this.x, this.system.gravity > 0 ? this.y + 1 : this.y - 1);
183
+ this._isRidingSolidCache = solids.some((solid) => this.isRiding(solid));
184
+ return this._isRidingSolidCache;
185
+ }
186
+
187
+ /**
188
+ * Force moves the sensor to a new position, ignoring static state and collisions.
189
+ *
190
+ * @param x - New X position
191
+ * @param y - New Y position
192
+ */
193
+ public moveStatic(x: number, y: number): void {
194
+ this._x = x;
195
+ this._y = y;
196
+ this._xRemainder = 0;
197
+ this._yRemainder = 0;
198
+ this.updateView();
199
+ this.checkActorOverlaps();
200
+ }
201
+
202
+ /**
203
+ * Moves the sensor horizontally, passing through solids.
204
+ *
205
+ * @param amount - Distance to move in pixels
206
+ */
207
+ public moveX(amount: number): void {
208
+ if (this.isStatic) {
209
+ return;
210
+ }
211
+
212
+ this._xRemainder += amount;
213
+ const move = Math.round(this._xRemainder);
214
+
215
+ if (move !== 0) {
216
+ this._xRemainder -= move;
217
+ const sign = Math.sign(move);
218
+ let remaining = Math.abs(move);
219
+ while (remaining > 0) {
220
+ const step = sign;
221
+ const nextX = this.x + step;
222
+
223
+ // Check for collision with any solid
224
+ let collided = false;
225
+ for (const solid of this.getSolidsAt(nextX, this.y)) {
226
+ if (solid.canCollide) {
227
+ collided = true;
228
+ break;
229
+ }
230
+ }
231
+
232
+ if (!collided) {
233
+ this._x = nextX;
234
+ remaining--;
235
+ this.updateView();
236
+ this.checkActorOverlaps();
237
+ } else {
238
+ // Stop horizontal movement when hitting a solid
239
+ this.velocity.x = 0;
240
+ break;
241
+ }
242
+ }
243
+ }
244
+ }
245
+
246
+ /**
247
+ * Moves the sensor vertically, colliding with solids for riding.
248
+ *
249
+ * @param amount - Distance to move in pixels
250
+ */
251
+ public moveY(amount: number): void {
252
+ if (this.isStatic) {
253
+ return;
254
+ }
255
+
256
+ this._yRemainder += amount;
257
+ const move = Math.round(this._yRemainder);
258
+
259
+ if (move !== 0) {
260
+ this._yRemainder -= move;
261
+ const sign = Math.sign(move);
262
+
263
+ let remaining = Math.abs(move);
264
+ while (remaining > 0) {
265
+ const step = sign;
266
+ const nextY = this.y + step;
267
+
268
+ // Check for collision with any solid
269
+ let collided = false;
270
+ // Only check collisions when moving in the direction of gravity
271
+ if (Math.sign(this.system.gravity) === sign) {
272
+ for (const solid of this.getSolidsAt(this.x, nextY)) {
273
+ if (solid.canCollide) {
274
+ collided = true;
275
+ break;
276
+ }
277
+ }
278
+ }
279
+
280
+ if (!collided) {
281
+ this._y = nextY;
282
+ remaining--;
283
+ this.updateView();
284
+ this.checkActorOverlaps();
285
+ } else {
286
+ // Stop vertical movement when landing on a solid
287
+ this.velocity.y = 0;
288
+ break;
289
+ }
290
+ }
291
+ }
292
+ }
293
+
294
+ /**
295
+ * Updates the sensor's position and checks for overlapping actors.
296
+ *
297
+ * @param deltaTime - Delta time in seconds
298
+ */
299
+ public update(deltaTime: number): void {
300
+ // Reset the cache at the start of each update
301
+ this._isRidingSolidCache = null;
302
+
303
+ if (!this.active) {
304
+ return;
305
+ }
306
+
307
+ // Only apply gravity if not static and not riding a solid
308
+ if (!this.isStatic && !this.isRidingSolid()) {
309
+ this.velocity.y += this.system.gravity * deltaTime;
310
+ }
311
+
312
+ // Move
313
+ if (this.velocity.x !== 0) {
314
+ this.moveX(this.velocity.x * deltaTime);
315
+ }
316
+ if (this.velocity.y !== 0) {
317
+ this.moveY(this.velocity.y * deltaTime);
318
+ }
319
+ }
320
+
321
+ /**
322
+ * Checks for overlapping actors and triggers callbacks.
323
+ *
324
+ * @returns Set of current overlaps
325
+ */
326
+ public checkActorOverlaps(): Set<SensorOverlap> {
327
+ // Skip if sensor has no collision mask or is inactive
328
+ if (this.collisionMask === 0 || !this.active) {
329
+ return new Set();
330
+ }
331
+
332
+ this._currentSensorOverlaps.clear();
333
+ this._currentOverlaps.clear();
334
+
335
+ // Get all actors at current position - use spatial filtering first
336
+ // Only get actors that are potentially in the same grid cells
337
+
338
+ // Cache collision layer and mask for faster access
339
+ const sensorLayer = this.collisionLayer;
340
+ const sensorMask = this.collisionMask;
341
+
342
+ // Get actors by type, but only process those that could be nearby
343
+ const nearbyActors = this.system.getActorsByType(this.collidableTypes);
344
+
345
+ for (const actor of nearbyActors) {
346
+ // Skip inactive actors
347
+ if (!actor.active) continue;
348
+
349
+ // Fast collision layer check
350
+ if ((sensorLayer & actor.collisionMask) === 0 || (actor.collisionLayer & sensorMask) === 0) {
351
+ continue;
352
+ }
353
+
354
+ // Fast AABB check before detailed overlap
355
+ if (this.system.aabbOverlap(this, actor)) {
356
+ this._currentOverlaps.add(actor);
357
+ if (!this.overlappingActors.has(actor)) {
358
+ // New overlap
359
+ this._currentSensorOverlaps.add({
360
+ actor,
361
+ sensor: this,
362
+ type: `${actor.type}|${this.type}`,
363
+ });
364
+
365
+ this.onActorEnter(actor);
366
+ }
367
+ }
368
+ }
369
+
370
+ // Check for actors that are no longer overlapping
371
+ for (const actor of this.overlappingActors) {
372
+ if (!this._currentOverlaps.has(actor)) {
373
+ this.onActorExit(actor);
374
+ }
375
+ }
376
+
377
+ // Swap the sets to avoid unnecessary clear and forEach operations
378
+ const temp = this.overlappingActors;
379
+ this.overlappingActors = this._currentOverlaps;
380
+ this._currentOverlaps = temp;
381
+ this._currentOverlaps.clear();
382
+
383
+ return this._currentSensorOverlaps;
384
+ }
385
+
386
+ public reset(): void {
387
+ super.reset();
388
+ this._currentSensorOverlaps.clear();
389
+ this._currentOverlaps.clear();
390
+ this._isRidingSolidCache = null;
391
+ this.velocity = { x: 0, y: 0 };
392
+ this.overlappingActors = new Set();
393
+ }
394
+
395
+ /**
396
+ * Called when an actor starts overlapping with this sensor.
397
+ * Override this to handle overlap start events.
398
+ *
399
+ * @param actor - The actor that entered
400
+ */
401
+ public onActorEnter<A extends Actor = Actor>(actor: A): void {
402
+ // Override in subclass
403
+ void actor;
404
+ }
405
+
406
+ /**
407
+ * Called when an actor stops overlapping with this sensor.
408
+ * Override this to handle overlap end events.
409
+ *
410
+ * @param actor - The actor that exited
411
+ */
412
+ public onActorExit<A extends Actor = Actor>(actor: A): void {
413
+ // Override in subclass
414
+ void actor;
415
+ }
416
+
417
+ /**
418
+ * Gets all solids at the specified position.
419
+ *
420
+ * @param x - X position to check
421
+ * @param y - Y position to check
422
+ * @returns Array of solids at the position
423
+ */
424
+ protected getSolidsAt(x: number, y: number): Solid[] {
425
+ return this.system.getSolidsAt(x, y, this);
426
+ }
427
+ }