littlejsengine 1.24.1 → 1.24.3

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.
Files changed (44) hide show
  1. package/CHANGELOG.md +78 -1
  2. package/EDITOR.md +2 -0
  3. package/FAQ.md +13 -2
  4. package/README.md +1 -1
  5. package/REFERENCE.md +22 -7
  6. package/dist/littlejs.d.ts +204 -118
  7. package/dist/littlejs.esm.js +2479 -1983
  8. package/dist/littlejs.esm.min.js +3 -3
  9. package/dist/littlejs.js +2476 -1983
  10. package/dist/littlejs.min.js +2 -2
  11. package/dist/littlejs.release.js +2434 -1990
  12. package/package.json +1 -1
  13. package/plugins/box2d.js +9 -5
  14. package/plugins/gltf.js +67 -9
  15. package/plugins/lightSystem.js +1 -1
  16. package/plugins/math3d.js +1014 -1013
  17. package/plugins/medalSystem.js +7 -11
  18. package/plugins/particleEffects.js +2 -2
  19. package/plugins/pathFinder.js +2 -2
  20. package/plugins/pluginExport.js +1 -0
  21. package/plugins/render3d.js +131 -55
  22. package/plugins/render3dEditor.js +24 -10
  23. package/plugins/render3dExtras.js +41 -25
  24. package/plugins/render3dLevel.js +21 -12
  25. package/plugins/render3dVoxels.js +2 -2
  26. package/plugins/textureSheet.js +109 -7
  27. package/plugins/tweakables.js +1 -1
  28. package/plugins/tweenSystem.js +2 -2
  29. package/plugins/uiSystem.js +6 -2
  30. package/src/engine.js +60 -23
  31. package/src/engineAudio.js +16 -6
  32. package/src/engineBuild.mjs +49 -2
  33. package/src/engineDebug.js +4 -1
  34. package/src/engineDraw.js +40 -25
  35. package/src/engineEditor.js +82 -50
  36. package/src/engineExport.js +2 -0
  37. package/src/engineInput.js +21 -4
  38. package/src/engineMath.js +6 -2
  39. package/src/engineObject.js +675 -675
  40. package/src/engineParticles.js +1 -1
  41. package/src/engineSettings.js +16 -0
  42. package/src/engineTileLayer.js +85 -22
  43. package/src/engineUtilities.js +20 -8
  44. package/src/engineWebGL.js +15 -11
@@ -1,676 +1,676 @@
1
- /**
2
- * LittleJS Object System
3
- * - EngineObject is the base class for all game objects
4
- * - Handles automatic updating, rendering, physics, and collision
5
- * - Supports parent-child hierarchies with transform inheritance
6
- * - 2D physics with velocity, acceleration, damping, and gravity
7
- * - Collision system with tiles and other objects
8
- * - Renders sprites from tile sheets with color and rotation
9
- * - Objects sorted by renderOrder for layered rendering
10
- */
11
-
12
- 'use strict';
13
-
14
- /**
15
- * LittleJS Object Base Object Class
16
- * - Top level object class used by the engine
17
- * - Automatically adds self to object list
18
- * - Will be updated and rendered each frame
19
- * - Renders as a sprite from a tilesheet by default
20
- * - Can have color and additive color applied
21
- * - 2D Physics and collision system
22
- * - Sorted by renderOrder
23
- * - Objects can have children attached
24
- * - Parents are updated before children, and set child transform
25
- * - Call destroy() to get rid of objects
26
- *
27
- * The physics system used by objects is simple and fast with some caveats...
28
- * - Collision uses the axis aligned size, the object's rotation angle is only for rendering
29
- * - Objects are guaranteed to not intersect tile collision from physics
30
- * - If an object starts or is moved inside tile collision, it will not collide with that tile
31
- * - Collision for objects can be set to be solid to block other objects
32
- * - Objects may get pushed into overlapping other solid objects, if so they will push away
33
- * - A static solid (mass 0) moved by its velocity, like a door or an elevator, pushes objects out of its way
34
- * - Solid objects are more performance intensive and should be used sparingly
35
- * @memberof Engine
36
- * @example
37
- * // create an engine object, normally you would first extend the class with your own
38
- * const pos = vec2(2,3);
39
- * const object = new EngineObject(pos);
40
- */
41
- class EngineObject
42
- {
43
- /** Create an engine object and adds it to the list of objects
44
- * @param {Vector2} [pos=vec2()] - World space position of the object
45
- * @param {Vector2} [size=vec2(1)] - World space size of the object
46
- * @param {TileInfo} [tileInfo] - Tile info to render object (undefined is untextured)
47
- * @param {number} [angle] - Angle the object is rotated by
48
- * @param {Color} [color=WHITE] - Color to apply to tile when rendered
49
- * @param {number} [renderOrder] - Objects sorted by renderOrder before being rendered
50
- */
51
- constructor(pos=vec2(), size=vec2(1), tileInfo, angle=0, color=WHITE, renderOrder=0)
52
- {
53
- // check passed in params
54
- ASSERT(isVector2(pos), 'object pos must be a vec2');
55
- ASSERT(isVector2(size), 'object size must be a vec2');
56
- ASSERT(!tileInfo || tileInfo instanceof TileInfo, 'object tileInfo should be a TileInfo or undefined');
57
- ASSERT(typeof angle === 'number' && isFinite(angle), 'object angle should be a number');
58
- ASSERT(isColor(color), 'object color should be a valid rgba color');
59
- ASSERT(typeof renderOrder === 'number', 'object renderOrder should be a number');
60
-
61
- /** @property {Vector2} - World space position of the object */
62
- this.pos = pos.copy();
63
- /** @property {Vector2} - World space width and height of the object */
64
- this.size = size.copy();
65
- /** @property {Vector2|undefined} - Size of object used for drawing, uses size if not set
66
- * @type {Vector2|undefined} */
67
- this.drawSize = undefined;
68
- /** @property {TileInfo|undefined} - Tile info to render object (undefined is untextured)
69
- * @type {TileInfo|undefined} */
70
- this.tileInfo = tileInfo;
71
- /** @property {number} - Angle to rotate the object */
72
- this.angle = angle;
73
- /** @property {Color} - Color to apply when rendered */
74
- this.color = color.copy();
75
- /** @property {Color|undefined} - Additive color to apply when rendered
76
- * @type {Color|undefined} */
77
- this.additiveColor = undefined;
78
- /** @property {Shader|undefined} - Custom shader to render with, undefined for the engine's own
79
- * @type {Shader|undefined} */
80
- this.shader = undefined;
81
- /** @property {boolean} - Does this object draw into the light system's shadow map; false for a floor layer, a background, a pickup */
82
- this.castShadow = true;
83
- /** @property {number} - With the light system, how much it lights itself: 0 lit only by the lights, 1 full
84
- * brightness in its own colors whatever the lights do, between partly; drawn into the lightmap through
85
- * renderEmissive, as 3D's emissive. Exact for solid pixels; a partly transparent one is self lit by its alpha
86
- * too, so a half alpha pixel shows at a quarter and a fading emissive sprite fades a little faster */
87
- this.emissive = 0;
88
- /** @property {boolean} - Should the rendered tile flip along the y axis. Affects rendering and the local→world transform of attached children (a mirrored parent flips its children's localPos.x and localAngle). Does not affect this object's own physics, collision, or localToWorld/worldToLocal. */
89
- this.mirror = false;
90
- /** @property {boolean} - Has object been destroyed? */
91
- this.destroyed = false;
92
- this.updatePass = 0; // the engine update pass it was last updated in, so nothing updates twice in one
93
-
94
- // physical properties
95
- /** @property {number} - How heavy the object is, static if 0: a static object moves by its velocity but does not
96
- * collide on its own, the moving ones collide with it */
97
- this.mass = objectDefaultMass;
98
- /** @property {number} - Fraction of velocity kept each frame, 1 keeps all of it, 0 stops at once */
99
- this.damping = objectDefaultDamping;
100
- /** @property {number} - Fraction of angular velocity kept each frame, 1 keeps all of it, 0 stops at once */
101
- this.angleDamping = objectDefaultAngleDamping;
102
- /** @property {number} - How bouncy the object is when colliding (0-1) */
103
- this.restitution = objectDefaultRestitution;
104
- /** @property {number} - Fraction of sliding speed kept each frame on the ground, 1 is no friction, 0 stops at
105
- * once, the more slippery of the object and its ground is used */
106
- this.friction = objectDefaultFriction;
107
- /** @property {number} - How much to scale gravity by for this object */
108
- this.gravityScale = 1;
109
- /** @property {number} - Objects are sorted by render order */
110
- this.renderOrder = renderOrder;
111
- /** @property {Vector2} - Velocity of the object, in world units per frame */
112
- this.velocity = vec2();
113
- /** @property {number} - Angular velocity of the object, in radians per frame */
114
- this.angleVelocity = 0;
115
- /** @property {number} - Track when object was created */
116
- this.spawnTime = time;
117
- /** @property {Array<EngineObject>} - List of children of this object
118
- * @type {Array<EngineObject>} */
119
- this.children = [];
120
- /** @property {boolean} - Limit object speed along x and y axis */
121
- this.clampSpeed = true;
122
- /** @property {EngineObject|undefined} - Object we are standing on, if any
123
- * @type {EngineObject|undefined} */
124
- this.groundObject = undefined;
125
-
126
- // parent child system
127
- /** @property {EngineObject|undefined} - Parent of object if in local space
128
- * @type {EngineObject|undefined} */
129
- this.parent = undefined;
130
- /** @property {Vector2|undefined} - Position relative to the parent, only while attached to one
131
- * @type {Vector2|undefined} */
132
- this.localPos = undefined;
133
- /** @property {number} - Local angle if child */
134
- this.localAngle = 0;
135
-
136
- // collision flags
137
- /** @property {boolean} - Object collides with the level, its tile collision layers
138
- * @type {boolean} */
139
- this.collideLevel = false;
140
- /** @property {boolean} - Object collides with solid objects */
141
- this.collideSolidObjects = false;
142
- /** @property {boolean} - Object collides with and blocks other objects */
143
- this.isSolid = false;
144
- /** @property {boolean} - Object collides with raycasts */
145
- this.collideRaycast = false;
146
-
147
- /** @property {boolean} - Object is skipped by engineObjectsDestroy, for things that outlive a level like a camera
148
- * - Calling destroy on it still destroys it, and its children go with it either way */
149
- this.persistent = false;
150
-
151
- // add to list of objects
152
- engineObjects.push(this);
153
- }
154
-
155
- /** Update the object transform, called automatically by engine even when paused
156
- * @param {boolean} [updateChildren] - Also update the children's transforms */
157
- updateTransforms(updateChildren=true)
158
- {
159
- const parent = this.parent;
160
- if (parent)
161
- {
162
- // compose with parent transform inline to avoid intermediate vector allocs
163
- const mirror = parent.getMirrorSign();
164
- const lp = this.localPos, pp = parent.pos;
165
- const lx = lp.x*mirror, ly = lp.y, pa = parent.angle;
166
- if (pa)
167
- {
168
- const c = cos(-pa), s = sin(-pa);
169
- this.pos.set(lx*c - ly*s + pp.x, lx*s + ly*c + pp.y);
170
- }
171
- else
172
- this.pos.set(lx + pp.x, ly + pp.y);
173
- this.angle = mirror*this.localAngle + pa;
174
- }
175
-
176
- // update children
177
- if (updateChildren)
178
- for (const child of this.children)
179
- child.updateTransforms();
180
- }
181
-
182
- /** Update the object physics, called automatically by engine once each frame. Can be overridden to stop or change how physics works for an object. */
183
- updatePhysics()
184
- {
185
- // child objects do not have physics
186
- ASSERT(!this.parent);
187
-
188
- // bail if a collision callback destroyed us mid-frame
189
- if (this.destroyed) return;
190
-
191
- // limit max speed to prevent missing collisions, only for what collides: with solids, or with tiles while it
192
- // has a mass, which tile collision needs; anything else moves as fast as it is told
193
- if (this.clampSpeed && enablePhysicsSolver && (this.collideSolidObjects || this.collideLevel && this.mass))
194
- {
195
- this.velocity.x = clamp(this.velocity.x, -objectMaxSpeed, objectMaxSpeed);
196
- this.velocity.y = clamp(this.velocity.y, -objectMaxSpeed, objectMaxSpeed);
197
- }
198
-
199
- // physics sanity checks
200
- ASSERT(this.angleDamping >= 0 && this.angleDamping <= 1, 'angleDamping must be 0 to 1, the fraction kept each frame');
201
- ASSERT(this.damping >= 0 && this.damping <= 1, 'damping must be 0 to 1, the fraction of velocity kept each frame');
202
-
203
- // apply physics; only the solver needs where the object was, so only then is it copied
204
- const solve = enablePhysicsSolver && this.mass;
205
- const oldPos = solve ? this.pos.copy() : undefined;
206
- this.velocity.x *= this.damping;
207
- this.velocity.y *= this.damping;
208
- if (this.mass)
209
- {
210
- // apply gravity only if it has mass
211
- this.velocity.x += gravity.x * this.gravityScale;
212
- this.velocity.y += gravity.y * this.gravityScale;
213
- }
214
- this.pos.x += this.velocity.x;
215
- this.pos.y += this.velocity.y;
216
- this.angle += this.angleVelocity *= this.angleDamping;
217
-
218
- // don't do collision for static objects or if solver disabled
219
- if (!solve) return;
220
-
221
- // which way is down for this object, a negative gravityScale falls up and lands on ceilings
222
- const gravityY = this.gravityScale < 0 ? -gravity.y : gravity.y;
223
- const wasFalling = this.velocity.y < 0 && gravityY < 0 || this.velocity.y > 0 && gravityY > 0;
224
- if (this.groundObject)
225
- {
226
- // apply friction in local space of ground object
227
- const friction = max(this.friction, this.groundObject.friction);
228
- const groundSpeed = this.groundObject.velocity.x;
229
- this.velocity.x = groundSpeed + (this.velocity.x - groundSpeed) * friction;
230
- this.groundObject = undefined;
231
- }
232
-
233
- // an object with no width or height has no box to push out of, or to be pushed out of
234
- if (this.collideSolidObjects && this.size.x && this.size.y)
235
- {
236
- // check collisions against solid objects
237
- const epsilon = .001; // necessary to push slightly outside of the collision
238
- for (const o of engineObjectsCollideStaticLast)
239
- {
240
- // skip destroyed, child objects, self collision, or objects with no box
241
- if (o.destroyed || o.parent || o === this || !o.size.x || !o.size.y) continue;
242
-
243
- // non solid objects don't collide with each other
244
- if (!this.isSolid && !o.isSolid) continue;
245
-
246
- // check collision
247
- if (!this.isOverlappingObject(o)) continue;
248
-
249
- // each moving object checks its own contacts, so a pair the other one already asked about this frame
250
- // is not asked twice: left overlapping, ignored or only nudged apart it is skipped, and one both said
251
- // to resolve, pushed back together since, is resolved again
252
- const answer = engineObjectsCollidePairAnswer(o, this);
253
- if (answer === false) continue;
254
- if (!answer)
255
- {
256
- // notify objects of collision and check if should be resolved
257
- const collide1 = this.collideWithObject(o);
258
- const collide2 = o.collideWithObject(this);
259
- if (!collide1 || !collide2)
260
- {
261
- engineObjectsCollidePairAdd(this, o);
262
- continue;
263
- }
264
- }
265
-
266
- const wasOverlapping = isOverlapping(oldPos, this.size, o.pos, o.size);
267
- if (wasOverlapping && (!o.mass || o.groundObject))
268
- {
269
- // a static solid that moved into it, like a door or an elevator, pushes it out the shortest way
270
- // at once and carries it along, it would only drift out slowly and the solid would pass through;
271
- // an object standing on something counts as fixed too, so a stack on an elevator rides together;
272
- // it bounces off relative to the mover, a paddle moved by setting pos bounces a ball as a wall does
273
- const push = collideBoxBox(this.pos, this.size, o.pos, o.size);
274
- if (push)
275
- {
276
- this.pos.x += push.x + sign(push.x) * epsilon;
277
- this.pos.y += push.y + sign(push.y) * epsilon;
278
- const restitution = max(this.restitution, o.restitution);
279
- if (push.x)
280
- {
281
- const v = this.velocity.x - o.velocity.x;
282
- if (v * push.x < 0) // moving into it
283
- this.velocity.x = o.velocity.x - v * restitution;
284
- }
285
- else
286
- {
287
- const v = this.velocity.y - o.velocity.y;
288
- if (v * push.y < 0) // moving into it
289
- this.velocity.y = o.velocity.y - v * restitution;
290
- if (push.y * gravityY < 0) // pushed up against gravity, it stands on it
291
- this.groundObject = o;
292
- }
293
- }
294
- engineObjectsCollidePairAdd(this, o);
295
-
296
- debugPhysics && debugOverlap(this.pos, this.size, o.pos, o.size, '#f00', undefined, false);
297
- continue;
298
- }
299
- if (wasOverlapping)
300
- {
301
- // if already was touching, try to push away
302
- const deltaPos = oldPos.subtract(o.pos);
303
- const length = deltaPos.length();
304
- const pushAwayAccel = .001;
305
- const velocity = length < .001 ? vec2(0, pushAwayAccel) : deltaPos.scale(pushAwayAccel/length);
306
- this.velocity = this.velocity.add(velocity);
307
- if (o.mass) // push away other object if not fixed
308
- o.velocity = o.velocity.subtract(velocity);
309
- engineObjectsCollidePairAdd(this, o);
310
-
311
- debugPhysics && debugOverlap(this.pos, this.size, o.pos, o.size, '#f00', undefined, false);
312
- continue;
313
- }
314
-
315
- // check for collision
316
- const sizeBoth = this.size.add(o.size);
317
- // prefer to push up if small delta, up away from this object's own gravity
318
- const smallStepUp = (gravityY > 0 ? o.pos.y - oldPos.y : oldPos.y - o.pos.y)*2 > sizeBoth.y - abs(gravityY);
319
- const isBlockedX = abs(oldPos.y - o.pos.y)*2 < sizeBoth.y;
320
- const isBlockedY = abs(oldPos.x - o.pos.x)*2 < sizeBoth.x;
321
- const restitution = max(this.restitution, o.restitution);
322
-
323
- if (smallStepUp || isBlockedY || !isBlockedX) // resolve y collision
324
- {
325
- // push outside object collision
326
- this.pos.y = o.pos.y + (sizeBoth.y/2 + epsilon) * sign(oldPos.y - o.pos.y);
327
- if ((o.groundObject && wasFalling) || !o.mass)
328
- {
329
- // set ground object if landed on something
330
- if (wasFalling)
331
- this.groundObject = o;
332
-
333
- // bounce if other object is fixed or grounded, relative to it so a rider keeps up with a
334
- // platform moving down instead of landing on it again every few frames
335
- this.velocity.y = o.velocity.y - (this.velocity.y - o.velocity.y) * restitution;
336
- }
337
- else // o has mass here, the other branch already handled the massless case
338
- {
339
- // inelastic collision
340
- const inelastic = (this.mass * this.velocity.y + o.mass * o.velocity.y) / (this.mass + o.mass);
341
-
342
- // elastic collision
343
- const elastic0 = this.velocity.y * (this.mass - o.mass) / (this.mass + o.mass)
344
- + o.velocity.y * 2 * o.mass / (this.mass + o.mass);
345
- const elastic1 = o.velocity.y * (o.mass - this.mass) / (this.mass + o.mass)
346
- + this.velocity.y * 2 * this.mass / (this.mass + o.mass);
347
-
348
- // lerp between elastic or inelastic based on restitution
349
- this.velocity.y = lerp(inelastic, elastic0, restitution);
350
- o.velocity.y = lerp(inelastic, elastic1, restitution);
351
- }
352
- }
353
- if (!smallStepUp && isBlockedX) // resolve x collision
354
- {
355
- // push outside collision
356
- this.pos.x = o.pos.x + (sizeBoth.x/2 + epsilon) * sign(oldPos.x - o.pos.x);
357
- if (o.mass)
358
- {
359
- // inelastic collision
360
- const inelastic = (this.mass * this.velocity.x + o.mass * o.velocity.x) / (this.mass + o.mass);
361
-
362
- // elastic collision
363
- const elastic0 = this.velocity.x * (this.mass - o.mass) / (this.mass + o.mass)
364
- + o.velocity.x * 2 * o.mass / (this.mass + o.mass);
365
- const elastic1 = o.velocity.x * (o.mass - this.mass) / (this.mass + o.mass)
366
- + this.velocity.x * 2 * this.mass / (this.mass + o.mass);
367
-
368
- // lerp between elastic or inelastic based on restitution
369
- this.velocity.x = lerp(inelastic, elastic0, restitution);
370
- o.velocity.x = lerp(inelastic, elastic1, restitution);
371
- }
372
- else // bounce if other object is fixed, relative to it as a landing is
373
- this.velocity.x = o.velocity.x - (this.velocity.x - o.velocity.x) * restitution;
374
- }
375
- engineObjectsCollidePairAdd(this, o, true);
376
- debugPhysics && debugOverlap(this.pos, this.size, o.pos, o.size, '#f0f', undefined, false);
377
- }
378
- }
379
- if (this.collideLevel)
380
- {
381
- // check collision against tiles
382
- const hitLayer = tileCollisionTest(this.pos, this.size, this);
383
- if (hitLayer)
384
- {
385
- // if already was stuck in collision, don't do anything
386
- // this should not happen unless something starts in collision
387
- if (!tileCollisionTest(oldPos, this.size, this))
388
- {
389
- // test which side we bounced off (or both if a corner)
390
- const isBlockedX = tileCollisionTest(vec2(this.pos.x, oldPos.y), this.size, this);
391
- const isBlockedY = tileCollisionTest(vec2(oldPos.x, this.pos.y), this.size, this);
392
- const restitution = max(this.restitution, hitLayer.restitution);
393
- if (isBlockedX)
394
- {
395
- // a ledge caught less than maxMove below its top lifts the object onto it instead of stopping it,
396
- // down off a ceiling ledge when gravity points up; zero gravity counts as down
397
- const epsilon = 1e-3;
398
- const maxMove = .1;
399
- const y = gravityY > 0 ?
400
- ceil( oldPos.y+this.size.y/2-1) - this.size.y/2 - epsilon :
401
- floor(oldPos.y-this.size.y/2+1) + this.size.y/2 + epsilon;
402
- if (abs(y - this.pos.y) < maxMove && !tileCollisionTest(vec2(this.pos.x, y), this.size, this))
403
- {
404
- this.pos.y = y;
405
- debugPhysics && debugRect(this.pos, this.size, '#ff0', 0, 0, false, false);
406
- return;
407
- }
408
-
409
- // move to previous X position and bounce
410
- this.pos.x = oldPos.x;
411
- this.velocity.x *= -restitution;
412
- }
413
- if (isBlockedY || !isBlockedX)
414
- {
415
- if (wasFalling)
416
- {
417
- // adjust position to slightly away from nearest tile
418
- // this prevents gap between object and ground
419
- const epsilon = .0001;
420
- const offset = this.size.y/2 + epsilon;
421
- // rounded in the layer's space as its collision test is, or a bottom a hair below a
422
- // grid line would round to the row under it, inside the floor, and fall through
423
- const layerY = hitLayer.pos.y;
424
- this.pos.y = layerY + (gravityY < 0 ?
425
- floor(oldPos.y - layerY - this.size.y/2) + offset :
426
- ceil( oldPos.y - layerY + this.size.y/2) - offset);
427
-
428
- // set ground object for tile collision
429
- this.groundObject = hitLayer;
430
- }
431
- else
432
- {
433
- // move to previous Y position
434
- this.pos.y = oldPos.y;
435
- this.groundObject = undefined;
436
- }
437
- // bounce velocity
438
- this.velocity.y *= -restitution;
439
- }
440
- debugPhysics && debugRect(this.pos, this.size, '#f00', 0, 0, false, false);
441
- }
442
- }
443
- }
444
- }
445
-
446
- /** Update the object, called automatically by engine once each frame. Does nothing by default. */
447
- update() {}
448
-
449
- /** Render the object, draws a tile by default, automatically called each frame, sorted by renderOrder */
450
- render()
451
- {
452
- // default object render
453
- drawTile(this.pos, this.drawSize || this.size, this.tileInfo, this.color, this.angle, this.mirror, this.additiveColor, glEnable, false);
454
- }
455
-
456
- /** Optional hook called during the light system plugin's lightmap pass to draw this object's lightmap contribution. Does nothing by default. */
457
- renderLight() {}
458
-
459
- /** Draw this object into the light system's shadow map, called during its shadow pass when castShadow is set.
460
- * Calls render() by default so the object casts its own shape; override to cast a different one, like a blob at a character's feet so its body stays lit;
461
- * screen space WebGL draws in render() are skipped during the pass */
462
- renderShadow() { this.render(); }
463
-
464
- /** Draw this object's shape into the light system's lightmap, called during its light pass when emissive is above
465
- * 0; what it draws shows in its own colors that much brighter. Calls render() by default so the whole object
466
- * glows; override to glow a part, like a robot's eyes */
467
- renderEmissive() { this.render(); }
468
-
469
- /** Destroy this object, destroy its children, detach its parent, and mark it for removal
470
- * @param {boolean} [immediate] - true removes attached effects like particle emitters at once, false lets them finish first */
471
- destroy(immediate=false)
472
- {
473
- if (this.destroyed) return;
474
-
475
- // disconnect from parent and destroy children
476
- this.destroyed = true;
477
- this.parent?.removeChild(this);
478
- for (const child of this.children)
479
- {
480
- child.parent = undefined;
481
- child.destroy(immediate);
482
- }
483
- }
484
-
485
- /** Convert from local space to world space
486
- * @param {Vector2} pos - local space point */
487
- localToWorld(pos) { return this.pos.add(pos.rotate(this.angle)); }
488
-
489
- /** Convert from world space to local space
490
- * @param {Vector2} pos - world space point */
491
- worldToLocal(pos) { return pos.subtract(this.pos).rotate(-this.angle); }
492
-
493
- /** Convert from local space to world space for a vector (rotation only)
494
- * @param {Vector2} vec - local space vector */
495
- localToWorldVector(vec) { return vec.rotate(this.angle); }
496
-
497
- /** Convert from world space to local space for a vector (rotation only)
498
- * @param {Vector2} vec - world space vector */
499
- worldToLocalVector(vec) { return vec.rotate(-this.angle); }
500
-
501
- /** Called to check if a tile collision should be resolved. Return true for physics to resolve the collision or false to ignore and resolve it manually.
502
- * - Called for each solid tile the physics tests, which can be several times a frame for the same tile, and for
503
- * positions it only tries, so keep it free of side effects or guard them to once a frame
504
- * - this.pos has already moved, so a check on where it came from, like a one way platform, needs the position
505
- * saved in update, as the platformer example does
506
- * @param {number} tileData - the value of the tile at the position
507
- * @param {Vector2} pos - the tile's bottom left corner in world space
508
- * @return {boolean} - true if the collision should be resolved by modifying it's position and velocity */
509
- collideWithTile(tileData, pos) { return tileData > 0; }
510
-
511
- /** Called by the engine to check if an object collision should be resolved. Return true for physics to resolve the collision or false to ignore and resolve it manually.
512
- * - Both objects of a touching pair are asked once a frame, whichever order they update in; an object that
513
- * destroys itself here is gone at the end of the frame and is still asked about the pairs left this frame, so a
514
- * bullet that should hit one thing checks its own destroyed flag first
515
- * @param {EngineObject} object - the object to test against
516
- * @param {Vector3} [push] - what it would take to move this object clear, a Vector3 from the 3D plugin, undefined in 2D
517
- * @return {boolean} - true if the collision should be resolved by modifying it's position and velocity
518
- */
519
- collideWithObject(object, push) { return true; }
520
-
521
- /** Get this object's up vector
522
- * @param {number} [scale] - length of the vector
523
- * @return {Vector2} */
524
- getUp(scale=1) { return vec2().setAngle(this.angle, scale); }
525
-
526
- /** Get this object's right vector
527
- * @param {number} [scale] - length of the vector
528
- * @return {Vector2} */
529
- getRight(scale=1) { return vec2().setAngle(this.angle+PI/2, scale); }
530
-
531
- /** How long since the object was created
532
- * @return {number} */
533
- getAliveTime() { return time - this.spawnTime; }
534
-
535
- /** Get the speed of this object
536
- * @return {number} */
537
- getSpeed() { return this.velocity.length(); }
538
-
539
- /** Apply acceleration to this object (adjust velocity, not affected by mass)
540
- * - Does nothing on a static object (mass 0), set its velocity instead
541
- * @param {Vector2} acceleration */
542
- applyAcceleration(acceleration)
543
- { if (this.mass) this.velocity = this.velocity.add(acceleration); }
544
-
545
- /** Apply angular acceleration to this object
546
- * - Does nothing on a static object (mass 0), set its angleVelocity instead
547
- * @param {number} acceleration */
548
- applyAngularAcceleration(acceleration)
549
- { if (this.mass) this.angleVelocity += acceleration; }
550
-
551
- /** Apply force to this object (adjust velocity, affected by mass)
552
- * - Does nothing on a static object (mass 0), set its velocity instead
553
- * @param {Vector2} force */
554
- applyForce(force)
555
- { if (this.mass) this.applyAcceleration(force.scale(1/this.mass)); }
556
-
557
- /** Get the direction of the mirror
558
- * @return {number} -1 if this.mirror is true, or 1 if not mirrored */
559
- getMirrorSign() { return this.mirror ? -1 : 1; }
560
-
561
- /** Attaches a child to this with a local transform, returns child for chaining
562
- * @param {EngineObject} child
563
- * @param {Vector2} [localPos=vec2()]
564
- * @param {number} [localAngle]
565
- * @return {EngineObject} The child object added */
566
- addChild(child, localPos=vec2(), localAngle=0)
567
- {
568
- ASSERT(!this.destroyed, 'cannot add child to destroyed object');
569
- if (this.destroyed) return child;
570
- ASSERT(!child.parent && !this.children.includes(child), 'child already has a parent, removeChild it first or use attach');
571
- ASSERT(child instanceof EngineObject, 'child must be an EngineObject');
572
- ASSERT(!child.destroyed, 'cannot add a destroyed child');
573
- for (let p = /** @type {EngineObject} */ (this); p; p = p.parent)
574
- ASSERT(p !== child, 'cannot add an object as a child of itself or of its own child');
575
- this.children.push(child);
576
- child.parent = this;
577
- child.localPos = localPos.copy();
578
- child.localAngle = localAngle;
579
- child.updateTransforms();
580
- return child;
581
- }
582
-
583
- /** Attaches a child to this without moving it: the local transform is worked out from where the child is now,
584
- * where addChild takes one; a child of something else is moved over, returns child for chaining
585
- * @param {EngineObject} child
586
- * @return {EngineObject} The child object attached */
587
- attach(child)
588
- {
589
- ASSERT(child instanceof EngineObject, 'child must be an EngineObject');
590
- ASSERT(child !== this, 'cannot attach self');
591
- child.parent?.removeChild(child);
592
- // the local values that updateTransforms turns back into the child's current pos and angle
593
- const mirror = this.getMirrorSign(), local = this.worldToLocal(child.pos);
594
- local.x *= mirror;
595
- return this.addChild(child, local, mirror * (child.angle - this.angle));
596
- }
597
-
598
- /** Removes a child from this one, it stays where it is in the world
599
- * @param {EngineObject} child */
600
- removeChild(child)
601
- {
602
- ASSERT(child.parent === this && this.children.includes(child));
603
- const i = this.children.indexOf(child);
604
- if (i < 0) return; // not a child of this one, release has no assert
605
- this.children.splice(i, 1);
606
- child.parent = child.localPos = undefined;
607
- }
608
-
609
- /** Check if this object's box overlaps another object's box
610
- * @param {EngineObject} object
611
- * @return {boolean} */
612
- isOverlappingObject(object)
613
- { return this.isOverlapping(object.pos, object.size); }
614
-
615
- /** Check if overlapping a point or aligned bounding box
616
- * @param {Vector2} pos - Center of box
617
- * @param {Vector2} [size=vec2()] - Size of box, uses a point if undefined
618
- * @return {boolean} */
619
- isOverlapping(pos, size=vec2())
620
- { return isOverlapping(this.pos, this.size, pos, size); }
621
-
622
- /** Set how this object collides
623
- * @param {boolean} [collideSolidObjects] - Does it collide with solid objects?
624
- * @param {boolean} [isSolid] - Does it collide with and block other objects? (expensive in large numbers)
625
- * @param {boolean} [collideLevel] - Does it collide with the level, its tile collision layers?
626
- * @param {boolean} [collideRaycast] - Does it collide with raycasts? */
627
- setCollision(collideSolidObjects=true, isSolid=true, collideLevel=true, collideRaycast=true)
628
- {
629
- ASSERT(collideSolidObjects || !isSolid, 'solid objects must be set to collide');
630
-
631
- this.collideSolidObjects = collideSolidObjects;
632
- this.isSolid = isSolid;
633
- this.collideLevel = collideLevel;
634
- this.collideRaycast = collideRaycast;
635
- }
636
-
637
- /** @deprecated since 1.20, use collideLevel
638
- * @type {boolean} */
639
- get collideTiles() { return this.collideLevel; }
640
- set collideTiles(collide) { this.collideLevel = collide; }
641
-
642
- /** Returns string containing info about this object for debugging
643
- * @return {string} */
644
- toString()
645
- {
646
- let text = 'type = ' + this.constructor.name;
647
- if (this.pos.x || this.pos.y)
648
- text += '\npos = ' + this.pos;
649
- if (this.velocity.x || this.velocity.y)
650
- text += '\nvelocity = ' + this.velocity;
651
- if (this.size.x || this.size.y)
652
- text += '\nsize = ' + this.size;
653
- if (this.angle)
654
- text += '\nangle = ' + this.angle.toFixed(3);
655
- text += '\ncolor = ' + this.color;
656
- return text;
657
- }
658
-
659
- /** Render debug info for this object */
660
- renderDebugInfo()
661
- {
662
- if (!debug) return;
663
-
664
- // check if there is anything to show
665
- const hasPhysics = this.collideLevel || this.collideSolidObjects || this.isSolid;
666
- if (!hasPhysics && !this.parent) return;
667
-
668
- // show object info for debugging
669
- const size = vec2(max(this.size.x, .2), max(this.size.y, .2));
670
- const color = rgb(this.collideLevel?1:0, this.collideSolidObjects?1:0, this.isSolid?1:0, .5);
671
- debugRect(this.pos, size, color, 0, hasPhysics ? 0 : this.angle, hasPhysics, false); // collision ignores the angle
672
- if (this.parent)
673
- debugRect(this.pos, size.scale(.8), rgb(1,1,1,.5), 0, this.angle, false, false);
674
- this.parent && debugLine(this.pos, this.parent.pos, rgb(1,1,1,.5), .5, 0, false);
675
- }
1
+ /**
2
+ * LittleJS Object System
3
+ * - EngineObject is the base class for all game objects
4
+ * - Handles automatic updating, rendering, physics, and collision
5
+ * - Supports parent-child hierarchies with transform inheritance
6
+ * - 2D physics with velocity, acceleration, damping, and gravity
7
+ * - Collision system with tiles and other objects
8
+ * - Renders sprites from tile sheets with color and rotation
9
+ * - Objects sorted by renderOrder for layered rendering
10
+ */
11
+
12
+ 'use strict';
13
+
14
+ /**
15
+ * LittleJS Object Base Object Class
16
+ * - Top level object class used by the engine
17
+ * - Automatically adds self to object list
18
+ * - Will be updated and rendered each frame
19
+ * - Renders as a sprite from a tilesheet by default
20
+ * - Can have color and additive color applied
21
+ * - 2D Physics and collision system
22
+ * - Sorted by renderOrder
23
+ * - Objects can have children attached
24
+ * - Parents are updated before children, and set child transform
25
+ * - Call destroy() to get rid of objects
26
+ *
27
+ * The physics system used by objects is simple and fast with some caveats...
28
+ * - Collision uses the axis aligned size, the object's rotation angle is only for rendering
29
+ * - Objects are guaranteed to not intersect tile collision from physics
30
+ * - If an object starts or is moved inside tile collision, it will not collide with that tile
31
+ * - Collision for objects can be set to be solid to block other objects
32
+ * - Objects may get pushed into overlapping other solid objects, if so they will push away
33
+ * - A static solid (mass 0) moved by its velocity, like a door or an elevator, pushes objects out of its way
34
+ * - Solid objects are more performance intensive and should be used sparingly
35
+ * @memberof Engine
36
+ * @example
37
+ * // create an engine object, normally you would first extend the class with your own
38
+ * const pos = vec2(2,3);
39
+ * const object = new EngineObject(pos);
40
+ */
41
+ class EngineObject
42
+ {
43
+ /** Create an engine object and adds it to the list of objects
44
+ * @param {Vector2} [pos=vec2()] - World space position of the object
45
+ * @param {Vector2} [size=vec2(1)] - World space size of the object
46
+ * @param {TileInfo} [tileInfo] - Tile info to render object (undefined is untextured)
47
+ * @param {number} [angle] - Angle the object is rotated by
48
+ * @param {Color} [color=WHITE] - Color to apply to tile when rendered
49
+ * @param {number} [renderOrder] - Objects sorted by renderOrder before being rendered
50
+ */
51
+ constructor(pos=vec2(), size=vec2(1), tileInfo, angle=0, color=WHITE, renderOrder=0)
52
+ {
53
+ // check passed in params
54
+ ASSERT(isVector2(pos), 'object pos must be a vec2');
55
+ ASSERT(isVector2(size), 'object size must be a vec2');
56
+ ASSERT(!tileInfo || tileInfo instanceof TileInfo, 'object tileInfo should be a TileInfo or undefined');
57
+ ASSERT(typeof angle === 'number' && isFinite(angle), 'object angle should be a number');
58
+ ASSERT(isColor(color), 'object color should be a valid rgba color');
59
+ ASSERT(typeof renderOrder === 'number', 'object renderOrder should be a number');
60
+
61
+ /** @property {Vector2} - World space position of the object */
62
+ this.pos = pos.copy();
63
+ /** @property {Vector2} - World space width and height of the object */
64
+ this.size = size.copy();
65
+ /** @property {Vector2|undefined} - Size of object used for drawing, uses size if not set
66
+ * @type {Vector2|undefined} */
67
+ this.drawSize = undefined;
68
+ /** @property {TileInfo|undefined} - Tile info to render object (undefined is untextured)
69
+ * @type {TileInfo|undefined} */
70
+ this.tileInfo = tileInfo;
71
+ /** @property {number} - Angle to rotate the object */
72
+ this.angle = angle;
73
+ /** @property {Color} - Color to apply when rendered */
74
+ this.color = color.copy();
75
+ /** @property {Color|undefined} - Additive color to apply when rendered
76
+ * @type {Color|undefined} */
77
+ this.additiveColor = undefined;
78
+ /** @property {Shader|undefined} - Custom shader to render with, undefined for the engine's own
79
+ * @type {Shader|undefined} */
80
+ this.shader = undefined;
81
+ /** @property {boolean} - Does this object draw into the light system's shadow map; false for a floor layer, a background, a pickup */
82
+ this.castShadow = true;
83
+ /** @property {number} - With the light system, how much it lights itself: 0 lit only by the lights, 1 full
84
+ * brightness in its own colors whatever the lights do, between partly; drawn into the lightmap through
85
+ * renderEmissive, as 3D's emissive. Exact for solid pixels; a partly transparent one is self lit by its alpha
86
+ * too, so a half alpha pixel shows at a quarter and a fading emissive sprite fades a little faster */
87
+ this.emissive = 0;
88
+ /** @property {boolean} - Should the rendered tile flip along the y axis. Affects rendering and the local→world transform of attached children (a mirrored parent flips its children's localPos.x and localAngle). Does not affect this object's own physics, collision, or localToWorld/worldToLocal. */
89
+ this.mirror = false;
90
+ /** @property {boolean} - Has object been destroyed? */
91
+ this.destroyed = false;
92
+ this.updatePass = 0; // the engine update pass it was last updated in, so nothing updates twice in one
93
+
94
+ // physical properties
95
+ /** @property {number} - How heavy the object is, static if 0: a static object moves by its velocity but does not
96
+ * collide on its own, the moving ones collide with it */
97
+ this.mass = objectDefaultMass;
98
+ /** @property {number} - Fraction of velocity kept each frame, 1 keeps all of it, 0 stops at once */
99
+ this.damping = objectDefaultDamping;
100
+ /** @property {number} - Fraction of angular velocity kept each frame, 1 keeps all of it, 0 stops at once */
101
+ this.angleDamping = objectDefaultAngleDamping;
102
+ /** @property {number} - How bouncy the object is when colliding (0-1) */
103
+ this.restitution = objectDefaultRestitution;
104
+ /** @property {number} - Fraction of sliding speed kept each frame on the ground, 1 is no friction, 0 stops at
105
+ * once, the more slippery of the object and its ground is used */
106
+ this.friction = objectDefaultFriction;
107
+ /** @property {number} - How much to scale gravity by for this object */
108
+ this.gravityScale = 1;
109
+ /** @property {number} - Objects are sorted by render order */
110
+ this.renderOrder = renderOrder;
111
+ /** @property {Vector2} - Velocity of the object, in world units per frame */
112
+ this.velocity = vec2();
113
+ /** @property {number} - Angular velocity of the object, in radians per frame */
114
+ this.angleVelocity = 0;
115
+ /** @property {number} - Track when object was created */
116
+ this.spawnTime = time;
117
+ /** @property {Array<EngineObject>} - List of children of this object
118
+ * @type {Array<EngineObject>} */
119
+ this.children = [];
120
+ /** @property {boolean} - Limit object speed along x and y axis */
121
+ this.clampSpeed = true;
122
+ /** @property {EngineObject|undefined} - Object we are standing on, if any
123
+ * @type {EngineObject|undefined} */
124
+ this.groundObject = undefined;
125
+
126
+ // parent child system
127
+ /** @property {EngineObject|undefined} - Parent of object if in local space
128
+ * @type {EngineObject|undefined} */
129
+ this.parent = undefined;
130
+ /** @property {Vector2|undefined} - Position relative to the parent, only while attached to one
131
+ * @type {Vector2|undefined} */
132
+ this.localPos = undefined;
133
+ /** @property {number} - Local angle if child */
134
+ this.localAngle = 0;
135
+
136
+ // collision flags
137
+ /** @property {boolean} - Object collides with the level, its tile collision layers
138
+ * @type {boolean} */
139
+ this.collideLevel = false;
140
+ /** @property {boolean} - Object collides with solid objects */
141
+ this.collideSolidObjects = false;
142
+ /** @property {boolean} - Object collides with and blocks other objects */
143
+ this.isSolid = false;
144
+ /** @property {boolean} - Object collides with raycasts */
145
+ this.collideRaycast = false;
146
+
147
+ /** @property {boolean} - Object is skipped by engineObjectsDestroy, for things that outlive a level like a camera
148
+ * - Calling destroy on it still destroys it, and its children go with it either way */
149
+ this.persistent = false;
150
+
151
+ // add to list of objects
152
+ engineObjects.push(this);
153
+ }
154
+
155
+ /** Update the object transform, called automatically by engine even when paused
156
+ * @param {boolean} [updateChildren] - Also update the children's transforms */
157
+ updateTransforms(updateChildren=true)
158
+ {
159
+ const parent = this.parent;
160
+ if (parent)
161
+ {
162
+ // compose with parent transform inline to avoid intermediate vector allocs
163
+ const mirror = parent.getMirrorSign();
164
+ const lp = this.localPos, pp = parent.pos;
165
+ const lx = lp.x*mirror, ly = lp.y, pa = parent.angle;
166
+ if (pa)
167
+ {
168
+ const c = cos(-pa), s = sin(-pa);
169
+ this.pos.set(lx*c - ly*s + pp.x, lx*s + ly*c + pp.y);
170
+ }
171
+ else
172
+ this.pos.set(lx + pp.x, ly + pp.y);
173
+ this.angle = mirror*this.localAngle + pa;
174
+ }
175
+
176
+ // update children
177
+ if (updateChildren)
178
+ for (const child of this.children)
179
+ child.updateTransforms();
180
+ }
181
+
182
+ /** Update the object physics, called automatically by engine once each frame. Can be overridden to stop or change how physics works for an object. */
183
+ updatePhysics()
184
+ {
185
+ // child objects do not have physics
186
+ ASSERT(!this.parent);
187
+
188
+ // bail if a collision callback destroyed us mid-frame
189
+ if (this.destroyed) return;
190
+
191
+ // limit max speed to prevent missing collisions, only for what collides: with solids, or with tiles while it
192
+ // has a mass, which tile collision needs; anything else moves as fast as it is told
193
+ if (this.clampSpeed && enablePhysicsSolver && (this.collideSolidObjects || this.collideLevel && this.mass))
194
+ {
195
+ this.velocity.x = clamp(this.velocity.x, -objectMaxSpeed, objectMaxSpeed);
196
+ this.velocity.y = clamp(this.velocity.y, -objectMaxSpeed, objectMaxSpeed);
197
+ }
198
+
199
+ // physics sanity checks
200
+ ASSERT(this.angleDamping >= 0 && this.angleDamping <= 1, 'angleDamping must be 0 to 1, the fraction kept each frame');
201
+ ASSERT(this.damping >= 0 && this.damping <= 1, 'damping must be 0 to 1, the fraction of velocity kept each frame');
202
+
203
+ // apply physics; only collision needs where the object was, so only then is it copied
204
+ const solve = enablePhysicsSolver && this.mass;
205
+ const oldPos = solve && (this.collideSolidObjects || this.collideLevel) ? this.pos.copy() : undefined;
206
+ this.velocity.x *= this.damping;
207
+ this.velocity.y *= this.damping;
208
+ if (this.mass)
209
+ {
210
+ // apply gravity only if it has mass
211
+ this.velocity.x += gravity.x * this.gravityScale;
212
+ this.velocity.y += gravity.y * this.gravityScale;
213
+ }
214
+ this.pos.x += this.velocity.x;
215
+ this.pos.y += this.velocity.y;
216
+ this.angle += this.angleVelocity *= this.angleDamping;
217
+
218
+ // don't do collision for static objects or if solver disabled
219
+ if (!solve) return;
220
+
221
+ // which way is down for this object, a negative gravityScale falls up and lands on ceilings
222
+ const gravityY = this.gravityScale < 0 ? -gravity.y : gravity.y;
223
+ const wasFalling = this.velocity.y < 0 && gravityY < 0 || this.velocity.y > 0 && gravityY > 0;
224
+ if (this.groundObject)
225
+ {
226
+ // apply friction in local space of ground object
227
+ const friction = max(this.friction, this.groundObject.friction);
228
+ const groundSpeed = this.groundObject.velocity.x;
229
+ this.velocity.x = groundSpeed + (this.velocity.x - groundSpeed) * friction;
230
+ this.groundObject = undefined;
231
+ }
232
+
233
+ // an object with no width or height has no box to push out of, or to be pushed out of
234
+ if (this.collideSolidObjects && this.size.x && this.size.y)
235
+ {
236
+ // check collisions against solid objects
237
+ const epsilon = .001; // necessary to push slightly outside of the collision
238
+ for (const o of engineObjectsCollideStaticLast)
239
+ {
240
+ // skip destroyed, child objects, self collision, or objects with no box
241
+ if (o.destroyed || o.parent || o === this || !o.size.x || !o.size.y) continue;
242
+
243
+ // non solid objects don't collide with each other
244
+ if (!this.isSolid && !o.isSolid) continue;
245
+
246
+ // check collision
247
+ if (!this.isOverlappingObject(o)) continue;
248
+
249
+ // each moving object checks its own contacts, so a pair the other one already asked about this frame
250
+ // is not asked twice: left overlapping, ignored or only nudged apart it is skipped, and one both said
251
+ // to resolve, pushed back together since, is resolved again
252
+ const answer = engineObjectsCollidePairAnswer(o, this);
253
+ if (answer === false) continue;
254
+ if (!answer)
255
+ {
256
+ // notify objects of collision and check if should be resolved
257
+ const collide1 = this.collideWithObject(o);
258
+ const collide2 = o.collideWithObject(this);
259
+ if (!collide1 || !collide2)
260
+ {
261
+ engineObjectsCollidePairAdd(this, o);
262
+ continue;
263
+ }
264
+ }
265
+
266
+ const wasOverlapping = isOverlapping(oldPos, this.size, o.pos, o.size);
267
+ if (wasOverlapping && (!o.mass || o.groundObject))
268
+ {
269
+ // a static solid that moved into it, like a door or an elevator, pushes it out the shortest way
270
+ // at once and carries it along, it would only drift out slowly and the solid would pass through;
271
+ // an object standing on something counts as fixed too, so a stack on an elevator rides together;
272
+ // it bounces off relative to the mover, a paddle moved by setting pos bounces a ball as a wall does
273
+ const push = collideBoxBox(this.pos, this.size, o.pos, o.size);
274
+ if (push)
275
+ {
276
+ this.pos.x += push.x + sign(push.x) * epsilon;
277
+ this.pos.y += push.y + sign(push.y) * epsilon;
278
+ const restitution = max(this.restitution, o.restitution);
279
+ if (push.x)
280
+ {
281
+ const v = this.velocity.x - o.velocity.x;
282
+ if (v * push.x < 0) // moving into it
283
+ this.velocity.x = o.velocity.x - v * restitution;
284
+ }
285
+ else
286
+ {
287
+ const v = this.velocity.y - o.velocity.y;
288
+ if (v * push.y < 0) // moving into it
289
+ this.velocity.y = o.velocity.y - v * restitution;
290
+ if (push.y * gravityY < 0) // pushed up against gravity, it stands on it
291
+ this.groundObject = o;
292
+ }
293
+ }
294
+ engineObjectsCollidePairAdd(this, o);
295
+
296
+ debugPhysics && debugOverlap(this.pos, this.size, o.pos, o.size, '#f00', undefined, false);
297
+ continue;
298
+ }
299
+ if (wasOverlapping)
300
+ {
301
+ // if already was touching, try to push away
302
+ const deltaPos = oldPos.subtract(o.pos);
303
+ const length = deltaPos.length();
304
+ const pushAwayAccel = .001;
305
+ const velocity = length < .001 ? vec2(0, pushAwayAccel) : deltaPos.scale(pushAwayAccel/length);
306
+ this.velocity = this.velocity.add(velocity);
307
+ if (o.mass) // push away other object if not fixed
308
+ o.velocity = o.velocity.subtract(velocity);
309
+ engineObjectsCollidePairAdd(this, o);
310
+
311
+ debugPhysics && debugOverlap(this.pos, this.size, o.pos, o.size, '#f00', undefined, false);
312
+ continue;
313
+ }
314
+
315
+ // check for collision
316
+ const sizeBoth = this.size.add(o.size);
317
+ // prefer to push up if small delta, up away from this object's own gravity
318
+ const smallStepUp = (gravityY > 0 ? o.pos.y - oldPos.y : oldPos.y - o.pos.y)*2 > sizeBoth.y - abs(gravityY);
319
+ const isBlockedX = abs(oldPos.y - o.pos.y)*2 < sizeBoth.y;
320
+ const isBlockedY = abs(oldPos.x - o.pos.x)*2 < sizeBoth.x;
321
+ const restitution = max(this.restitution, o.restitution);
322
+
323
+ if (smallStepUp || isBlockedY || !isBlockedX) // resolve y collision
324
+ {
325
+ // push outside object collision
326
+ this.pos.y = o.pos.y + (sizeBoth.y/2 + epsilon) * sign(oldPos.y - o.pos.y);
327
+ if ((o.groundObject && wasFalling) || !o.mass)
328
+ {
329
+ // set ground object if landed on something
330
+ if (wasFalling)
331
+ this.groundObject = o;
332
+
333
+ // bounce if other object is fixed or grounded, relative to it so a rider keeps up with a
334
+ // platform moving down instead of landing on it again every few frames
335
+ this.velocity.y = o.velocity.y - (this.velocity.y - o.velocity.y) * restitution;
336
+ }
337
+ else // o has mass here, the other branch already handled the massless case
338
+ {
339
+ // inelastic collision
340
+ const inelastic = (this.mass * this.velocity.y + o.mass * o.velocity.y) / (this.mass + o.mass);
341
+
342
+ // elastic collision
343
+ const elastic0 = this.velocity.y * (this.mass - o.mass) / (this.mass + o.mass)
344
+ + o.velocity.y * 2 * o.mass / (this.mass + o.mass);
345
+ const elastic1 = o.velocity.y * (o.mass - this.mass) / (this.mass + o.mass)
346
+ + this.velocity.y * 2 * this.mass / (this.mass + o.mass);
347
+
348
+ // lerp between elastic or inelastic based on restitution
349
+ this.velocity.y = lerp(inelastic, elastic0, restitution);
350
+ o.velocity.y = lerp(inelastic, elastic1, restitution);
351
+ }
352
+ }
353
+ if (!smallStepUp && isBlockedX) // resolve x collision
354
+ {
355
+ // push outside collision
356
+ this.pos.x = o.pos.x + (sizeBoth.x/2 + epsilon) * sign(oldPos.x - o.pos.x);
357
+ if (o.mass)
358
+ {
359
+ // inelastic collision
360
+ const inelastic = (this.mass * this.velocity.x + o.mass * o.velocity.x) / (this.mass + o.mass);
361
+
362
+ // elastic collision
363
+ const elastic0 = this.velocity.x * (this.mass - o.mass) / (this.mass + o.mass)
364
+ + o.velocity.x * 2 * o.mass / (this.mass + o.mass);
365
+ const elastic1 = o.velocity.x * (o.mass - this.mass) / (this.mass + o.mass)
366
+ + this.velocity.x * 2 * this.mass / (this.mass + o.mass);
367
+
368
+ // lerp between elastic or inelastic based on restitution
369
+ this.velocity.x = lerp(inelastic, elastic0, restitution);
370
+ o.velocity.x = lerp(inelastic, elastic1, restitution);
371
+ }
372
+ else // bounce if other object is fixed, relative to it as a landing is
373
+ this.velocity.x = o.velocity.x - (this.velocity.x - o.velocity.x) * restitution;
374
+ }
375
+ engineObjectsCollidePairAdd(this, o, true);
376
+ debugPhysics && debugOverlap(this.pos, this.size, o.pos, o.size, '#f0f', undefined, false);
377
+ }
378
+ }
379
+ if (this.collideLevel)
380
+ {
381
+ // check collision against tiles
382
+ const hitLayer = tileCollisionTest(this.pos, this.size, this);
383
+ if (hitLayer)
384
+ {
385
+ // if already was stuck in collision, don't do anything
386
+ // this should not happen unless something starts in collision
387
+ if (!tileCollisionTest(oldPos, this.size, this))
388
+ {
389
+ // test which side we bounced off (or both if a corner)
390
+ const isBlockedX = tileCollisionTest(vec2(this.pos.x, oldPos.y), this.size, this);
391
+ const isBlockedY = tileCollisionTest(vec2(oldPos.x, this.pos.y), this.size, this);
392
+ const restitution = max(this.restitution, hitLayer.restitution);
393
+ if (isBlockedX)
394
+ {
395
+ // a ledge caught less than maxMove below its top lifts the object onto it instead of stopping it,
396
+ // down off a ceiling ledge when gravity points up; zero gravity counts as down
397
+ const epsilon = 1e-3;
398
+ const maxMove = .1;
399
+ const y = gravityY > 0 ?
400
+ ceil( oldPos.y+this.size.y/2-1) - this.size.y/2 - epsilon :
401
+ floor(oldPos.y-this.size.y/2+1) + this.size.y/2 + epsilon;
402
+ if (abs(y - this.pos.y) < maxMove && !tileCollisionTest(vec2(this.pos.x, y), this.size, this))
403
+ {
404
+ this.pos.y = y;
405
+ debugPhysics && debugRect(this.pos, this.size, '#ff0', 0, 0, false, false);
406
+ return;
407
+ }
408
+
409
+ // move to previous X position and bounce
410
+ this.pos.x = oldPos.x;
411
+ this.velocity.x *= -restitution;
412
+ }
413
+ if (isBlockedY || !isBlockedX)
414
+ {
415
+ if (wasFalling)
416
+ {
417
+ // adjust position to slightly away from nearest tile
418
+ // this prevents gap between object and ground
419
+ const epsilon = .0001;
420
+ const offset = this.size.y/2 + epsilon;
421
+ // rounded in the layer's space as its collision test is, or a bottom a hair below a
422
+ // grid line would round to the row under it, inside the floor, and fall through
423
+ const layerY = hitLayer.pos.y;
424
+ this.pos.y = layerY + (gravityY < 0 ?
425
+ floor(oldPos.y - layerY - this.size.y/2) + offset :
426
+ ceil( oldPos.y - layerY + this.size.y/2) - offset);
427
+
428
+ // set ground object for tile collision
429
+ this.groundObject = hitLayer;
430
+ }
431
+ else
432
+ {
433
+ // move to previous Y position
434
+ this.pos.y = oldPos.y;
435
+ this.groundObject = undefined;
436
+ }
437
+ // bounce velocity
438
+ this.velocity.y *= -restitution;
439
+ }
440
+ debugPhysics && debugRect(this.pos, this.size, '#f00', 0, 0, false, false);
441
+ }
442
+ }
443
+ }
444
+ }
445
+
446
+ /** Update the object, called automatically by engine once each frame. Does nothing by default. */
447
+ update() {}
448
+
449
+ /** Render the object, draws a tile by default, automatically called each frame, sorted by renderOrder */
450
+ render()
451
+ {
452
+ // default object render
453
+ drawTile(this.pos, this.drawSize || this.size, this.tileInfo, this.color, this.angle, this.mirror, this.additiveColor, glEnable, false);
454
+ }
455
+
456
+ /** Optional hook called during the light system plugin's lightmap pass to draw this object's lightmap contribution. Does nothing by default. */
457
+ renderLight() {}
458
+
459
+ /** Draw this object into the light system's shadow map, called during its shadow pass when castShadow is set.
460
+ * Calls render() by default so the object casts its own shape; override to cast a different one, like a blob at a character's feet so its body stays lit;
461
+ * screen space WebGL draws in render() are skipped during the pass */
462
+ renderShadow() { this.render(); }
463
+
464
+ /** Draw this object's shape into the light system's lightmap, called during its light pass when emissive is above
465
+ * 0; what it draws shows in its own colors that much brighter. Calls render() by default so the whole object
466
+ * glows; override to glow a part, like a robot's eyes */
467
+ renderEmissive() { this.render(); }
468
+
469
+ /** Destroy this object, destroy its children, detach its parent, and mark it for removal
470
+ * @param {boolean} [immediate] - true removes attached effects like particle emitters at once, false lets them finish first */
471
+ destroy(immediate=false)
472
+ {
473
+ if (this.destroyed) return;
474
+
475
+ // disconnect from parent and destroy children
476
+ this.destroyed = true;
477
+ this.parent?.removeChild(this);
478
+ for (const child of this.children)
479
+ {
480
+ child.parent = undefined;
481
+ child.destroy(immediate);
482
+ }
483
+ }
484
+
485
+ /** Convert from local space to world space
486
+ * @param {Vector2} pos - local space point */
487
+ localToWorld(pos) { return this.pos.add(pos.rotate(this.angle)); }
488
+
489
+ /** Convert from world space to local space
490
+ * @param {Vector2} pos - world space point */
491
+ worldToLocal(pos) { return pos.subtract(this.pos).rotate(-this.angle); }
492
+
493
+ /** Convert from local space to world space for a vector (rotation only)
494
+ * @param {Vector2} vec - local space vector */
495
+ localToWorldVector(vec) { return vec.rotate(this.angle); }
496
+
497
+ /** Convert from world space to local space for a vector (rotation only)
498
+ * @param {Vector2} vec - world space vector */
499
+ worldToLocalVector(vec) { return vec.rotate(-this.angle); }
500
+
501
+ /** Called to check if a tile collision should be resolved. Return true for physics to resolve the collision or false to ignore and resolve it manually.
502
+ * - Called for each solid tile the physics tests, which can be several times a frame for the same tile, and for
503
+ * positions it only tries, so keep it free of side effects or guard them to once a frame
504
+ * - this.pos has already moved, so a check on where it came from, like a one way platform, needs the position
505
+ * saved in update, as the platformer example does
506
+ * @param {number} tileData - the value of the tile at the position
507
+ * @param {Vector2} pos - the tile's bottom left corner in world space
508
+ * @return {boolean} - true if the collision should be resolved by modifying it's position and velocity */
509
+ collideWithTile(tileData, pos) { return tileData > 0; }
510
+
511
+ /** Called by the engine to check if an object collision should be resolved. Return true for physics to resolve the collision or false to ignore and resolve it manually.
512
+ * - Both objects of a touching pair are asked once a frame, whichever order they update in; an object that
513
+ * destroys itself here is gone at the end of the frame and is still asked about the pairs left this frame, so a
514
+ * bullet that should hit one thing checks its own destroyed flag first
515
+ * @param {EngineObject} object - the object to test against
516
+ * @param {Vector3} [push] - what it would take to move this object clear, a Vector3 from the 3D plugin, undefined in 2D
517
+ * @return {boolean} - true if the collision should be resolved by modifying it's position and velocity
518
+ */
519
+ collideWithObject(object, push) { return true; }
520
+
521
+ /** Get this object's up vector
522
+ * @param {number} [scale] - length of the vector
523
+ * @return {Vector2} */
524
+ getUp(scale=1) { return vec2().setAngle(this.angle, scale); }
525
+
526
+ /** Get this object's right vector
527
+ * @param {number} [scale] - length of the vector
528
+ * @return {Vector2} */
529
+ getRight(scale=1) { return vec2().setAngle(this.angle+PI/2, scale); }
530
+
531
+ /** How long since the object was created
532
+ * @return {number} */
533
+ getAliveTime() { return time - this.spawnTime; }
534
+
535
+ /** Get the speed of this object
536
+ * @return {number} */
537
+ getSpeed() { return this.velocity.length(); }
538
+
539
+ /** Apply acceleration to this object (adjust velocity, not affected by mass)
540
+ * - Does nothing on a static object (mass 0), set its velocity instead
541
+ * @param {Vector2} acceleration */
542
+ applyAcceleration(acceleration)
543
+ { if (this.mass) this.velocity = this.velocity.add(acceleration); }
544
+
545
+ /** Apply angular acceleration to this object
546
+ * - Does nothing on a static object (mass 0), set its angleVelocity instead
547
+ * @param {number} acceleration */
548
+ applyAngularAcceleration(acceleration)
549
+ { if (this.mass) this.angleVelocity += acceleration; }
550
+
551
+ /** Apply force to this object (adjust velocity, affected by mass)
552
+ * - Does nothing on a static object (mass 0), set its velocity instead
553
+ * @param {Vector2} force */
554
+ applyForce(force)
555
+ { if (this.mass) this.applyAcceleration(force.scale(1/this.mass)); }
556
+
557
+ /** Get the direction of the mirror
558
+ * @return {number} -1 if this.mirror is true, or 1 if not mirrored */
559
+ getMirrorSign() { return this.mirror ? -1 : 1; }
560
+
561
+ /** Attaches a child to this with a local transform, returns child for chaining
562
+ * @param {EngineObject} child
563
+ * @param {Vector2} [localPos=vec2()]
564
+ * @param {number} [localAngle]
565
+ * @return {EngineObject} The child object added */
566
+ addChild(child, localPos=vec2(), localAngle=0)
567
+ {
568
+ ASSERT(!this.destroyed, 'cannot add child to destroyed object');
569
+ if (this.destroyed) return child;
570
+ ASSERT(!child.parent && !this.children.includes(child), 'child already has a parent, removeChild it first or use attach');
571
+ ASSERT(child instanceof EngineObject, 'child must be an EngineObject');
572
+ ASSERT(!child.destroyed, 'cannot add a destroyed child');
573
+ for (let p = /** @type {EngineObject} */ (this); p; p = p.parent)
574
+ ASSERT(p !== child, 'cannot add an object as a child of itself or of its own child');
575
+ this.children.push(child);
576
+ child.parent = this;
577
+ child.localPos = localPos.copy();
578
+ child.localAngle = localAngle;
579
+ child.updateTransforms();
580
+ return child;
581
+ }
582
+
583
+ /** Attaches a child to this without moving it: the local transform is worked out from where the child is now,
584
+ * where addChild takes one; a child of something else is moved over, returns child for chaining
585
+ * @param {EngineObject} child
586
+ * @return {EngineObject} The child object attached */
587
+ attach(child)
588
+ {
589
+ ASSERT(child instanceof EngineObject, 'child must be an EngineObject');
590
+ ASSERT(child !== this, 'cannot attach self');
591
+ child.parent?.removeChild(child);
592
+ // the local values that updateTransforms turns back into the child's current pos and angle
593
+ const mirror = this.getMirrorSign(), local = this.worldToLocal(child.pos);
594
+ local.x *= mirror;
595
+ return this.addChild(child, local, mirror * (child.angle - this.angle));
596
+ }
597
+
598
+ /** Removes a child from this one, it stays where it is in the world
599
+ * @param {EngineObject} child */
600
+ removeChild(child)
601
+ {
602
+ ASSERT(child.parent === this && this.children.includes(child));
603
+ const i = this.children.indexOf(child);
604
+ if (i < 0) return; // not a child of this one, release has no assert
605
+ this.children.splice(i, 1);
606
+ child.parent = child.localPos = undefined;
607
+ }
608
+
609
+ /** Check if this object's box overlaps another object's box
610
+ * @param {EngineObject} object
611
+ * @return {boolean} */
612
+ isOverlappingObject(object)
613
+ { return this.isOverlapping(object.pos, object.size); }
614
+
615
+ /** Check if overlapping a point or aligned bounding box
616
+ * @param {Vector2} pos - Center of box
617
+ * @param {Vector2} [size=vec2()] - Size of box, uses a point if undefined
618
+ * @return {boolean} */
619
+ isOverlapping(pos, size=vec2())
620
+ { return isOverlapping(this.pos, this.size, pos, size); }
621
+
622
+ /** Set how this object collides
623
+ * @param {boolean} [collideSolidObjects] - Does it collide with solid objects?
624
+ * @param {boolean} [isSolid] - Does it collide with and block other objects? (expensive in large numbers)
625
+ * @param {boolean} [collideLevel] - Does it collide with the level, its tile collision layers?
626
+ * @param {boolean} [collideRaycast] - Does it collide with raycasts? */
627
+ setCollision(collideSolidObjects=true, isSolid=true, collideLevel=true, collideRaycast=true)
628
+ {
629
+ ASSERT(collideSolidObjects || !isSolid, 'solid objects must be set to collide');
630
+
631
+ this.collideSolidObjects = collideSolidObjects;
632
+ this.isSolid = isSolid;
633
+ this.collideLevel = collideLevel;
634
+ this.collideRaycast = collideRaycast;
635
+ }
636
+
637
+ /** @deprecated since 1.20, use collideLevel
638
+ * @type {boolean} */
639
+ get collideTiles() { return this.collideLevel; }
640
+ set collideTiles(collide) { this.collideLevel = collide; }
641
+
642
+ /** Returns string containing info about this object for debugging
643
+ * @return {string} */
644
+ toString()
645
+ {
646
+ let text = 'type = ' + this.constructor.name;
647
+ if (this.pos.x || this.pos.y)
648
+ text += '\npos = ' + this.pos;
649
+ if (this.velocity.x || this.velocity.y)
650
+ text += '\nvelocity = ' + this.velocity;
651
+ if (this.size.x || this.size.y)
652
+ text += '\nsize = ' + this.size;
653
+ if (this.angle)
654
+ text += '\nangle = ' + this.angle.toFixed(3);
655
+ text += '\ncolor = ' + this.color;
656
+ return text;
657
+ }
658
+
659
+ /** Render debug info for this object */
660
+ renderDebugInfo()
661
+ {
662
+ if (!debug) return;
663
+
664
+ // check if there is anything to show
665
+ const hasPhysics = this.collideLevel || this.collideSolidObjects || this.isSolid;
666
+ if (!hasPhysics && !this.parent) return;
667
+
668
+ // show object info for debugging
669
+ const size = vec2(max(this.size.x, .2), max(this.size.y, .2));
670
+ const color = rgb(this.collideLevel?1:0, this.collideSolidObjects?1:0, this.isSolid?1:0, .5);
671
+ debugRect(this.pos, size, color, 0, hasPhysics ? 0 : this.angle, hasPhysics, false); // collision ignores the angle
672
+ if (this.parent)
673
+ debugRect(this.pos, size.scale(.8), rgb(1,1,1,.5), 0, this.angle, false, false);
674
+ this.parent && debugLine(this.pos, this.parent.pos, rgb(1,1,1,.5), .5, 0, false);
675
+ }
676
676
  }