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 +84 -8
- package/dist/entity.d.ts +9 -1
- package/dist/entity.js +12 -1
- package/dist/game.d.ts +1 -0
- package/dist/game.js +13 -1
- package/dist/physics.d.ts +62 -1
- package/dist/physics.js +365 -8
- package/dist/vector.d.ts +14 -0
- package/dist/vector.js +43 -0
- package/package.json +1 -1
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
###
|
|
277
|
+
### Sleep system
|
|
218
278
|
|
|
219
|
-
|
|
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
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
|
-
|
|
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
|
-
|
|
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
|
}
|