kippy 0.4.0 → 0.5.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/README.md CHANGED
@@ -67,6 +67,7 @@ const entity = new Entity({
67
67
  position, // Entity's position (centered), type Vector2
68
68
  rotation, // Entity's rotation in radians, type number
69
69
  body, // Entity's physical body, type EntityBody
70
+ collider, // Entity's collider, type Collider
70
71
  });
71
72
 
72
73
  // Add it to a scene
@@ -84,7 +85,7 @@ entity.rotation; // Initialized from the "rotation" param above, 0 if not specif
84
85
 
85
86
  ### Create a sprite
86
87
 
87
- A sprite represents what an entity looks like, the "graphics part", you can create a sprite like this:
88
+ A sprite represents what an entity looks like (the "graphics part"), and you can create a sprite like this:
88
89
  ```js
89
90
  import { Sprite } from "kippy";
90
91
 
@@ -100,7 +101,7 @@ entity.sprite = sprite;
100
101
 
101
102
  ### Add controls
102
103
 
103
- Game controls like mouse presses, key presses, touch, and cursor traking (in the game canvas, not the web window) can be done by using the input handler from your `game` instance:
104
+ Game controls like mouse presses, key presses, touch, and cursor tracking (in the game canvas, not the web window) can be done by using the input handler from your `game` instance:
104
105
  ```js
105
106
  const input = game.input;
106
107
  ```
@@ -123,7 +124,7 @@ input.pointer.y; // Current Y position of mouse/touch
123
124
 
124
125
  ### Vectors
125
126
 
126
- To work with positions and movements in Kippy, it is best to know about `Vector2` first. Positions, velocities, forces, etc are all represented as vectors in Kippy. And here is how you can create a 2D vector and some vector math utilities that comes along with it:
127
+ To work with positions and movements in Kippy, it is best to know about `Vector2` first. Positions, velocities, forces, etc are all represented as vectors in Kippy. And here are how you can create a 2D vector and some vector math utilities that come along with it:
127
128
  ```js
128
129
  import { Vector2 } from "kippy";
129
130
 
@@ -134,17 +135,31 @@ vect.x; // X coordinate
134
135
  vect.y; // Y coordinate
135
136
 
136
137
  // Utilities
138
+ vect.toString(); // Returns "Vector2(x, y)"
137
139
  vect.add(otherVect); // Add another vector and return the result vector
138
140
  vect.sub(otherVect); // Subtract another vector and return the result vector
141
+ vect.mul(otherVect); // Multiply with another vector and return the result vector
142
+ vect.div(otherVect); // Divide by another vector and return the result vector
143
+ vect.neg(); // Negate and return the result vector
139
144
  vect.scale(scale); // Multiply with scale and return the result vector
140
145
  vect.magnitude(); // Return the magnitude/length of vector
146
+ vect.magnitudeSquared(); // Return the squared magnitude/length of vector
141
147
  vect.normalize(); // Return the normalized vector by magnitude
142
148
  vect.dot(otherVect); // Return dot product with another vector
149
+ vect.cross(otherVect); // Return cross product with another vector
150
+ vect.project(otherVect); // Return projection on another vector
151
+ vect.min(otherVect); // Return a new vector with min coordinates
152
+ vect.max(otherVect); // Return a new vector with max coordinates
153
+ vect.floor(); // Floor rounding
154
+ vect.ceil(); // Ceil rounding
155
+ vect.round(); // Normal rounding
143
156
  vect.distance(otherVect); // Return distance to another vector
157
+ vect.distanceSquared(otherVect); // Return squared distance to another vector
144
158
  vect.copy(); // Return a copy (same coordinates, different reference)
145
159
  vect.lerp(otherVect, scale); // Apply linear interpolation and return
146
160
  vect.clamp(maxLength); // Clamp vector to have length below maxLength
147
161
  vect.rotate(angle); // Return rotated vector by provided angle
162
+ vect.orthogonal(); // Return orthogonal vector of this vector
148
163
  vect.angle(); // Return angle of vector.
149
164
  vect.angleTo(otherVec); // Return angle between this and another vector
150
165
  vect.reflect(otherVect); // Return reflection/bounce back vector
@@ -163,14 +178,17 @@ Vector2.RIGHT; // Vector2(1, 0);
163
178
 
164
179
  For movements, currently you can create a `RigidBody`:
165
180
  ```js
181
+ import { RigidBody } from "kippy";
182
+
166
183
  // Create a rigid body
167
184
  const rigidBody = new RigidBody({
168
185
  velocity, // Entity's velocity vector, type Vector2
169
- rotationVelocity, // Entity's ngular/rotation velocity, type number
186
+ rotationVelocity, // Entity's angular/rotation velocity, type number
170
187
  mass, // Entity's mass, type number
171
188
  inertia, // Entity's inertia, type number
172
189
  force, // Entity's force vector, type Vector2
173
190
  torque, // Entity's torque/rotational force, type number
191
+ restitution // Entity's restitution for collision bounce back, type number
174
192
  });
175
193
 
176
194
  // Attach body to an entity
@@ -184,11 +202,53 @@ entity.body.inertia; // Set with the matching parameter above, default is 1
184
202
  // Note that forces are reset after every frame
185
203
  entity.body.force; // Set with the matching parameter above, default is Vector2(0, 0)
186
204
  entity.body.torque; // Set with the matching parameter above, default is 0
205
+ entity.body.restitution; // Set with the matching parameter above, default is 0
187
206
  ```
188
207
 
189
- Collisions to be added.
208
+ For collisions, you can create a `CircleCollider` for now:
209
+ ```js
210
+ import { CircleCollider } from "kippy";
211
+
212
+ const collider = new CircleCollider({
213
+ radius, // Circle collider's radius, type number
214
+ offset, // Offset from entity's position, type Vector2
215
+ isTrigger, // If true, trigger callbacks are called and collision physics like bouncing
216
+ // will not apply. Otherwise, collision callbacks are called and physics apply
217
+ layer, // A bit mask to determine what collision layer this collider is at
218
+ mask, // A bit mask to check what colliders to collide
219
+ });
220
+
221
+ // Attach collider to an entity
222
+ entity.collider = collider;
223
+
224
+ // You can mutate these props to configure the collider
225
+ collider.radius; // Set with the matching parameter above, required
226
+ collider.offset; // Set with the matching parameter above, default is Vector2(0, 0)
227
+ collider.isTrigger; // Set with the matching parameter above, default is false
228
+ collider.layer; // Set with the matching parameter above, default is (1 << 0)
229
+ collider.mask; // Set with the matching parameter above, default is 0xFFFFFFFF
230
+ ```
190
231
 
191
- ## Camera
232
+ And you can handle when two objects collide:
233
+ ```js
234
+ collider.onCollisionEnter = (other, info) => {};
235
+ collider.onCollisionStay = (other, info) => {};
236
+ collider.onCollisionExit = (other, info) => {};
237
+ collider.onTriggerEnter = (other) => {};
238
+ collider.onTriggerStay = (other) => {};
239
+ collider.onTriggerExit = (other) => {};
240
+ ```
241
+
242
+ `info` has the structure of:
243
+ ```js
244
+ {
245
+ normal, // Vector2
246
+ penetration, // number
247
+ contact, // Vector2
248
+ }
249
+ ```
250
+
251
+ ### Camera
192
252
 
193
253
  The camera decides what part of your game world gets rendered. Note that unlike most camera implementations of which positions are centered, Kippy's camera position is at the top-left of the camera. For example, camera at (0,0) and entity at (0,0) in Godot would show the entity at the center, while the same setup in Kippy would show the entity at the top-left. This is to be more aligned with how web and canvas positioning works.
194
254
 
@@ -214,9 +274,25 @@ To be added, for now mutate `entity.sprite` to swap sprites and create animation
214
274
 
215
275
  To be added, for now use web's built-in `Audio` class.
216
276
 
217
- ### Asset management
277
+ ### Sleep system
218
278
 
219
- To be added.
279
+ When a body's velocity is too low for too long, the body will enter sleep state, which means its position will not be affected by the physics engine until a force is applied or a collision happens, this is to prevent jittering and optimize performance.
280
+
281
+ You can configure it inside `RigidBody`:
282
+ ```js
283
+ const rigidBody = new RigidBody({
284
+ sleepThreshold, // The low threshold velocity to enter sleep state, type number
285
+ sleepTimeThreshold, // The duration of sustained low velocity to enter sleep state, type number
286
+ isSleeping, // Flag to set sleep state, type boolean
287
+ sleepTimer // Current sleep timer, you probably don't need this
288
+ });
289
+
290
+ // You can mutate these to change sleep configuration:
291
+ rigidBody.sleepThreshold; // Set with the param above, default is 0.1
292
+ rigidBody.sleepTimeThreshold; // Set with the param above, default is 0.5
293
+ rigidBody.isSleeping; // Set with the param above, default is false
294
+ rigidBody.sleepTimer; // Set with the param above, default is 0
295
+ ```
220
296
 
221
297
  ## Copyrights and License
222
298
 
package/dist/entity.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { EntityBody } from "./physics.js";
1
+ import { Collider, CollisionInfo, EntityBody } from "./physics.js";
2
2
  import { Sprite } from "./sprite.js";
3
3
  import { Vector2 } from "./vector.js";
4
4
  export interface EntityOptions {
@@ -6,12 +6,20 @@ export interface EntityOptions {
6
6
  position?: Vector2;
7
7
  rotation?: number;
8
8
  body?: EntityBody;
9
+ collider?: Collider;
9
10
  }
10
11
  export declare class Entity {
11
12
  sprite?: Sprite;
12
13
  position: Vector2;
13
14
  rotation: number;
14
15
  body?: EntityBody;
16
+ collider?: Collider;
15
17
  constructor(options?: EntityOptions);
18
+ onCollisionEnter?: (other: Entity, info: CollisionInfo) => void;
19
+ onCollisionStay?: (other: Entity, info: CollisionInfo) => void;
20
+ onCollisionExit?: (other: Entity, info: CollisionInfo) => void;
21
+ onTriggerEnter?: (other: Entity) => void;
22
+ onTriggerStay?: (other: Entity) => void;
23
+ onTriggerExit?: (other: Entity) => void;
16
24
  render(ctx: CanvasRenderingContext2D): void;
17
25
  }
package/dist/entity.js CHANGED
@@ -1,21 +1,32 @@
1
1
  import { Vector2 } from "./vector.js";
2
2
  export class Entity {
3
+ // Basic entity structure
3
4
  sprite;
4
5
  position;
5
6
  rotation;
6
7
  body;
8
+ collider;
7
9
  constructor(options = {}) {
8
10
  this.sprite = options.sprite;
9
11
  this.position = options.position ?? new Vector2(0, 0);
10
12
  this.rotation = options.rotation ?? 0;
11
13
  this.body = options.body;
14
+ this.collider = options.collider;
12
15
  }
16
+ // Event handlers
17
+ onCollisionEnter;
18
+ onCollisionStay;
19
+ onCollisionExit;
20
+ onTriggerEnter;
21
+ onTriggerStay;
22
+ onTriggerExit;
23
+ // Render with sprite
13
24
  render(ctx) {
14
25
  if (this.sprite) {
15
26
  ctx.save();
16
27
  ctx.translate(this.position.x, this.position.y);
17
28
  ctx.rotate(this.rotation);
18
- ctx.drawImage(this.sprite.texture, -this.sprite.width / 2, -this.sprite.height / 2);
29
+ ctx.drawImage(this.sprite.texture, -this.sprite.width / 2, -this.sprite.height / 2, this.sprite.width, this.sprite.height);
19
30
  ctx.restore();
20
31
  }
21
32
  }
package/dist/game.d.ts CHANGED
@@ -13,6 +13,7 @@ export declare class Game {
13
13
  lastTime: number;
14
14
  input: Input;
15
15
  physics: Physics;
16
+ paused: boolean;
16
17
  constructor(options: GameOptions);
17
18
  setCanvas(canvas: HTMLCanvasElement): void;
18
19
  setScene(scene: Scene): void;
package/dist/game.js CHANGED
@@ -7,6 +7,7 @@ export class Game {
7
7
  lastTime = 0;
8
8
  input;
9
9
  physics;
10
+ paused = false;
10
11
  constructor(options) {
11
12
  this.canvas = options.canvas;
12
13
  this.ctx = this.canvas.getContext("2d");
@@ -24,10 +25,21 @@ export class Game {
24
25
  this.scene.init();
25
26
  }
26
27
  start() {
28
+ window.addEventListener("blur", () => {
29
+ this.paused = true;
30
+ });
31
+ window.addEventListener("focus", () => {
32
+ this.paused = false;
33
+ this.lastTime = performance.now(); // Reset!
34
+ });
27
35
  requestAnimationFrame(this.loop.bind(this));
28
36
  }
29
37
  // Game loop
30
38
  loop(timestamp) {
39
+ if (this.paused) {
40
+ requestAnimationFrame(this.loop.bind(this));
41
+ return;
42
+ }
31
43
  const dt = (timestamp - this.lastTime) / 1000;
32
44
  this.lastTime = timestamp;
33
45
  if (this.scene) {
@@ -36,7 +48,7 @@ export class Game {
36
48
  // Update game logic
37
49
  this.scene.update(dt);
38
50
  // Update physics info
39
- this.physics.update(this.scene.entities);
51
+ this.physics.update(this.scene.entities, dt);
40
52
  // Render
41
53
  this.ctx.clearRect(0, 0, this.ctx.canvas.width, this.ctx.canvas.height);
42
54
  this.scene.render();
package/dist/physics.d.ts CHANGED
@@ -7,6 +7,11 @@ export interface RigidBodyOptions {
7
7
  inertia?: number;
8
8
  force?: Vector2;
9
9
  torque?: number;
10
+ restitution?: number;
11
+ sleepThreshold?: number;
12
+ sleepTimeThreshold?: number;
13
+ isSleeping?: boolean;
14
+ sleepTimer?: number;
10
15
  }
11
16
  export declare class RigidBody {
12
17
  velocity: Vector2;
@@ -15,9 +20,65 @@ export declare class RigidBody {
15
20
  inertia: number;
16
21
  force: Vector2;
17
22
  torque: number;
23
+ restitution: number;
24
+ sleepThreshold: number;
25
+ sleepTimeThreshold: number;
26
+ isSleeping: boolean;
27
+ sleepTimer: number;
18
28
  constructor(options?: RigidBodyOptions);
29
+ wake(): void;
19
30
  }
20
31
  export type EntityBody = RigidBody;
32
+ export interface CircleColliderOptions {
33
+ radius: number;
34
+ offset?: Vector2;
35
+ isTrigger?: boolean;
36
+ layer?: number;
37
+ mask?: number;
38
+ }
39
+ export declare class CircleCollider {
40
+ radius: number;
41
+ offset: Vector2;
42
+ isTrigger: boolean;
43
+ layer: number;
44
+ mask: number;
45
+ constructor(options: CircleColliderOptions);
46
+ }
47
+ export type Collider = CircleCollider;
48
+ export interface SpatialGridOptions {
49
+ cellSize?: number;
50
+ grid?: Map<string, Set<Entity>>;
51
+ }
52
+ export declare class SpatialGrid {
53
+ cellSize: number;
54
+ grid: Map<string, Set<Entity>>;
55
+ constructor(options?: SpatialGridOptions);
56
+ clear(): void;
57
+ adaptCellSize(entities: Entity[]): void;
58
+ insert(entity: Entity): void;
59
+ getNearby(entity: Entity): Entity[];
60
+ getEntityBounds(entity: Entity): {
61
+ minX: number;
62
+ maxX: number;
63
+ minY: number;
64
+ maxY: number;
65
+ };
66
+ }
67
+ export interface CollisionInfo {
68
+ normal: Vector2;
69
+ penetration: number;
70
+ contact: Vector2;
71
+ accumulatedNormalImpulse: number;
72
+ }
73
+ export interface CollisionResult {
74
+ isTrigger: boolean;
75
+ info?: CollisionInfo;
76
+ }
21
77
  export declare class Physics {
22
- update(entities: Entity[]): void;
78
+ collisionPairs: Map<Entity, Map<Entity, CollisionInfo>>;
79
+ spatialGrid: SpatialGrid;
80
+ entityCount: number;
81
+ update(entities: Entity[], dt: number): void;
82
+ checkCollision(entityA: Entity, entityB: Entity): CollisionResult;
83
+ resolveCollision(entityA: Entity, entityB: Entity, info: CollisionInfo, dt: number): void;
23
84
  }
package/dist/physics.js CHANGED
@@ -6,6 +6,11 @@ export class RigidBody {
6
6
  inertia;
7
7
  force;
8
8
  torque;
9
+ restitution;
10
+ sleepThreshold;
11
+ sleepTimeThreshold;
12
+ isSleeping;
13
+ sleepTimer;
9
14
  constructor(options = {}) {
10
15
  this.velocity = options.velocity ?? new Vector2(0, 0);
11
16
  this.rotationVelocity = options.rotationVelocity ?? 0;
@@ -13,25 +18,377 @@ export class RigidBody {
13
18
  this.inertia = options.inertia ?? 1;
14
19
  this.force = options.force ?? new Vector2(0, 0);
15
20
  this.torque = options.torque ?? 0;
21
+ this.restitution = options.restitution ?? 0;
22
+ this.sleepThreshold = options.sleepThreshold ?? 0.1;
23
+ this.sleepTimeThreshold = options.sleepTimeThreshold ?? 0.5;
24
+ this.isSleeping = options.isSleeping ?? false;
25
+ this.sleepTimer = options.sleepTimer ?? 0;
26
+ }
27
+ wake() {
28
+ this.isSleeping = false;
29
+ this.sleepTimer = 0;
30
+ }
31
+ }
32
+ export class CircleCollider {
33
+ radius;
34
+ offset;
35
+ isTrigger;
36
+ layer;
37
+ mask;
38
+ constructor(options) {
39
+ this.radius = options.radius;
40
+ this.offset = options.offset ?? new Vector2(0, 0);
41
+ this.isTrigger = options.isTrigger ?? false;
42
+ this.layer = options.layer ?? (1 << 0);
43
+ this.mask = options.mask ?? 0xFFFFFFFF;
16
44
  }
17
45
  }
46
+ export class SpatialGrid {
47
+ cellSize;
48
+ grid;
49
+ constructor(options = {}) {
50
+ this.cellSize = options.cellSize || 100;
51
+ this.grid = options.grid || new Map();
52
+ }
53
+ clear() {
54
+ this.grid.clear();
55
+ }
56
+ // Auto update cell size
57
+ adaptCellSize(entities) {
58
+ // Sample entity sizes
59
+ const sizes = [];
60
+ for (const entity of entities) {
61
+ const bounds = this.getEntityBounds(entity);
62
+ const width = bounds.maxX - bounds.minX;
63
+ const height = bounds.maxY - bounds.minY;
64
+ const maxDimension = Math.max(width, height);
65
+ sizes.push(maxDimension);
66
+ }
67
+ // Use median or 75th percentile (ignore outliers)
68
+ sizes.sort((a, b) => a - b);
69
+ const percentile75 = sizes[Math.floor(sizes.length * 0.75)];
70
+ // Multiply by 2-3x (common heuristic)
71
+ this.cellSize = percentile75 * 2.5;
72
+ }
73
+ // Insert entity into grid
74
+ insert(entity) {
75
+ if (entity.collider) {
76
+ // Get bounds of entity ('s collider)
77
+ const bounds = this.getEntityBounds(entity);
78
+ // Insert into all cells it overlaps
79
+ const minCellX = Math.floor(bounds.minX / this.cellSize);
80
+ const maxCellX = Math.floor(bounds.maxX / this.cellSize);
81
+ const minCellY = Math.floor(bounds.minY / this.cellSize);
82
+ const maxCellY = Math.floor(bounds.maxY / this.cellSize);
83
+ for (let cx = minCellX; cx <= maxCellX; cx++) {
84
+ for (let cy = minCellY; cy <= maxCellY; cy++) {
85
+ const key = `${cx},${cy}`;
86
+ if (!this.grid.has(key)) {
87
+ this.grid.set(key, new Set());
88
+ }
89
+ this.grid.get(key).add(entity);
90
+ }
91
+ }
92
+ }
93
+ }
94
+ // Get nearby entities (checks 3x3 grid around entity)
95
+ getNearby(entity) {
96
+ const centerX = Math.floor(entity.position.x / this.cellSize);
97
+ const centerY = Math.floor(entity.position.y / this.cellSize);
98
+ const nearby = new Set();
99
+ // Check 3x3 grid of cells
100
+ for (let dx = -1; dx <= 1; dx++) {
101
+ for (let dy = -1; dy <= 1; dy++) {
102
+ const key = `${centerX + dx},${centerY + dy}`;
103
+ const cell = this.grid.get(key);
104
+ if (cell) {
105
+ for (const e of cell) {
106
+ if (e !== entity) {
107
+ nearby.add(e);
108
+ }
109
+ }
110
+ }
111
+ }
112
+ }
113
+ return Array.from(nearby);
114
+ }
115
+ // Helper to get entity bounds
116
+ getEntityBounds(entity) {
117
+ if (entity.collider instanceof CircleCollider) {
118
+ const radius = entity.collider.radius;
119
+ return {
120
+ minX: entity.position.x - radius,
121
+ maxX: entity.position.x + radius,
122
+ minY: entity.position.y - radius,
123
+ maxY: entity.position.y + radius
124
+ };
125
+ }
126
+ else {
127
+ throw new Error("Collider type not supported");
128
+ }
129
+ }
130
+ }
131
+ // Physics engine
18
132
  export class Physics {
19
- update(entities) {
133
+ collisionPairs = new Map(); // To store past collisions
134
+ spatialGrid = new SpatialGrid();
135
+ entityCount = 0;
136
+ update(entities, dt) {
137
+ // Update velocity/apply force
20
138
  for (const entity of entities) {
21
139
  if (entity.body instanceof RigidBody) {
140
+ // Wake if force applied
141
+ if (entity.body.force.magnitudeSquared() > 0 || entity.body.torque !== 0) {
142
+ entity.body.wake();
143
+ }
144
+ // Skip sleeping bodies with no forces
145
+ if (entity.body.isSleeping)
146
+ continue;
22
147
  // Acceleration/apply force
23
- entity.body.velocity.x += entity.body.force.x / entity.body.mass;
24
- entity.body.velocity.y += entity.body.force.y / entity.body.mass;
25
- entity.body.rotationVelocity += entity.body.torque / entity.body.inertia;
26
- // Positional update
27
- entity.position.x += entity.body.velocity.x;
28
- entity.position.y += entity.body.velocity.y;
29
- entity.rotation += entity.body.rotationVelocity;
148
+ entity.body.velocity.x += entity.body.force.x / entity.body.mass * dt;
149
+ entity.body.velocity.y += entity.body.force.y / entity.body.mass * dt;
150
+ entity.body.rotationVelocity += entity.body.torque / entity.body.inertia * dt;
30
151
  // Clear force
31
152
  entity.body.force.x = 0;
32
153
  entity.body.force.y = 0;
33
154
  entity.body.torque = 0;
34
155
  }
35
156
  }
157
+ // Rebuild spatial grid for collision handling
158
+ this.spatialGrid.clear();
159
+ if (this.entityCount !== entities.length) {
160
+ this.entityCount = entities.length;
161
+ this.spatialGrid.adaptCellSize(entities);
162
+ }
163
+ for (const entity of entities) {
164
+ this.spatialGrid.insert(entity);
165
+ }
166
+ // Handle collisions - PHASE 1: Detect and collect all contacts
167
+ const currentCollisions = new Map(); // To update this.collisionPairs and check duplicates
168
+ const contacts = [];
169
+ for (const entity of entities) {
170
+ if (entity.collider) {
171
+ const nearby = this.spatialGrid.getNearby(entity);
172
+ for (const other of nearby) {
173
+ if (other.collider) {
174
+ // Check duplicate
175
+ if ((currentCollisions.has(entity) && currentCollisions.get(entity)?.has(other)) ||
176
+ (currentCollisions.has(other) && currentCollisions.get(other)?.has(entity))) {
177
+ continue;
178
+ }
179
+ // Check collision
180
+ const collisionResult = this.checkCollision(entity, other);
181
+ if (collisionResult.info) {
182
+ // Track collision
183
+ if (!currentCollisions.has(entity)) {
184
+ currentCollisions.set(entity, new Map());
185
+ }
186
+ currentCollisions.get(entity)?.set(other, collisionResult.info);
187
+ // Check if this is a new collision
188
+ const wasColliding = (this.collisionPairs.get(entity)?.has(other) ||
189
+ this.collisionPairs.get(other)?.has(entity));
190
+ if (!wasColliding) {
191
+ // ENTER
192
+ entity.body?.wake();
193
+ other.body?.wake();
194
+ if (collisionResult.isTrigger) {
195
+ entity.onTriggerEnter?.(other);
196
+ other.onTriggerEnter?.(entity);
197
+ }
198
+ else {
199
+ entity.onCollisionEnter?.(other, collisionResult.info);
200
+ other.onCollisionEnter?.(entity, {
201
+ ...collisionResult.info,
202
+ normal: new Vector2(-collisionResult.info.normal.x, -collisionResult.info.normal.y)
203
+ });
204
+ }
205
+ }
206
+ else {
207
+ // STAY
208
+ if (collisionResult.isTrigger) {
209
+ entity.onTriggerStay?.(other);
210
+ other.onTriggerStay?.(entity);
211
+ }
212
+ else {
213
+ entity.onCollisionStay?.(other, collisionResult.info);
214
+ other.onCollisionStay?.(entity, {
215
+ ...collisionResult.info,
216
+ normal: new Vector2(-collisionResult.info.normal.x, -collisionResult.info.normal.y)
217
+ });
218
+ }
219
+ }
220
+ // Collect contact for solving
221
+ if (!collisionResult.isTrigger) {
222
+ contacts.push({
223
+ entityA: entity,
224
+ entityB: other,
225
+ info: collisionResult.info
226
+ });
227
+ }
228
+ }
229
+ }
230
+ }
231
+ }
232
+ }
233
+ // PHASE 2: Iteratively solve all contacts together
234
+ for (let iteration = 0; iteration < 6; iteration++) {
235
+ for (const contact of contacts) {
236
+ this.resolveCollision(contact.entityA, contact.entityB, contact.info, dt);
237
+ }
238
+ }
239
+ // EXIT
240
+ for (const [entity, others] of this.collisionPairs) {
241
+ for (const [other, lastInfo] of others) {
242
+ // Check if still colliding
243
+ const stillColliding = (currentCollisions.get(entity)?.has(other) ||
244
+ currentCollisions.get(other)?.has(entity));
245
+ if (!stillColliding) {
246
+ // Determine if was a trigger
247
+ const wasTrigger = entity.collider?.isTrigger || other.collider?.isTrigger;
248
+ if (wasTrigger) {
249
+ entity.onTriggerExit?.(other);
250
+ other.onTriggerExit?.(entity);
251
+ }
252
+ else {
253
+ entity.onCollisionExit?.(other, lastInfo);
254
+ other.onCollisionExit?.(entity, {
255
+ ...lastInfo,
256
+ normal: new Vector2(-lastInfo.normal.x, -lastInfo.normal.y)
257
+ });
258
+ }
259
+ }
260
+ }
261
+ }
262
+ // Update tracked collisions
263
+ this.collisionPairs = currentCollisions;
264
+ // Update position
265
+ for (const entity of entities) {
266
+ if (entity.body instanceof RigidBody) {
267
+ // Skip sleeping bodies
268
+ if (entity.body.isSleeping)
269
+ continue;
270
+ // Positional update
271
+ entity.position.x += entity.body.velocity.x * dt;
272
+ entity.position.y += entity.body.velocity.y * dt;
273
+ entity.rotation += entity.body.rotationVelocity * dt;
274
+ // Sleep accumulation
275
+ const speed = entity.body.velocity.magnitude();
276
+ const angularSpeed = Math.abs(entity.body.rotationVelocity);
277
+ if (speed < entity.body.sleepThreshold && angularSpeed < entity.body.sleepThreshold) {
278
+ entity.body.sleepTimer += dt;
279
+ if (entity.body.sleepTimer >= entity.body.sleepTimeThreshold) {
280
+ // Go to sleep
281
+ entity.body.isSleeping = true;
282
+ entity.body.velocity.x = 0;
283
+ entity.body.velocity.y = 0;
284
+ entity.body.rotationVelocity = 0;
285
+ }
286
+ }
287
+ else {
288
+ // Reset timer if moving too fast
289
+ entity.body.sleepTimer = 0;
290
+ }
291
+ }
292
+ }
293
+ }
294
+ checkCollision(entityA, entityB) {
295
+ if (entityA.collider && entityB.collider) {
296
+ // Layer/mask filtering
297
+ if ((entityA.collider.mask & entityB.collider.layer) === 0 ||
298
+ (entityB.collider.mask & entityA.collider.layer) === 0) {
299
+ return {
300
+ isTrigger: false
301
+ };
302
+ }
303
+ // Get trigger info
304
+ const isTrigger = entityA.collider.isTrigger || entityB.collider.isTrigger;
305
+ // Check collision
306
+ const posA = entityA.position.add(entityA.collider.offset);
307
+ const posB = entityB.position.add(entityB.collider.offset);
308
+ // Check different types of colliders, only circle collider for now
309
+ if (entityA.collider instanceof CircleCollider && entityB.collider instanceof CircleCollider) {
310
+ const distance = posA.distance(posB);
311
+ const radiusSum = entityA.collider.radius + entityB.collider.radius;
312
+ if (distance >= radiusSum) {
313
+ return {
314
+ isTrigger: false
315
+ };
316
+ }
317
+ const penetration = radiusSum - distance;
318
+ const direction = posB.sub(posA);
319
+ let normal = distance > 0 ? direction.scale(1 / distance) : new Vector2(1, 0);
320
+ // Warm starting - copy accumulated impulse from last frame if contact persists
321
+ let accumulatedImpulse = 0;
322
+ const lastContactInfo = this.collisionPairs.get(entityA)?.get(entityB) ||
323
+ this.collisionPairs.get(entityB)?.get(entityA);
324
+ if (lastContactInfo) {
325
+ accumulatedImpulse = lastContactInfo.accumulatedNormalImpulse;
326
+ }
327
+ return {
328
+ isTrigger,
329
+ info: {
330
+ normal,
331
+ penetration,
332
+ contact: posA.add(normal.scale(entityA.collider.radius)),
333
+ accumulatedNormalImpulse: accumulatedImpulse
334
+ }
335
+ };
336
+ }
337
+ return {
338
+ isTrigger: false
339
+ };
340
+ }
341
+ return {
342
+ isTrigger: false
343
+ };
344
+ }
345
+ resolveCollision(entityA, entityB, info, dt) {
346
+ if (!entityA.body || !entityB.body)
347
+ return;
348
+ const bodyA = entityA.body;
349
+ const bodyB = entityB.body;
350
+ // Check for infinite mass
351
+ const invMassA = isFinite(bodyA.mass) ? 1 / bodyA.mass : 0;
352
+ const invMassB = isFinite(bodyB.mass) ? 1 / bodyB.mass : 0;
353
+ const totalInvMass = invMassA + invMassB;
354
+ // If both infinite mass, do nothing
355
+ if (totalInvMass === 0)
356
+ return;
357
+ // Relative velocity
358
+ const relVel = bodyB.velocity.sub(bodyA.velocity);
359
+ // Velocity along normal
360
+ const velAlongNormal = relVel.dot(info.normal);
361
+ // Don't resolve if velocities are separating
362
+ if (velAlongNormal > 0)
363
+ return;
364
+ // Restitution (bounciness) with slop
365
+ const restitutionSlop = 0.5; // Kill bounce below 0.5 unit/sec
366
+ let restitution = Math.max(bodyA.restitution, bodyB.restitution);
367
+ // No bounce for slow collisions (helps objects settle)
368
+ if (Math.abs(velAlongNormal) < restitutionSlop) {
369
+ restitution = 0;
370
+ }
371
+ // Position correction with slop (Box2D/PhysX style)
372
+ const slop = 0.01; // Allow small penetration without correction
373
+ const baumgarte = 0.2; // Correct 20% of penetration per frame
374
+ // Bias velocity - only apply when no bounce (restitution = 0)
375
+ // When bouncing, let restitution handle it naturally
376
+ const biasVelocity = restitution === 0 ? (Math.max(0, info.penetration - slop) * baumgarte) / dt : 0;
377
+ // Calculate impulse using inverse mass
378
+ const jn = (-(1 + restitution) * velAlongNormal + biasVelocity) / totalInvMass;
379
+ // Clamp accumulated impulse
380
+ const oldImpulse = info.accumulatedNormalImpulse || 0;
381
+ info.accumulatedNormalImpulse = Math.max(0, oldImpulse + jn);
382
+ const actualImpulse = info.accumulatedNormalImpulse - oldImpulse;
383
+ // Apply actual impulse
384
+ bodyA.velocity = bodyA.velocity.sub(info.normal.scale(actualImpulse * invMassA));
385
+ bodyB.velocity = bodyB.velocity.add(info.normal.scale(actualImpulse * invMassB));
386
+ // Apply angular impulse (torque from contact point)
387
+ const rA = info.contact.sub(entityA.position);
388
+ const rB = info.contact.sub(entityB.position);
389
+ const angularImpulseA = rA.cross(info.normal.scale(-actualImpulse));
390
+ const angularImpulseB = rB.cross(info.normal.scale(actualImpulse));
391
+ bodyA.rotationVelocity += angularImpulseA * (isFinite(bodyA.inertia) ? 1 / bodyA.inertia : 0);
392
+ bodyB.rotationVelocity += angularImpulseB * (isFinite(bodyB.inertia) ? 1 / bodyB.inertia : 0);
36
393
  }
37
394
  }
package/dist/vector.d.ts CHANGED
@@ -8,17 +8,31 @@ export declare class Vector2 {
8
8
  static LEFT: Vector2;
9
9
  static RIGHT: Vector2;
10
10
  constructor(x: number, y: number);
11
+ toString(): string;
11
12
  add(other: Vector2): Vector2;
12
13
  sub(other: Vector2): Vector2;
14
+ mul(other: Vector2): Vector2;
15
+ div(other: Vector2): Vector2;
16
+ neg(): Vector2;
13
17
  scale(scale: number): Vector2;
14
18
  magnitude(): number;
19
+ magnitudeSquared(): number;
15
20
  normalize(): Vector2;
16
21
  dot(other: Vector2): number;
22
+ cross(other: Vector2): number;
23
+ project(other: Vector2): Vector2;
24
+ min(other: Vector2): Vector2;
25
+ max(other: Vector2): Vector2;
26
+ floor(): Vector2;
27
+ ceil(): Vector2;
28
+ round(): Vector2;
17
29
  distance(other: Vector2): number;
30
+ distanceSquared(other: Vector2): number;
18
31
  copy(): Vector2;
19
32
  lerp(other: Vector2, scale: number): Vector2;
20
33
  clamp(maxLength: number): Vector2;
21
34
  rotate(angle: number): Vector2;
35
+ orthogonal(): Vector2;
22
36
  angle(): number;
23
37
  angleTo(other: Vector2): number;
24
38
  reflect(normal: Vector2): Vector2;
package/dist/vector.js CHANGED
@@ -11,18 +11,33 @@ export class Vector2 {
11
11
  this.x = x;
12
12
  this.y = y;
13
13
  }
14
+ toString() {
15
+ return `Vector2(${this.x}, ${this.y})`;
16
+ }
14
17
  add(other) {
15
18
  return new Vector2(this.x + other.x, this.y + other.y);
16
19
  }
17
20
  sub(other) {
18
21
  return new Vector2(this.x - other.x, this.y - other.y);
19
22
  }
23
+ mul(other) {
24
+ return new Vector2(this.x * other.x, this.y * other.y);
25
+ }
26
+ div(other) {
27
+ return new Vector2(this.x / other.x, this.y / other.y);
28
+ }
29
+ neg() {
30
+ return new Vector2(-this.x, -this.y);
31
+ }
20
32
  scale(scale) {
21
33
  return new Vector2(this.x * scale, this.y * scale);
22
34
  }
23
35
  magnitude() {
24
36
  return Math.sqrt(this.x * this.x + this.y * this.y);
25
37
  }
38
+ magnitudeSquared() {
39
+ return this.x * this.x + this.y * this.y;
40
+ }
26
41
  normalize() {
27
42
  const mag = this.magnitude();
28
43
  return mag > 0 ? new Vector2(this.x / mag, this.y / mag) : new Vector2(0, 0);
@@ -30,9 +45,34 @@ export class Vector2 {
30
45
  dot(other) {
31
46
  return this.x * other.x + this.y * other.y;
32
47
  }
48
+ cross(other) {
49
+ return this.x * other.y - this.y * other.x;
50
+ }
51
+ project(other) {
52
+ const scalar = this.dot(other) / other.magnitudeSquared();
53
+ return other.scale(scalar);
54
+ }
55
+ min(other) {
56
+ return new Vector2(Math.min(this.x, other.x), Math.min(this.y, other.y));
57
+ }
58
+ max(other) {
59
+ return new Vector2(Math.max(this.x, other.x), Math.max(this.y, other.y));
60
+ }
61
+ floor() {
62
+ return new Vector2(Math.floor(this.x), Math.floor(this.y));
63
+ }
64
+ ceil() {
65
+ return new Vector2(Math.ceil(this.x), Math.ceil(this.y));
66
+ }
67
+ round() {
68
+ return new Vector2(Math.round(this.x), Math.round(this.y));
69
+ }
33
70
  distance(other) {
34
71
  return Math.sqrt((this.x - other.x) ** 2 + (this.y - other.y) ** 2);
35
72
  }
73
+ distanceSquared(other) {
74
+ return (this.x - other.x) ** 2 + (this.y - other.y) ** 2;
75
+ }
36
76
  copy() {
37
77
  return new Vector2(this.x, this.y);
38
78
  }
@@ -48,6 +88,9 @@ export class Vector2 {
48
88
  const sin = Math.sin(angle);
49
89
  return new Vector2(this.x * cos - this.y * sin, this.x * sin + this.y * cos);
50
90
  }
91
+ orthogonal() {
92
+ return new Vector2(-this.y, this.x);
93
+ }
51
94
  angle() {
52
95
  return Math.atan2(this.y, this.x);
53
96
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kippy",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Kippy 2D web game engine for JS",
5
5
  "keywords": [
6
6
  "kippy",