├── .gitattributes
├── examples
├── index.html
├── declarations.d.ts
├── tsconfig.json
├── package.json
├── pbd.ts
├── index.ts
├── aabb-overlap.ts
├── edge-collision.ts
├── circle-collisions.ts
├── platformer.ts
├── aabb-soup.ts
├── edge-collision-aabb.ts
├── circle-box-collision.ts
├── simplified.ts
└── bucket.ts
├── jest.config.js
├── tsconfig.json
├── src
├── tsconfig.json
├── tsconfig.build-cjs.json
├── solve-drag.ts
├── overlap-circle-circle.ts
├── inertia.ts
├── common-types.ts
├── accelerate.ts
├── project-capsule.ts
├── segment-intersection.ts
├── index.ts
├── solve-gravitation.ts
├── v2.test.ts
├── rewind-to-collision-point.ts
├── overlap-aabb-aabb.ts
├── solve-distance-constraint.ts
├── project-point-edge.ts
├── collide-circle-circle.ts
├── project-point-edge.test.ts
├── v2.ts
├── collision-response-aabb.ts
├── collide-circle-edge.ts
└── index.test.ts
├── .gitignore
├── .yarnrc.yml
├── tsconfig.common.json
├── README.md
└── package.json
/.gitattributes:
--------------------------------------------------------------------------------
1 | /.yarn/releases/** binary
2 | /.yarn/plugins/** binary
--------------------------------------------------------------------------------
/examples/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
pocket-physics demos
4 |
5 |
6 |
7 |
--------------------------------------------------------------------------------
/examples/declarations.d.ts:
--------------------------------------------------------------------------------
1 | declare module 'science-halt' {
2 | function scienceHalt (onhalt: () => void, opt_msg?: string, opt_keycode?: number): void;
3 | export = scienceHalt;
4 | }
--------------------------------------------------------------------------------
/jest.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | preset: 'ts-jest',
3 | roots: ['src'],
4 | globals: {
5 | 'ts-jest': {
6 | diagnostics: false
7 | }
8 | }
9 | };
10 |
11 |
--------------------------------------------------------------------------------
/examples/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../tsconfig.common.json",
3 | "include": ["./*.ts"],
4 | "references": [{ "path": "../src/tsconfig.json" }],
5 | "compilerOptions": { "noEmit": true }
6 | }
7 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "files": [],
3 | "references": [
4 | { "path": "./src/tsconfig.json" },
5 | { "path": "./src/tsconfig.build-cjs.json" },
6 | { "path": "./examples/tsconfig.json" }
7 | ]
8 | }
--------------------------------------------------------------------------------
/src/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../tsconfig.common.json",
3 | "compilerOptions": {
4 | "outDir": "../esm",
5 | "module": "es2015",
6 | "noEmit": false
7 | },
8 | "include": ["./*.ts"]
9 | }
10 |
--------------------------------------------------------------------------------
/src/tsconfig.build-cjs.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../tsconfig.common.json",
3 | "compilerOptions": {
4 | "rootDir": ".",
5 | "outDir": "../cjs",
6 | "module": "commonjs",
7 | "noEmit": false
8 | }
9 | }
10 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | cjs
3 | esm
4 |
5 | # parcel
6 | .parcel-cache
7 | build-demos
8 |
9 | # tsc
10 | tsconfig.tsbuildinfo
11 |
12 | # yarn
13 | .yarn/*
14 | !.yarn/patches
15 | !.yarn/releases
16 | !.yarn/plugins
17 | !.yarn/sdks
18 | !.yarn/versions
19 | .pnp.*
--------------------------------------------------------------------------------
/src/solve-drag.ts:
--------------------------------------------------------------------------------
1 | import { VelocityDerivable } from "./common-types";
2 |
3 | export const solveDrag = (p1: VelocityDerivable, drag: number) => {
4 | const x = (p1.ppos.x - p1.cpos.x) * drag;
5 | const y = (p1.ppos.y - p1.cpos.y) * drag;
6 | p1.ppos.x = p1.cpos.x + x;
7 | p1.ppos.y = p1.cpos.y + y;
8 | };
--------------------------------------------------------------------------------
/.yarnrc.yml:
--------------------------------------------------------------------------------
1 | nodeLinker: node-modules
2 |
3 | plugins:
4 | - path: .yarn/plugins/@yarnpkg/plugin-interactive-tools.cjs
5 | spec: "@yarnpkg/plugin-interactive-tools"
6 | - path: .yarn/plugins/@yarnpkg/plugin-version.cjs
7 | spec: "@yarnpkg/plugin-version"
8 |
9 | yarnPath: .yarn/releases/yarn-berry.cjs
10 |
--------------------------------------------------------------------------------
/src/overlap-circle-circle.ts:
--------------------------------------------------------------------------------
1 | export const overlapCircleCircle = (
2 | ax: number,
3 | ay: number,
4 | arad: number,
5 | bx: number,
6 | by: number,
7 | brad: number
8 | ) => {
9 | const x = bx - ax;
10 | const y = by - ay;
11 | const rad = arad + brad;
12 | return x * x + y * y < rad * rad;
13 | };
14 |
--------------------------------------------------------------------------------
/src/inertia.ts:
--------------------------------------------------------------------------------
1 | import { set } from "./v2";
2 | import { Integratable } from "./common-types";
3 |
4 | export const inertia = (cmp: Integratable) => {
5 | const x = cmp.cpos.x * 2 - cmp.ppos.x;
6 | const y = cmp.cpos.y * 2 - cmp.ppos.y;
7 |
8 | set(cmp.ppos, cmp.cpos.x, cmp.cpos.y);
9 | set(cmp.cpos, x, y);
10 | };
11 |
--------------------------------------------------------------------------------
/src/common-types.ts:
--------------------------------------------------------------------------------
1 | import { Vector2 } from "./v2";
2 |
3 | export type Integratable = {
4 | cpos: Vector2;
5 | ppos: Vector2;
6 | acel: Vector2;
7 | };
8 | export type VelocityDerivable = Pick<
9 | Integratable,
10 | "cpos" | "ppos"
11 | >;
12 | export type DeltaTimeMS = number;
13 |
--------------------------------------------------------------------------------
/src/accelerate.ts:
--------------------------------------------------------------------------------
1 | import { set } from './v2';
2 | import { DeltaTimeMS, Integratable } from './common-types';
3 |
4 | export const accelerate = (cmp: Integratable, dt: DeltaTimeMS) => {
5 | // apply acceleration to current position, convert dt to seconds
6 | cmp.cpos.x += cmp.acel.x * dt * dt * 0.001;
7 | cmp.cpos.y += cmp.acel.y * dt * dt * 0.001;
8 |
9 | // reset acceleration
10 | set(cmp.acel, 0, 0);
11 | };
--------------------------------------------------------------------------------
/src/project-capsule.ts:
--------------------------------------------------------------------------------
1 | import { v2, Vector2, sub, normalize, scale, add } from "./v2";
2 | import { Integratable } from "./common-types";
3 |
4 | // preallocations
5 | const v = v2();
6 | const direction = v2();
7 | const radiusSegment = v2();
8 |
9 | /**
10 | * Compute the leading edge of a circular moving object given a radius: cpos + radius in the direction of velocity.
11 | */
12 | export const projectCposWithRadius = (
13 | out: Vector2,
14 | p: Integratable,
15 | radius: number
16 | ) => {
17 | sub(v, p.cpos, p.ppos);
18 | normalize(direction, v);
19 | scale(radiusSegment, direction, radius);
20 | add(out, radiusSegment, p.cpos);
21 | return out;
22 | }
23 |
--------------------------------------------------------------------------------
/tsconfig.common.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "incremental": true,
4 | "composite": true,
5 | "target": "es2019",
6 | "module": "commonjs",
7 | "declaration": true,
8 | "declarationMap": true,
9 | "noEmit": true,
10 |
11 | "strict": true,
12 | "noImplicitAny": true,
13 | "strictNullChecks": true,
14 | "strictFunctionTypes": true,
15 | "strictBindCallApply": true,
16 | "strictPropertyInitialization": true,
17 | "noImplicitThis": true,
18 | "alwaysStrict": true,
19 | "noUnusedParameters": true,
20 |
21 | "allowSyntheticDefaultImports": true,
22 | "esModuleInterop": true,
23 | "moduleResolution": "node"
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/examples/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "examples",
3 | "packageManager": "yarn@3.0.2",
4 | "private": true,
5 | "targets": {
6 | "default": {
7 | "source": "./index.html",
8 | "distDir": "./build-demos/",
9 | "publicUrl": "/pocket-physics"
10 | }
11 | },
12 | "dependencies": {
13 | "science-halt": "^0.2.0"
14 | },
15 | "devDependencies": {
16 | "gh-pages": "^3.2.3",
17 | "parcel": "^2.0.0-rc.0"
18 | },
19 | "scripts": {
20 | "demos": "parcel serve --target default --dist-dir ./build-demos/ .",
21 | "demos:bundle": "rm -rf build-demos && parcel build --target default .",
22 | "demos:deploy": "yarn demos:bundle && gh-pages -d ./build-demos/"
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/src/segment-intersection.ts:
--------------------------------------------------------------------------------
1 | import { sub, v2, Vector2 } from "./v2";
2 |
3 | const s1 = v2();
4 | const s2 = v2();
5 |
6 | export function segmentIntersection(
7 | p0: Vector2,
8 | p1: Vector2,
9 | p2: Vector2,
10 | p3: Vector2,
11 | intersectionPoint: Vector2
12 | ) {
13 | sub(s1, p1, p0);
14 | sub(s2, p3, p2);
15 |
16 | const s =
17 | (-s1.y * (p0.x - p2.x) + s1.x * (p0.y - p2.y)) /
18 | (-s2.x * s1.y + s1.x * s2.y);
19 | const t =
20 | (s2.x * (p0.y - p2.y) - s2.y * (p0.x - p2.x)) /
21 | (-s2.x * s1.y + s1.x * s2.y);
22 |
23 | if (s >= 0 && s <= 1 && t >= 0 && t <= 1) {
24 | // Collision detected
25 | intersectionPoint.x = p0.x + t * s1.x;
26 | intersectionPoint.y = p0.y + t * s1.y;
27 | return true;
28 | }
29 |
30 | return false;
31 | }
32 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # pocket-physics
2 |
3 | Demos at [https://kirbysayshi.github.io/pocket-physics](https://kirbysayshi.github.io/pocket-physics).
4 |
5 | Fully typed, as it's written in Typescript!
6 |
7 | ```
8 | npm install pocket-physics
9 | ```
10 |
11 | ```js
12 | var { accelerate, inertia } = require("pocket-physics");
13 |
14 | var point = {
15 | cpos: { x: 0, y: 0 },
16 | ppos: { x: 0, y: 0 },
17 | acel: { x: 10, y: 0 },
18 | };
19 |
20 | // 16 is the delta time between steps in milliseconds
21 | accelerate(point1, 16);
22 | inertia(point);
23 |
24 | console.log(point.cpos);
25 | // { x: 5.12, y: 0 }
26 | ```
27 |
28 | # Maintenance
29 |
30 | Demos to gh-pages:
31 |
32 | ```
33 | yarn demos:deploy
34 | ```
35 |
36 | Publishing:
37 |
38 | ```
39 | npm version [...]
40 | git push origin HEAD --tags
41 | npm publish
42 | ```
43 |
44 | # LICENSE
45 |
46 | MIT
47 |
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 |
2 | // I would prefer to name all the exports here to prevent name clobbering,
3 | // but Babel cannot know if a named export is a Type or not. So even loading
4 | // this transpiled file in a project will cause failures when a builder,
5 | // like webpack or rollup, tries to resolve a Type as a code export.
6 |
7 | export * from "./accelerate";
8 | export * from "./collide-circle-circle";
9 | export * from "./collide-circle-edge";
10 | export * from "./collision-response-aabb";
11 | export * from "./solve-distance-constraint";
12 | export * from "./solve-drag";
13 | export * from "./solve-gravitation";
14 | export * from "./inertia";
15 | export * from "./overlap-aabb-aabb";
16 | export * from "./overlap-circle-circle";
17 | export * from "./rewind-to-collision-point";
18 | export * from "./segment-intersection";
19 | export * from "./v2";
20 | export * from "./common-types";
21 | export * from './project-point-edge';
22 | export * from './project-capsule';
23 |
--------------------------------------------------------------------------------
/src/solve-gravitation.ts:
--------------------------------------------------------------------------------
1 | import { add, magnitude, normalize, scale, set, v2 } from "./v2";
2 | import { Integratable, VelocityDerivable } from "./common-types";
3 | const accel1 = v2();
4 |
5 | export function solveGravitation(
6 | p1: Integratable,
7 | p1mass: number,
8 | p2: VelocityDerivable,
9 | p2mass: number,
10 | gravityConstant = 0.99
11 | ) {
12 | // handle either obj not having mass
13 | if (p1mass <= 0 || p2mass <= 0) return;
14 |
15 | let mag: number;
16 | let factor: number;
17 |
18 | const diffx = p2.cpos.x - p1.cpos.x;
19 | const diffy = p2.cpos.y - p1.cpos.y;
20 |
21 | set(accel1, diffx, diffy);
22 | mag = magnitude(accel1);
23 |
24 | // Prevent divide by zero.
25 | mag = mag === 0 ? 1 : mag;
26 |
27 | // Newton's Law of Universal Gravitation -- Vector Form!
28 | factor = gravityConstant * ((p1mass * p2mass) / (mag * mag));
29 |
30 | // scale by gravity acceleration
31 | normalize(accel1, accel1);
32 | scale(accel1, accel1, factor);
33 |
34 | // add the acceleration from gravity to p1 accel
35 | add(p1.acel, p1.acel, accel1);
36 | }
37 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "pocket-physics",
3 | "packageManager": "yarn@3.0.2",
4 | "version": "11.0.0",
5 | "description": "Verlet physics extracted from pocket-ces demos",
6 | "main": "cjs/index.js",
7 | "module": "esm/index.js",
8 | "workspaces": [
9 | "./examples"
10 | ],
11 | "files": [
12 | "esm/",
13 | "cjs",
14 | "src/"
15 | ],
16 | "scripts": {
17 | "prepublishOnly": "yarn build",
18 | "build": "tsc --build",
19 | "test": "jest"
20 | },
21 | "repository": {
22 | "type": "git",
23 | "url": "https://github.com/kirbysayshi/pocket-physics"
24 | },
25 | "keywords": [
26 | "verlet",
27 | "physics",
28 | "pocket-ces",
29 | "game"
30 | ],
31 | "author": "Drew Petersen ",
32 | "license": "MIT",
33 | "bugs": {
34 | "url": "https://github.com/kirbysayshi/pocket-physics/issues"
35 | },
36 | "homepage": "https://github.com/kirbysayshi/pocket-physics",
37 | "devDependencies": {
38 | "@types/jest": "^27.0.1",
39 | "jest": "^27.2.0",
40 | "prettier": "^2.4.0",
41 | "ts-jest": "^27.0.5",
42 | "typescript": "^4.4.3"
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/examples/pbd.ts:
--------------------------------------------------------------------------------
1 |
2 | // http://mmacklin.com/uppfrta_preprint.pdf
3 | // function tick () {
4 | // // "Agorithm 1"
5 |
6 | // const predictions = Array(particles.length);
7 | // const velocities = Array(particles.length);
8 |
9 | // for (let i = 0; i < particles.length; i++) {
10 | // // apply forces / accelerate
11 | // velocities[i] = sub(v2(), particles[i].cpos, particles[i].ppos);
12 | // velocities[i] = add(velocities[i], velocities[i], scale(v2(), particles[i].acel, dt))
13 | // // predict position
14 | // predictions[i] = add(v2(), particles[i].cpos, velocities[i]);
15 | // // apply mass scaling ???
16 | // }
17 |
18 | // for (let i = 0; i < particles.length; i++) {
19 | // // find neighboring particles using the predicted positions
20 | // // find solid contacts
21 | // }
22 |
23 | // let stabilityIterations = 2;
24 | // while (stabilityIterations--) {
25 | // // solve solid contact constraints, summing up deltas
26 | // // apply the (delta / numConstraints) to both predicted position and current position
27 | // }
28 |
29 | // let solverIterations = 2;
30 | // while (solverIterations--) [
31 | // // for each constraint type (group): distance, density, shape, etc
32 | // // solve all constraints, sum up deltas
33 | // // apply (delta / numConstrants) to predicted position
34 | // ]
35 |
36 | // for (let i = 0; i < particles.length; i++) {
37 | // // update velocity
38 | // // advect diffuse particles...
39 | // // apply internal forces (drag, vort)
40 | // // update positions to predicted positions, or apply sleeping
41 | // }
42 | // }
--------------------------------------------------------------------------------
/src/v2.test.ts:
--------------------------------------------------------------------------------
1 | import {
2 | add,
3 | copy,
4 | distance,
5 | distance2,
6 | dot,
7 | normal,
8 | normalize,
9 | perpDot,
10 | rotate2d,
11 | scale,
12 | sub,
13 | translate,
14 | v2,
15 | Vector2,
16 | } from "./v2";
17 |
18 | test("generics + nominal types", () => {
19 | type Millis = number & { _millis: true };
20 | type Nanos = number & { _nanos: true };
21 |
22 | const v0 = v2();
23 | const v1 = v2();
24 | const out = v2();
25 |
26 | // @ts-expect-error
27 | add(out, v0, v1);
28 |
29 | // @ts-expect-error
30 | sub(out, v0, v1);
31 |
32 | // @ts-expect-error
33 | dot(v0, v2());
34 |
35 | // @ts-expect-error
36 | copy(out, v1);
37 |
38 | const c: Vector2 = copy(v2(), v1);
39 | const s: Vector2 = sub(v2(), v0, v1);
40 |
41 | // @ts-expect-error
42 | scale(out, v0, 5);
43 |
44 | // @ts-expect-error
45 | distance(v1, v2());
46 |
47 | // @ts-expect-error
48 | distance2(v1, v2());
49 |
50 | // @ts-expect-error
51 | normalize(v1, v2());
52 |
53 | // @ts-expect-error
54 | normal(out, v0, v1);
55 |
56 | // @ts-expect-error
57 | perpDot(v1, v2());
58 |
59 | // @ts-expect-error
60 | translate(out, v0, v1);
61 |
62 | // @ts-expect-error
63 | rotate2d(out, v0, v1, Math.PI / 2);
64 | });
65 |
66 | test("rotate", () => {
67 | const out = v2();
68 | const origin = v2();
69 | const point = v2(1, 0);
70 | rotate2d(out, point, origin, Math.PI / 2);
71 | expect(out).toMatchInlineSnapshot(`
72 | Object {
73 | "x": 6.123233995736766e-17,
74 | "y": 1,
75 | }
76 | `);
77 | });
78 |
--------------------------------------------------------------------------------
/examples/index.ts:
--------------------------------------------------------------------------------
1 | import * as AABBOverlapDemo from "./aabb-overlap";
2 | import * as AABBSoupDemo from "./aabb-soup";
3 | import * as CircleCollisions from "./circle-collisions";
4 | import * as CircleBoxCollision from "./circle-box-collision";
5 | import * as EdgeCollision from "./edge-collision";
6 | import * as Platformer from "./platformer";
7 | import * as Bucket from "./bucket";
8 | import * as EdgeCollisionAABB from './edge-collision-aabb';
9 |
10 | const qs = new URLSearchParams(window.location.search);
11 | const demoName = qs.get("demo");
12 |
13 | const demos = new Map void; stop?: () => void }>([
14 | ["Bucket of Circles (Verlet)", Bucket],
15 | ["Circle Collisions (Verlet)", CircleCollisions],
16 | ["Circle to Box Collision (Verlet)", CircleBoxCollision],
17 | ["Single Edge Circle Collision (Verlet)", EdgeCollision],
18 | ["Platformer (AABB Impulse Model)", Platformer],
19 | ["AABB Overlap Demo (AABB Impulse Model)", AABBOverlapDemo],
20 | ["AABB Soup Demo (AABB Impulse Model)", AABBSoupDemo],
21 | ["Single Edge Circle Collision (AABB Impulse Model)", EdgeCollisionAABB]
22 | ]);
23 |
24 | if (demoName && demos.has(demoName)) {
25 | demos.get(demoName)!.start();
26 | } else {
27 | const names = Array.from(demos.keys());
28 |
29 | const li = (name: string) => {
30 | const cmp = encodeURIComponent(name);
31 | const url = `${window.location.pathname}?demo=${cmp}`;
32 | return `
33 | ${name}
34 | `;
35 | };
36 |
37 | const html = `
38 |
39 | ${names.map(name => li(name)).join("\n")}
40 |
41 | `;
42 | const el = document.createElement("div");
43 | el.innerHTML = html;
44 | document.body.appendChild(el);
45 | }
46 |
--------------------------------------------------------------------------------
/src/rewind-to-collision-point.ts:
--------------------------------------------------------------------------------
1 | import { sub, v2, Vector2, scale, normalize, add } from "./v2";
2 | import { segmentIntersection } from "./segment-intersection";
3 | import { Integratable } from "./common-types";
4 |
5 | const tunnelPoint = v2();
6 | const offset = v2();
7 |
8 | const v = v2();
9 | const direction = v2();
10 | const radiusSegment = v2();
11 | const cposIncludingRadius = v2();
12 |
13 | export function rewindToCollisionPoint(
14 | point3: Integratable,
15 | radius3: number,
16 | point1: Vector2,
17 | point2: Vector2
18 | ): boolean {
19 | // detect if a collision has occurred but would have been missed due to
20 | // point3 moving beyond the edge in one time step.
21 |
22 | // TODO: this should accept some sort of manifold or collision contact object,
23 | // not randum args (radius, etc). Without a manifold it's imposible to know
24 | // if the point "tunneled" due to a collision or the result of _resolving_
25 | // a collision!
26 |
27 | // Compute where the cpos would be if the segment actually included the
28 | // radius.
29 | // Without this, we would rewind to the point3 center, and the direction
30 | // of two points colliding with each other exactly is undefined.
31 | sub(v, point3.cpos, point3.ppos);
32 | normalize(direction, v);
33 | scale(radiusSegment, direction, radius3);
34 | add(cposIncludingRadius, radiusSegment, point3.cpos);
35 |
36 | const hasTunneled = segmentIntersection(
37 | cposIncludingRadius,
38 | point3.ppos,
39 | point1,
40 | point2,
41 | tunnelPoint
42 | );
43 |
44 | if (!hasTunneled) return false;
45 |
46 | // Translate point3 to tunnelPoint, including the radius of the point.
47 | sub(offset, cposIncludingRadius, tunnelPoint);
48 |
49 | sub(point3.cpos, point3.cpos, offset);
50 | sub(point3.ppos, point3.ppos, offset);
51 | return true;
52 | }
53 |
54 | function debuggerIfNaN(point: Vector2) {
55 | if (isNaN(point.x) || isNaN(point.y)) {
56 | debugger;
57 | }
58 | }
59 |
--------------------------------------------------------------------------------
/src/overlap-aabb-aabb.ts:
--------------------------------------------------------------------------------
1 | import { set, v2, Vector2 } from "./v2";
2 |
3 | // https://github.com/noonat/intersect/blob/master/intersect.js
4 |
5 | export type AABBOverlapResult = {
6 | resolve: V2;
7 | hitPos: V2;
8 | normal: V2;
9 | };
10 |
11 | /**
12 | * Create a result object to use for overlap tests.
13 | */
14 | export const createAABBOverlapResult = (): AABBOverlapResult<
15 | Vector2
16 | > => {
17 | return { resolve: v2(), hitPos: v2(), normal: v2() };
18 | };
19 |
20 | /**
21 | * Compute the "collision manifold" for two AABB, storing the result in `result`.
22 | * Note: The `normal` is always perpendicular to an AABB edge, which may produce
23 | * some slighly weird-looking collisions. `collisionResponseAABB()` will compute
24 | * a normal using the midpoints, which looks more natural.
25 | */
26 | export const overlapAABBAABB = (
27 | center1X: number,
28 | center1Y: number,
29 | width1: number,
30 | height1: number,
31 | center2X: number,
32 | center2Y: number,
33 | width2: number,
34 | height2: number,
35 | result: AABBOverlapResult
36 | ) => {
37 | const dx = center2X - center1X;
38 | const px = width2 / 2 + width1 / 2 - Math.abs(dx);
39 | const dy = center2Y - center1Y;
40 | const py = height2 / 2 + height1 / 2 - Math.abs(dy);
41 |
42 | if (px <= 0) return null;
43 | if (py <= 0) return null;
44 |
45 | set(result.resolve, 0, 0);
46 | set(result.hitPos, 0, 0);
47 | set(result.normal, 0, 0);
48 |
49 | if (px < py) {
50 | const sx = dx < 0 ? -1 : 1;
51 | result.resolve.x = px * sx;
52 | result.normal.x = sx;
53 | // Really not sure about these values.
54 | result.hitPos.x = center1X + (width1 / 2) * sx;
55 | result.hitPos.y = center2Y;
56 | } else {
57 | const sy = dy < 0 ? -1 : 1;
58 | result.resolve.y = py * sy;
59 | result.normal.y = sy;
60 | // Really not sure about these values.
61 | result.hitPos.x = center2X;
62 | result.hitPos.y = center1Y + (height1 / 2) * sy;
63 | }
64 |
65 | return result;
66 | };
67 |
--------------------------------------------------------------------------------
/src/solve-distance-constraint.ts:
--------------------------------------------------------------------------------
1 | import { add, distance2, set, sub, v2, magnitude, scale } from "./v2";
2 | import { Integratable } from "./common-types";
3 |
4 | // negative or zero mass implies a fixed or "pinned" point
5 | export function solveDistanceConstraint(
6 | p1: Integratable,
7 | p1mass: number,
8 | p2: Integratable,
9 | p2mass: number,
10 | goal: number,
11 | // number between 0 and 1
12 | stiffness = 1,
13 | // If false, correct the previous position in addition to the current position
14 | // when solving the constraint. While unnatural looking, this will prevent
15 | // "energy" (velocity) from being spontaneously created in the constraint
16 | // system.
17 | impartEnergy = true
18 | ): void {
19 | const mass1 = p1mass > 0 ? p1mass : 1;
20 | const mass2 = p2mass > 0 ? p2mass : 1;
21 | const imass1 = 1 / (mass1 || 1);
22 | const imass2 = 1 / (mass2 || 1);
23 | const imass = imass1 + imass2;
24 |
25 | // Current relative vector
26 | const delta = sub(v2(), p2.cpos, p1.cpos);
27 | const deltaMag = magnitude(delta);
28 |
29 | // nothing to do.
30 | if (deltaMag === 0) return;
31 |
32 | // Difference between current distance and goal distance
33 | const diff = (deltaMag - goal) / deltaMag;
34 |
35 | // TODO: is this even correct? Should mass come into effect here?
36 | // approximate mass
37 | scale(delta, delta, diff / imass);
38 |
39 | // TODO: not sure if this is the right place to apply stiffness.
40 | const p1correction = scale(v2(), delta, imass1 * stiffness);
41 | const p2correction = scale(v2(), delta, imass2 * stiffness);
42 |
43 | // Add correction to p1, but only if not "pinned".
44 | // If it's pinned and p2 is not, apply it to p2.
45 | if (p1mass > 0) {
46 | add(p1.cpos, p1.cpos, p1correction);
47 | if (!impartEnergy) add(p1.ppos, p1.ppos, p1correction);
48 | } else if (p2mass > 0) {
49 | sub(p2.cpos, p2.cpos, p1correction);
50 | if (!impartEnergy) sub(p2.ppos, p2.ppos, p1correction);
51 | }
52 |
53 | // Add correction to p2, but only if not "pinned".
54 | // If it's pinned and p1 is not, apply it to p1.
55 | if (p2mass > 0) {
56 | sub(p2.cpos, p2.cpos, p2correction);
57 | if (!impartEnergy) sub(p2.ppos, p2.ppos, p2correction);
58 | } else if (p1mass > 0) {
59 | add(p1.cpos, p1.cpos, p2correction);
60 | if (!impartEnergy) add(p1.ppos, p1.ppos, p2correction);
61 | }
62 | }
63 |
--------------------------------------------------------------------------------
/src/project-point-edge.ts:
--------------------------------------------------------------------------------
1 | import { distance, dot, normal, set, sub, v2, Vector2 } from "./v2";
2 |
3 | export type PointEdgeProjection = {
4 | // distance between the point and the projected point on the line
5 | distance: number;
6 | // dot product between edge normal (perp of endpoint1 -> endpoint2) and normal
7 | // from edge to point. If positive, they are pointing in the same direction
8 | // (aka the point is on the side of the segment in the direction of the
9 | // normal). If negative, the point is on the opposite side. The absolute value
10 | // of this will always match the distance.
11 | similarity: number;
12 | // What percentage along the line the projection is. < 0 means behind the
13 | // edge, > 1 means ahead of the edge endpoints.
14 | u: number;
15 | // The point in absolute space of the projection along the edge
16 | projectedPoint: V2;
17 | // The normal of the edge (endpoint1 -> endpoint2): Given v1(0,0) -> v2(10, 0), the normal will be (0, 1)
18 | edgeNormal: V2;
19 | };
20 |
21 | /**
22 | * Create a pre-made result object for tests.
23 | */
24 | export function createPointEdgeProjectionResult<
25 | V extends number
26 | >(): PointEdgeProjection> {
27 | return {
28 | distance: Number.MIN_SAFE_INTEGER,
29 | similarity: 0,
30 | u: Number.MIN_SAFE_INTEGER,
31 | projectedPoint: v2(),
32 | edgeNormal: v2(),
33 | };
34 | }
35 |
36 | const edgeDelta = v2();
37 | const perp = v2();
38 |
39 | export function projectPointEdge(
40 | point: Vector2,
41 | endpoint1: Vector2,
42 | endpoint2: Vector2,
43 | result: PointEdgeProjection
44 | ) {
45 | sub(edgeDelta, endpoint2, endpoint1);
46 | if (edgeDelta.x === 0 && edgeDelta.y === 0) {
47 | throw new Error("ZeroLengthEdge");
48 | }
49 |
50 | // http://paulbourke.net/geometry/pointlineplane/
51 | // http://paulbourke.net/geometry/pointlineplane/DistancePoint.java
52 | const u =
53 | ((point.x - endpoint1.x) * edgeDelta.x +
54 | (point.y - endpoint1.y) * edgeDelta.y) /
55 | (edgeDelta.x * edgeDelta.x + edgeDelta.y * edgeDelta.y);
56 |
57 | result.u = u;
58 |
59 | const proj = set(
60 | result.projectedPoint,
61 | endpoint1.x + u * edgeDelta.x,
62 | endpoint1.y + u * edgeDelta.y
63 | );
64 |
65 | result.distance = distance(proj, point);
66 |
67 | // given:
68 | // E1----------------------E2 Proj
69 | // | |
70 | // | EdgeNorm | perp
71 | // | Point
72 | //
73 | // What is the similarity (dot product) between EdgeNorm and Perp?
74 | const edgeNorm = normal(result.edgeNormal, endpoint1, endpoint2);
75 | sub(perp, point, proj);
76 | result.similarity = dot(edgeNorm, perp);
77 | }
78 |
--------------------------------------------------------------------------------
/src/collide-circle-circle.ts:
--------------------------------------------------------------------------------
1 | import { add, distance2, set, sub } from "./v2";
2 | import { VelocityDerivable } from "./common-types";
3 |
4 | // Preallocations!
5 | const vel1 = { x: 0, y: 0 };
6 | const vel2 = { x: 0, y: 0 };
7 | const diff = { x: 0, y: 0 };
8 | const move = { x: 0, y: 0 };
9 |
10 | // TODO: is the below even true???
11 | // The codeflow demo does nothing if the circles are no longer overlapping.
12 |
13 | // It's very important that this function not do any distance checking.
14 | // It is assumed that if this function is called, then the points are
15 | // definitely colliding, and that after being called with preserveInertia
16 | // === false, another call with === true should be made, even if the first
17 | // calculation has moved the points away from physically touching.
18 |
19 | export const collideCircleCircle = (
20 | p1: VelocityDerivable,
21 | p1radius: number,
22 | p1mass: number,
23 | p2: VelocityDerivable,
24 | p2radius: number,
25 | p2mass: number,
26 | preserveInertia: boolean,
27 | damping: number
28 | ) => {
29 | const dist2 = distance2(p1.cpos, p2.cpos);
30 | const target = p1radius + p2radius;
31 | const min2 = target * target;
32 |
33 | // if (dist2 > min2) return;
34 |
35 | sub(vel1, p1.cpos, p1.ppos);
36 | sub(vel2, p2.cpos, p2.ppos);
37 |
38 | sub(diff, p1.cpos, p2.cpos);
39 | const dist = Math.sqrt(dist2);
40 | let factor = (dist - target) / dist;
41 |
42 | // Avoid division by zero in case points are directly atop each other.
43 | if (dist === 0) factor = 1;
44 |
45 | const mass1 = p1mass > 0 ? p1mass : 1;
46 | const mass2 = p2mass > 0 ? p2mass : 1;
47 | const massT = mass1 + mass2;
48 |
49 | // Move a away
50 | move.x = diff.x * factor * (mass2 / massT);
51 | move.y = diff.y * factor * (mass2 / massT);
52 | if (p1mass > 0) {
53 | sub(p1.cpos, p1.cpos, move);
54 | }
55 |
56 | // Move b away
57 | move.x = diff.x * factor * (mass1 / massT);
58 | move.y = diff.y * factor * (mass1 / massT);
59 | if (p2mass > 0) {
60 | add(p2.cpos, p2.cpos, move);
61 | }
62 |
63 | if (!preserveInertia) return;
64 |
65 | damping = damping || 1;
66 |
67 | const f1 = (damping * (diff.x * vel1.x + diff.y * vel1.y)) / (dist2 || 1);
68 | const f2 = (damping * (diff.x * vel2.x + diff.y * vel2.y)) / (dist2 || 1);
69 |
70 | vel1.x += (f2 * diff.x - f1 * diff.x) / (mass1 || 1); // * (mass2 / massT);
71 | vel2.x += (f1 * diff.x - f2 * diff.x) / (mass2 || 1); // * (mass1 / massT);
72 | vel1.y += (f2 * diff.y - f1 * diff.y) / (mass1 || 1); // * (mass2 / massT);
73 | vel2.y += (f1 * diff.y - f2 * diff.y) / (mass2 || 1); // * (mass1 / massT);
74 |
75 | if (p1mass > 0) set(p1.ppos, p1.cpos.x - vel1.x, p1.cpos.y - vel1.y);
76 | if (p2mass > 0) set(p2.ppos, p2.cpos.x - vel2.x, p2.cpos.y - vel2.y);
77 | };
78 |
--------------------------------------------------------------------------------
/src/project-point-edge.test.ts:
--------------------------------------------------------------------------------
1 | import {
2 | createPointEdgeProjectionResult,
3 | projectPointEdge,
4 | } from "./project-point-edge";
5 | import { v2 } from "./v2";
6 |
7 | const projection = createPointEdgeProjectionResult();
8 |
9 | test("point above edge 2", () => {
10 | const ball = v2(0, 5);
11 | const p0 = v2(-10, 0);
12 | const p1 = v2(10, 0);
13 |
14 | projectPointEdge(ball, p0, p1, projection);
15 |
16 | expect(projection).toMatchInlineSnapshot(`
17 | Object {
18 | "distance": 5,
19 | "edgeNormal": Object {
20 | "x": 0,
21 | "y": 1,
22 | },
23 | "projectedPoint": Object {
24 | "x": 0,
25 | "y": 0,
26 | },
27 | "similarity": 5,
28 | "u": 0.5,
29 | }
30 | `);
31 | });
32 |
33 | test("point below edge 2", () => {
34 | const ball = v2(0, -5);
35 | const p0 = v2(-10, 0);
36 | const p1 = v2(10, 0);
37 |
38 | projectPointEdge(ball, p0, p1, projection);
39 |
40 | expect(projection).toMatchInlineSnapshot(`
41 | Object {
42 | "distance": 5,
43 | "edgeNormal": Object {
44 | "x": 0,
45 | "y": 1,
46 | },
47 | "projectedPoint": Object {
48 | "x": 0,
49 | "y": 0,
50 | },
51 | "similarity": -5,
52 | "u": 0.5,
53 | }
54 | `);
55 | });
56 |
57 | test("point above and behind edge 2", () => {
58 | const ball = v2(-15, 5);
59 | const p0 = v2(-10, 0);
60 | const p1 = v2(10, 0);
61 |
62 | projectPointEdge(ball, p0, p1, projection);
63 |
64 | expect(projection).toMatchInlineSnapshot(`
65 | Object {
66 | "distance": 5,
67 | "edgeNormal": Object {
68 | "x": 0,
69 | "y": 1,
70 | },
71 | "projectedPoint": Object {
72 | "x": -15,
73 | "y": 0,
74 | },
75 | "similarity": 5,
76 | "u": -0.25,
77 | }
78 | `);
79 | });
80 |
81 | test("point above and ahead of edge 2", () => {
82 | const ball = v2(15, 5);
83 | const p0 = v2(-10, 0);
84 | const p1 = v2(10, 0);
85 |
86 | projectPointEdge(ball, p0, p1, projection);
87 |
88 | expect(projection).toMatchInlineSnapshot(`
89 | Object {
90 | "distance": 5,
91 | "edgeNormal": Object {
92 | "x": 0,
93 | "y": 1,
94 | },
95 | "projectedPoint": Object {
96 | "x": 15,
97 | "y": 0,
98 | },
99 | "similarity": 5,
100 | "u": 1.25,
101 | }
102 | `);
103 | });
104 |
105 | test("point below and behind edge 2", () => {
106 | const ball = v2(-15, -5);
107 | const p0 = v2(-10, 0);
108 | const p1 = v2(10, 0);
109 |
110 | projectPointEdge(ball, p0, p1, projection);
111 |
112 | expect(projection).toMatchInlineSnapshot(`
113 | Object {
114 | "distance": 5,
115 | "edgeNormal": Object {
116 | "x": 0,
117 | "y": 1,
118 | },
119 | "projectedPoint": Object {
120 | "x": -15,
121 | "y": 0,
122 | },
123 | "similarity": -5,
124 | "u": -0.25,
125 | }
126 | `);
127 | });
128 |
129 | test("point below and ahead edge 2", () => {
130 | const ball = v2(15, -5);
131 | const p0 = v2(-10, 0);
132 | const p1 = v2(10, 0);
133 |
134 | projectPointEdge(ball, p0, p1, projection);
135 |
136 | expect(projection).toMatchInlineSnapshot(`
137 | Object {
138 | "distance": 5,
139 | "edgeNormal": Object {
140 | "x": 0,
141 | "y": 1,
142 | },
143 | "projectedPoint": Object {
144 | "x": 15,
145 | "y": 0,
146 | },
147 | "similarity": -5,
148 | "u": 1.25,
149 | }
150 | `);
151 | });
152 |
--------------------------------------------------------------------------------
/examples/aabb-overlap.ts:
--------------------------------------------------------------------------------
1 | import scihalt from "science-halt";
2 | import {
3 | accelerate,
4 | add,
5 | collisionResponseAABB,
6 | createAABBOverlapResult,
7 | inertia,
8 | overlapAABBAABB,
9 | scale,
10 | sub,
11 | v2,
12 | Vector2,
13 | } from "../src";
14 |
15 | type Box = {
16 | cpos: Vector2;
17 | ppos: Vector2;
18 | acel: Vector2;
19 | w: number;
20 | h: number;
21 | mass: number;
22 | }
23 |
24 | export const start = () => {
25 | const cvs = document.createElement("canvas");
26 | const ctx = cvs.getContext("2d")!;
27 | cvs.width = cvs.height = 800;
28 | cvs.style.border = "1px solid gray";
29 | document.body.appendChild(cvs);
30 |
31 | const box1 = {
32 | cpos: v2(350, 90),
33 | ppos: v2(349, 80),
34 | acel: v2(),
35 | w: 100,
36 | h: 150,
37 | mass: 10
38 | };
39 |
40 | const box2 = {
41 | cpos: v2(350, 600),
42 | ppos: v2(350, 600),
43 | acel: v2(),
44 | w: 100,
45 | h: 150,
46 | mass: 10
47 | };
48 |
49 | const points: Box[] = [];
50 | const collision = createAABBOverlapResult();
51 |
52 | points.push(box1, box2);
53 |
54 | let running = true;
55 | scihalt(() => (running = false));
56 |
57 | (function step() {
58 | const dt = 1;
59 | for (let i = 0; i < points.length; i++) {
60 | const point = points[i];
61 | accelerate(point, dt);
62 | }
63 |
64 | const isOverlapping = overlapAABBAABB(
65 | box1.cpos.x,
66 | box1.cpos.y,
67 | box1.w,
68 | box1.h,
69 | box2.cpos.x,
70 | box2.cpos.y,
71 | box2.w,
72 | box2.h,
73 | collision
74 | );
75 |
76 | if (isOverlapping) {
77 | // for debugging
78 | render(points, ctx);
79 |
80 | // move to non-overlapping position
81 | const overlapHalf = scale(v2(), collision.resolve, 0.5);
82 | add(box2.cpos, box2.cpos, overlapHalf);
83 | add(box2.ppos, box2.ppos, overlapHalf);
84 | sub(box1.cpos, box1.cpos, overlapHalf);
85 | sub(box1.ppos, box1.ppos, overlapHalf);
86 |
87 | // for debugging
88 | render(points, ctx);
89 |
90 | const box1v = v2();
91 | const box2v = v2();
92 |
93 | const restitution = 1;
94 | const staticFriction = 0.9;
95 | const dynamicFriction = 0.01;
96 |
97 | collisionResponseAABB(
98 | box1.cpos,
99 | box1.ppos,
100 | box1.mass,
101 | restitution,
102 | staticFriction,
103 | dynamicFriction,
104 | box2.cpos,
105 | box2.ppos,
106 | box2.mass,
107 | restitution,
108 | staticFriction,
109 | dynamicFriction,
110 | // Allow the response function to recompute a normal based on the
111 | // axis between the centers of the boxes. this produces a more
112 | // natural looking collision.
113 | // collision.normal,
114 | v2(),
115 | box1v,
116 | box2v
117 | );
118 |
119 | // Apply the new velocity
120 | sub(box1.ppos, box1.cpos, box1v);
121 | sub(box2.ppos, box2.cpos, box2v);
122 |
123 | // for debugging
124 | render(points, ctx);
125 | }
126 |
127 | for (let i = 0; i < points.length; i++) {
128 | const point = points[i];
129 | inertia(point);
130 | }
131 |
132 | render(points, ctx);
133 | if (!running) return;
134 | window.requestAnimationFrame(step);
135 | })();
136 |
137 | function render(points: Box[], ctx: CanvasRenderingContext2D) {
138 | ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
139 | for (let i = 0; i < points.length; i++) {
140 | const point = points[i];
141 |
142 | ctx.fillStyle = "red";
143 | ctx.fillRect(
144 | point.ppos.x - point.w / 2,
145 | point.ppos.y - point.h / 2,
146 | point.w,
147 | point.h
148 | );
149 |
150 | ctx.fillStyle = "black";
151 | ctx.fillRect(
152 | point.cpos.x - point.w / 2,
153 | point.cpos.y - point.h / 2,
154 | point.w,
155 | point.h
156 | );
157 | }
158 | }
159 | };
160 |
--------------------------------------------------------------------------------
/src/v2.ts:
--------------------------------------------------------------------------------
1 | export type Vector2 = { x: V; y: V };
2 |
3 | export function v2(x?: V, y?: V): Vector2 {
4 | return { x: x || (0 as V), y: y || (0 as V) };
5 | }
6 |
7 | export const copy = (out: Vector2, a: Vector2) => {
8 | out.x = a.x;
9 | out.y = a.y;
10 | return out;
11 | };
12 |
13 | export const set = (out: Vector2, x: V, y: V) => {
14 | out.x = x;
15 | out.y = y;
16 | return out;
17 | };
18 |
19 | export const add = (
20 | out: Vector2,
21 | a: Vector2,
22 | b: Vector2
23 | ) => {
24 | out.x = (a.x + b.x) as V;
25 | out.y = (a.y + b.y) as V;
26 | return out;
27 | };
28 |
29 | export const sub = (
30 | out: Vector2,
31 | a: Vector2,
32 | b: Vector2
33 | ) => {
34 | out.x = (a.x - b.x) as V;
35 | out.y = (a.y - b.y) as V;
36 | return out;
37 | };
38 |
39 | export const dot = (a: Vector2, b: Vector2) =>
40 | a.x * b.x + a.y * b.y;
41 |
42 | export const scale = (
43 | out: Vector2,
44 | a: Vector2,
45 | factor: number
46 | ) => {
47 | out.x = (a.x * factor) as V;
48 | out.y = (a.y * factor) as V;
49 | return out;
50 | };
51 |
52 | export const distance = (v1: Vector2, v2: Vector2) => {
53 | const x = v1.x - v2.x;
54 | const y = v1.y - v2.y;
55 | return Math.sqrt(x * x + y * y);
56 | };
57 |
58 | export const distance2 = (v1: Vector2, v2: Vector2) => {
59 | const x = v1.x - v2.x;
60 | const y = v1.y - v2.y;
61 | return x * x + y * y;
62 | };
63 |
64 | export const magnitude = (v1: Vector2) => {
65 | const x = v1.x;
66 | const y = v1.y;
67 | return Math.sqrt(x * x + y * y);
68 | };
69 |
70 | export const normalize = (out: Vector2, a: Vector2) => {
71 | const x = a.x;
72 | const y = a.y;
73 | let len = x * x + y * y;
74 | if (len > 0) {
75 | len = 1 / Math.sqrt(len);
76 | out.x = (a.x * len) as V;
77 | out.y = (a.y * len) as V;
78 | }
79 | return out;
80 | };
81 |
82 | /**
83 | * Compute the normal pointing away perpendicular from two vectors.
84 | * Given v1(0,0) -> v2(10, 0), the normal will be (0, 1)
85 | * */
86 | export const normal = (
87 | out: Vector2,
88 | v1: Vector2,
89 | v2: Vector2
90 | ) => {
91 | out.y = (v2.x - v1.x) as V;
92 | out.x = (v1.y - v2.y) as V;
93 | return normalize(out, out);
94 | };
95 |
96 | // the perpendicular dot product, also known as "cross" elsewhere
97 | // http://stackoverflow.com/a/243977/169491
98 | export const perpDot = (v1: Vector2, v2: Vector2) => {
99 | return v1.x * v2.y - v1.y * v2.x;
100 | };
101 |
102 | /**
103 | * This is mostly useful for moving a verlet-style [current, previous]
104 | * by the same amount, translating them while preserving velocity.
105 | * @param by the vector to add to each subsequent vector
106 | * @param vN any number of vectors to translate
107 | */
108 | export const translate = (
109 | by: Vector2,
110 | ...vN: Vector2[]
111 | ) => {
112 | for (let i = 0; i < vN.length; i++) {
113 | const v = vN[i];
114 | add(v, v, by);
115 | }
116 | };
117 |
118 | /**
119 | *
120 | * @param v Print this vector for nice logs
121 | */
122 | export function vd(v: Vector2) {
123 | return `(${v.x}, ${v.y})`;
124 | }
125 |
126 | /**
127 | * Rotate a vector around another point. Taken nearly verbatim from gl-matrix
128 | */
129 | export const rotate2d = (
130 | out: V,
131 | target: V,
132 | origin: V,
133 | rad: number
134 | ) => {
135 | //Translate point to the origin
136 | const p0 = target.x - origin.x;
137 | const p1 = target.y - origin.y;
138 | const sinC = Math.sin(rad);
139 | const cosC = Math.cos(rad);
140 |
141 | //perform rotation and translate to correct position
142 | out.x = p0 * cosC - p1 * sinC + origin.x;
143 | out.y = p0 * sinC + p1 * cosC + origin.y;
144 |
145 | return out;
146 | };
147 |
148 | /**
149 | * Compute the Theta angle between a vector and the origin.
150 | */
151 | export function angleOf(v: Vector2) {
152 | return Math.atan2(v.y, v.x);
153 | }
154 |
--------------------------------------------------------------------------------
/examples/edge-collision.ts:
--------------------------------------------------------------------------------
1 | import scihalt from "science-halt";
2 | import {
3 | Vector2,
4 | v2,
5 | accelerate,
6 | inertia,
7 | add,
8 | collideCircleEdge,
9 | rewindToCollisionPoint,
10 | solveDistanceConstraint
11 | } from "../src";
12 |
13 | export const start = () => {
14 | const cvs = document.createElement("canvas");
15 | const ctx = cvs.getContext("2d")!;
16 | cvs.tabIndex = 1; // for keyboard events
17 | cvs.width = cvs.height = 800;
18 | cvs.style.border = "1px solid gray";
19 | document.body.appendChild(cvs);
20 |
21 | type CollidableLine = {
22 | point1: CollidableCircle;
23 | point2: CollidableCircle;
24 | goal: number;
25 | };
26 |
27 | type CollidableCircle = {
28 | cpos: Vector2;
29 | ppos: Vector2;
30 | acel: Vector2;
31 | mass: number;
32 | radius: number;
33 | };
34 |
35 | const player: CollidableCircle = {
36 | cpos: v2(600, 0),
37 | ppos: v2(600, 0),
38 | acel: v2(0, 0),
39 | mass: 1,
40 | radius: 20
41 | };
42 |
43 | const GRAVITY = 0.8;
44 |
45 | const platform: CollidableLine = {
46 | point1: {
47 | cpos: v2(100, 300),
48 | ppos: v2(100, 300),
49 | acel: v2(0, 0),
50 | // mass: 100000000,
51 | mass: -1,
52 | radius: 0
53 | },
54 | point2: {
55 | cpos: v2(700, 300),
56 | ppos: v2(700, 300),
57 | acel: v2(0, 0),
58 | // mass: 100000000,
59 | // mass: -1,
60 | mass: -1,
61 | radius: 0
62 | },
63 | goal: 500
64 | };
65 |
66 | const circles: CollidableCircle[] = [
67 | player,
68 | platform.point1,
69 | platform.point2
70 | ];
71 |
72 | const tunnelPoint = v2();
73 |
74 | let running = true;
75 | scihalt(() => (running = false));
76 |
77 | // const keys: { [key: string]: boolean } = {};
78 | // cvs.addEventListener("keydown", e => {
79 | // keys[e.key] = true;
80 | // e.preventDefault();
81 | // });
82 | // document.body.addEventListener("keyup", e => {
83 | // keys[e.key] = false;
84 | // e.preventDefault();
85 | // });
86 |
87 | (function step() {
88 | const dt = 16;
89 |
90 | // gravity!
91 | add(player.acel, player.acel, v2(0, GRAVITY));
92 |
93 | for (let i = 0; i < circles.length; i++) {
94 | const box = circles[i];
95 | accelerate(box, dt);
96 | }
97 |
98 | rewindToCollisionPoint(player, player.radius, platform.point1.cpos, platform.point2.cpos);
99 |
100 | collideCircleEdge(
101 | player,
102 | player.radius,
103 | player.mass,
104 | platform.point1,
105 | platform.point1.mass,
106 | platform.point2,
107 | platform.point2.mass,
108 | false,
109 | 0.9
110 | );
111 |
112 | for (let i = 0; i < circles.length; i++) {
113 | const box = circles[i];
114 | inertia(box);
115 | }
116 |
117 | collideCircleEdge(
118 | player,
119 | player.radius,
120 | player.mass,
121 | platform.point1,
122 | platform.point1.mass,
123 | platform.point2,
124 | platform.point2.mass,
125 | true,
126 | 0.9
127 | );
128 |
129 | for (let i = 0; i < 5; i++) {
130 | solveDistanceConstraint(
131 | platform.point1,
132 | platform.point1.mass,
133 | platform.point2,
134 | platform.point2.mass,
135 | platform.goal
136 | );
137 | }
138 |
139 | render(circles, [platform], ctx);
140 | if (!running) return;
141 | window.requestAnimationFrame(step);
142 | })();
143 |
144 | function render(
145 | circles: CollidableCircle[],
146 | segments: CollidableLine[],
147 | ctx: CanvasRenderingContext2D
148 | ) {
149 | ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
150 | for (let i = 0; i < circles.length; i++) {
151 | const point = circles[i];
152 |
153 | ctx.fillStyle = "red";
154 | ctx.beginPath();
155 | ctx.arc(point.ppos.x, point.ppos.y, point.radius, 0, Math.PI * 2, false);
156 | ctx.fill();
157 |
158 | ctx.fillStyle = "black";
159 | ctx.beginPath();
160 | ctx.arc(point.cpos.x, point.cpos.y, point.radius, 0, Math.PI * 2, false);
161 | ctx.fill();
162 | }
163 |
164 | for (let i = 0; i < segments.length; i++) {
165 | const segment = segments[i];
166 | ctx.strokeStyle = "red";
167 | ctx.beginPath();
168 | ctx.moveTo(segment.point1.ppos.x, segment.point1.ppos.y);
169 | ctx.lineTo(segment.point2.ppos.x, segment.point2.ppos.y);
170 | ctx.stroke();
171 | ctx.beginPath();
172 | ctx.strokeStyle = "black";
173 | ctx.moveTo(segment.point1.cpos.x, segment.point1.cpos.y);
174 | ctx.lineTo(segment.point2.cpos.x, segment.point2.cpos.y);
175 | ctx.stroke();
176 | }
177 | }
178 | };
179 |
--------------------------------------------------------------------------------
/examples/circle-collisions.ts:
--------------------------------------------------------------------------------
1 | import scihalt from "science-halt";
2 | import {
3 | add,
4 | copy,
5 | normalize,
6 | scale,
7 | sub,
8 | v2,
9 | accelerate,
10 | inertia,
11 | solveGravitation,
12 | overlapCircleCircle,
13 | collideCircleCircle,
14 | Vector2
15 | } from "../src/index";
16 |
17 | export const start = () => {
18 | const cvs = document.createElement("canvas");
19 | const ctx = cvs.getContext("2d")!;
20 | cvs.width = cvs.height = 800;
21 | cvs.style.border = "1px solid gray";
22 | document.body.appendChild(cvs);
23 |
24 | type Point = (ReturnType)[0];
25 |
26 | // generate a circle of circles
27 | const CENTER = { x: 400, y: 400 };
28 | const GRAVITATIONAL_POINT = {
29 | cpos: copy(v2(), CENTER),
30 | ppos: copy(v2(), CENTER),
31 | acel: v2(),
32 | radius: 20,
33 | mass: 10000
34 | };
35 | const RADIUS = 15;
36 | const DAMPING = 0.1;
37 | const points = generatePoints(CENTER, RADIUS, 40);
38 | const colliding: Point[] = [];
39 |
40 | points.unshift(GRAVITATIONAL_POINT);
41 |
42 | let running = true;
43 | scihalt(() => (running = false));
44 |
45 | let ticks = 0;
46 |
47 | (function step() {
48 | const force = v2();
49 | const dt = 16;
50 | for (let i = 0; i < points.length; i++) {
51 | const point = points[i];
52 | if (point !== GRAVITATIONAL_POINT && ticks < 100) {
53 | solveGravitation(
54 | point,
55 | point.mass,
56 | GRAVITATIONAL_POINT,
57 | GRAVITATIONAL_POINT.mass
58 | );
59 | //sub(force, GRAVITATIONAL_POINT.cpos, point.cpos);
60 | //normalize(force, force);
61 | //scale(force, force, 50);
62 | //add(point.acel, point.acel, force);
63 | }
64 | accelerate(point, dt);
65 | }
66 |
67 | collisionPairs(colliding, points);
68 |
69 | for (let i = 0; i < colliding.length; i += 2) {
70 | const pointA = colliding[i];
71 | const pointB = colliding[i + 1];
72 | collideCircleCircle(
73 | pointA,
74 | pointA.radius,
75 | pointA.mass,
76 | pointB,
77 | pointB.radius,
78 | pointB.mass,
79 | false,
80 | DAMPING
81 | );
82 | }
83 |
84 | for (let i = 0; i < points.length; i++) {
85 | const point = points[i];
86 | inertia(point);
87 | }
88 |
89 | // TODO: the original demo code technically "detects" collisions with each
90 | // iteration, but when we do this, it actually becomes more unstable.
91 | // collisionPairs(colliding, points);
92 |
93 | for (let i = 0; i < colliding.length; i += 2) {
94 | const pointA = colliding[i];
95 | const pointB = colliding[i + 1];
96 | collideCircleCircle(
97 | pointA,
98 | pointA.radius,
99 | pointA.mass,
100 | pointB,
101 | pointB.radius,
102 | pointB.mass,
103 | true,
104 | DAMPING
105 | );
106 | }
107 |
108 | render(points, ctx);
109 | ticks++;
110 | if (!running) return;
111 | window.requestAnimationFrame(step);
112 | })();
113 |
114 | function collisionPairs(pairs: Point[], points: Point[]) {
115 | pairs.length = 0;
116 |
117 | for (let i = 0; i < points.length; i++) {
118 | const pointA = points[i];
119 | for (let j = i + 1; j < points.length; j++) {
120 | const pointB = points[j];
121 | if (
122 | overlapCircleCircle(
123 | pointA.cpos.x,
124 | pointA.cpos.y,
125 | pointA.radius,
126 | pointB.cpos.x,
127 | pointB.cpos.y,
128 | pointB.radius
129 | )
130 | ) {
131 | pairs.push(pointA, pointB);
132 | }
133 | }
134 | }
135 |
136 | return pairs;
137 | }
138 |
139 | function generatePoints(center: Vector2, baseRadius: number, num: number) {
140 | const all = [];
141 | const minRadius = 10;
142 | for (let i = 0; i < num; i++) {
143 | const x = Math.cos(i) * center.x + center.x;
144 | const y = Math.sin(i) * center.y + center.y;
145 | all.push({
146 | cpos: { x, y },
147 | ppos: { x, y },
148 | acel: { x: 0, y: 0 },
149 | radius: Math.max(
150 | Math.abs(Math.cos(i) + Math.sin(i)) * baseRadius,
151 | minRadius
152 | ),
153 | mass: Math.max(Math.abs(Math.cos(i) + Math.sin(i)) * 1, 1)
154 | });
155 | }
156 | return all;
157 | }
158 |
159 | function render(points: Point[], ctx: CanvasRenderingContext2D) {
160 | ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
161 | for (let i = 0; i < points.length; i++) {
162 | const point = points[i];
163 |
164 | ctx.fillStyle = "red";
165 | ctx.beginPath();
166 | ctx.arc(point.ppos.x, point.ppos.y, point.radius, 0, Math.PI * 2, false);
167 | ctx.fill();
168 |
169 | ctx.fillStyle = "black";
170 | ctx.beginPath();
171 | ctx.arc(point.cpos.x, point.cpos.y, point.radius, 0, Math.PI * 2, false);
172 | ctx.fill();
173 | }
174 | }
175 | };
176 |
--------------------------------------------------------------------------------
/examples/platformer.ts:
--------------------------------------------------------------------------------
1 | import scihalt from "science-halt";
2 | import {
3 | accelerate,
4 | add,
5 | collisionResponseAABB,
6 | createAABBOverlapResult,
7 | inertia,
8 | overlapAABBAABB,
9 | scale,
10 | sub,
11 | translate,
12 | v2,
13 | Vector2,
14 | } from "../src";
15 |
16 | export const start = () => {
17 | const cvs = document.createElement("canvas");
18 | const ctx = cvs.getContext("2d")!;
19 | cvs.tabIndex = 1; // for keyboard events
20 | cvs.width = cvs.height = 800;
21 | cvs.style.border = "1px solid gray";
22 | document.body.appendChild(cvs);
23 |
24 | type CollidableBox = {
25 | cpos: Vector2;
26 | ppos: Vector2;
27 | acel: Vector2;
28 | width: number;
29 | height: number;
30 | mass: number;
31 | };
32 |
33 | const player: CollidableBox = {
34 | cpos: v2(400, 0),
35 | ppos: v2(400, 0),
36 | acel: v2(0, 0),
37 | width: 50,
38 | height: 75,
39 | mass: 1
40 | };
41 |
42 | const PLAYER_HOR_ACEL = 5;
43 | const GRAVITY = 9.8;
44 |
45 | const platform: CollidableBox = {
46 | cpos: v2(400, 700),
47 | ppos: v2(400, 700),
48 | acel: v2(0, 0),
49 | width: 800,
50 | height: 100,
51 | mass: Number.MAX_SAFE_INTEGER - 100000
52 | };
53 |
54 | const boxes: CollidableBox[] = [player, platform];
55 |
56 | const collision = createAABBOverlapResult();
57 |
58 | let running = true;
59 | scihalt(() => (running = false));
60 |
61 | const keys: { [key: string]: boolean } = {};
62 | cvs.addEventListener("keydown", e => {
63 | keys[e.key] = true;
64 | e.preventDefault();
65 | });
66 | document.body.addEventListener("keyup", e => {
67 | keys[e.key] = false;
68 | e.preventDefault();
69 | });
70 |
71 | (function step() {
72 | const dt = 16;
73 |
74 | // gravity!
75 | add(player.acel, player.acel, v2(0, GRAVITY));
76 |
77 | for (let i = 0; i < boxes.length; i++) {
78 | const box = boxes[i];
79 | accelerate(box, dt);
80 | }
81 |
82 | const isOverlapping = overlapAABBAABB(
83 | player.cpos.x,
84 | player.cpos.y,
85 | player.width,
86 | player.height,
87 | platform.cpos.x,
88 | platform.cpos.y,
89 | platform.width,
90 | platform.height,
91 | collision
92 | );
93 |
94 | if (isOverlapping) {
95 | // Move to non-overlapping positions
96 | const negativeResolve = scale(v2(), collision.resolve, -1);
97 | // translate(overlapHalf, platform.cpos, platform.ppos);
98 | translate(negativeResolve, player.cpos, player.ppos);
99 |
100 | // for debugging
101 | render(boxes, ctx);
102 |
103 | // We will put the new relative velocity vectors here.
104 | const box1v = v2();
105 | const box2v = v2();
106 |
107 | const restitution = 1;
108 | const staticFriction = 0.9;
109 | const dynamicFriction = 0.1;
110 |
111 | collisionResponseAABB(
112 | player.cpos,
113 | player.ppos,
114 | player.mass,
115 | restitution,
116 | staticFriction,
117 | dynamicFriction,
118 | platform.cpos,
119 | platform.ppos,
120 | platform.mass,
121 | restitution,
122 | staticFriction,
123 | dynamicFriction,
124 | collision.normal,
125 | box1v,
126 | box2v
127 | );
128 |
129 | // Apply the new velocity
130 | sub(player.ppos, player.cpos, box1v);
131 | // Kill vertical velocity
132 | player.ppos.y = player.cpos.y;
133 |
134 | // for debugging
135 | render(boxes, ctx);
136 | }
137 |
138 | // Movement in the air is less powerful than on the ground!
139 |
140 | if (keys.ArrowLeft) {
141 | add(
142 | player.acel,
143 | player.acel,
144 | v2(isOverlapping ? -PLAYER_HOR_ACEL : -PLAYER_HOR_ACEL / 10, 0)
145 | );
146 | }
147 |
148 | if (keys.ArrowRight) {
149 | add(
150 | player.acel,
151 | player.acel,
152 | v2(isOverlapping ? PLAYER_HOR_ACEL : PLAYER_HOR_ACEL / 10, 0)
153 | );
154 | }
155 |
156 | // we were overlapping, so probably ok to jump!
157 | if (isOverlapping && keys.ArrowUp && player.cpos.y - player.ppos.y === 0) {
158 | add(player.acel, player.acel, v2(0, -GRAVITY * 10));
159 | }
160 |
161 | for (let i = 0; i < boxes.length; i++) {
162 | const box = boxes[i];
163 | inertia(box);
164 | }
165 |
166 | render(boxes, ctx);
167 | if (!running) return;
168 | window.requestAnimationFrame(step);
169 | })();
170 |
171 | function render(boxes: CollidableBox[], ctx: CanvasRenderingContext2D) {
172 | ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
173 | for (let i = 0; i < boxes.length; i++) {
174 | const box = boxes[i];
175 |
176 | ctx.fillStyle = "red";
177 | ctx.fillRect(
178 | box.ppos.x - box.width / 2,
179 | box.ppos.y - box.height / 2,
180 | box.width,
181 | box.height
182 | );
183 |
184 | ctx.fillStyle = "black";
185 | ctx.fillRect(
186 | box.cpos.x - box.width / 2,
187 | box.cpos.y - box.height / 2,
188 | box.width,
189 | box.height
190 | );
191 | }
192 | }
193 | };
194 |
--------------------------------------------------------------------------------
/examples/aabb-soup.ts:
--------------------------------------------------------------------------------
1 | import scihalt from "science-halt";
2 | import {
3 | accelerate,
4 | add,
5 | collisionResponseAABB,
6 | createAABBOverlapResult,
7 | distance,
8 | inertia,
9 | overlapAABBAABB,
10 | scale,
11 | solveGravitation,
12 | sub,
13 | v2,
14 | } from "../src/index";
15 |
16 | export const start = () => {
17 | const cvs = document.createElement("canvas");
18 | const ctx = cvs.getContext("2d")!;
19 | cvs.width = cvs.height = 800;
20 | cvs.style.border = "1px solid gray";
21 | document.body.appendChild(cvs);
22 |
23 | type Box = ReturnType;
24 |
25 | const points: Box[] = [];
26 |
27 | for (let count = 25, i = 0; i < count; i++) {
28 | const centerX = cvs.width / 2;
29 | const centerY = cvs.height / 2;
30 | const distance = Math.min(centerX, centerY) * 0.5;
31 | const cos = Math.cos(i);
32 | const sin = Math.sin(i);
33 | const x = centerX + cos * distance;
34 | const y = centerY + sin * distance;
35 | points.push(makeBox(x, y));
36 | }
37 |
38 | const GRAVITATIONAL_POINT = {
39 | cpos: v2(cvs.width / 2, cvs.height / 2),
40 | ppos: v2(cvs.width / 2, cvs.height / 2),
41 | acel: v2(),
42 | mass: 100000
43 | };
44 |
45 | let running = true;
46 | scihalt(() => (running = false));
47 |
48 | (function step() {
49 | for (let i = 0; i < points.length; i++) {
50 | const point = points[i];
51 | const dist = distance(point.cpos, GRAVITATIONAL_POINT.cpos);
52 | dist > 100 &&
53 | solveGravitation(
54 | point,
55 | point.mass,
56 | GRAVITATIONAL_POINT,
57 | GRAVITATIONAL_POINT.mass
58 | );
59 | }
60 |
61 | const dt = 1;
62 | for (let i = 0; i < points.length; i++) {
63 | const point = points[i];
64 | accelerate(point, dt);
65 | }
66 |
67 | const collisions = [];
68 | const handled = [];
69 | const collision = createAABBOverlapResult();
70 |
71 | for (let i = 0; i < points.length; i++) {
72 | for (let j = i + 1; j < points.length; j++) {
73 | const box1 = points[i];
74 | const box2 = points[j];
75 | const isOverlapping = overlapAABBAABB(
76 | box1.cpos.x,
77 | box1.cpos.y,
78 | box1.w,
79 | box1.h,
80 | box2.cpos.x,
81 | box2.cpos.y,
82 | box2.w,
83 | box2.h,
84 | collision
85 | );
86 |
87 | if (
88 | isOverlapping &&
89 | handled.indexOf(box1.id + "," + box2.id) === -1 &&
90 | handled.indexOf(box2.id + "," + box1.id) === -1
91 | ) {
92 | // move to non-overlapping position
93 | const overlapHalf = scale(v2(), collision.resolve, 0.5);
94 | add(box2.cpos, box2.cpos, overlapHalf);
95 | add(box2.ppos, box2.ppos, overlapHalf);
96 | sub(box1.cpos, box1.cpos, overlapHalf);
97 | sub(box1.ppos, box1.ppos, overlapHalf);
98 |
99 | // for debugging
100 | render(points, ctx);
101 |
102 | const box1v = v2();
103 | const box2v = v2();
104 |
105 | const restitution = 1;
106 | const staticFriction = 0.9;
107 | const dynamicFriction = 0.01;
108 |
109 | collisionResponseAABB(
110 | box1.cpos,
111 | box1.ppos,
112 | box1.mass,
113 | restitution,
114 | staticFriction,
115 | dynamicFriction,
116 | box2.cpos,
117 | box2.ppos,
118 | box2.mass,
119 | restitution,
120 | staticFriction,
121 | dynamicFriction,
122 | // Allow the response function to recompute a normal based on the
123 | // axis between the centers of the boxes. this produces a more
124 | // natural looking collision.
125 | // collision.normal,
126 | v2(),
127 | box1v,
128 | box2v
129 | );
130 |
131 | // Apply the new velocity
132 | sub(box1.ppos, box1.cpos, box1v);
133 | sub(box2.ppos, box2.cpos, box2v);
134 |
135 | // for debugging
136 | render(points, ctx);
137 |
138 | handled.push(box1.id + "," + box2.id);
139 | handled.push(box2.id + "," + box1.id);
140 | }
141 | }
142 | }
143 |
144 | for (let i = 0; i < points.length; i++) {
145 | const point = points[i];
146 | inertia(point);
147 | }
148 |
149 | render(points, ctx);
150 | if (!running) return;
151 | window.requestAnimationFrame(step);
152 | })();
153 |
154 | function makeBox(x: number, y: number) {
155 | return {
156 | id: "id-" + Math.floor(Math.random() * 10000000),
157 | cpos: v2(x, y),
158 | ppos: v2(x, y),
159 | acel: v2(),
160 | mass: 10,
161 | w: 10,
162 | h: 10
163 | };
164 | }
165 |
166 | function render(points: Box[], ctx: CanvasRenderingContext2D) {
167 | ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
168 | for (let i = 0; i < points.length; i++) {
169 | const point = points[i];
170 |
171 | ctx.fillStyle = "red";
172 | ctx.fillRect(
173 | point.ppos.x - point.w / 2,
174 | point.ppos.y - point.h / 2,
175 | point.w,
176 | point.h
177 | );
178 |
179 | ctx.fillStyle = "black";
180 | ctx.fillRect(
181 | point.cpos.x - point.w / 2,
182 | point.cpos.y - point.h / 2,
183 | point.w,
184 | point.h
185 | );
186 | }
187 | }
188 | };
189 |
--------------------------------------------------------------------------------
/examples/edge-collision-aabb.ts:
--------------------------------------------------------------------------------
1 | import scihalt from "science-halt";
2 | import {
3 | Vector2,
4 | v2,
5 | accelerate,
6 | inertia,
7 | add,
8 | solveDistanceConstraint,
9 | collisionResponseAABB,
10 | createPointEdgeProjectionResult,
11 | projectPointEdge,
12 | sub,
13 | segmentIntersection,
14 | projectCposWithRadius,
15 | translate,
16 | } from "../src";
17 |
18 | export const start = () => {
19 | const cvs = document.createElement("canvas");
20 | const ctx = cvs.getContext("2d")!;
21 | cvs.tabIndex = 1; // for keyboard events
22 | cvs.width = cvs.height = 800;
23 | cvs.style.border = "1px solid gray";
24 | document.body.appendChild(cvs);
25 |
26 | type CollidableLine = {
27 | point1: CollidableCircle;
28 | point2: CollidableCircle;
29 | goal: number;
30 | };
31 |
32 | type CollidableCircle = {
33 | cpos: Vector2;
34 | ppos: Vector2;
35 | acel: Vector2;
36 | mass: number;
37 | radius: number;
38 | };
39 |
40 | const player: CollidableCircle = {
41 | cpos: v2(600, 100),
42 | ppos: v2(600, 100),
43 | acel: v2(0, 0),
44 | mass: 1,
45 | radius: 20,
46 | };
47 |
48 | const GRAVITY = 0.98;
49 |
50 | // Use similar masses because even if the platform is immobile, a huge mass
51 | // will impart nearly all velocity into the ball, and make restitution and
52 | // friction nearly meaningless.
53 | const platform: CollidableLine = {
54 | point1: {
55 | cpos: v2(100, 300),
56 | ppos: v2(100, 300),
57 | acel: v2(0, 0),
58 | mass: 1,
59 | radius: 0,
60 | },
61 | point2: {
62 | cpos: v2(700, 300),
63 | ppos: v2(700, 300),
64 | acel: v2(0, 0),
65 | mass: 1,
66 | radius: 0,
67 | },
68 | goal: 500,
69 | };
70 |
71 | const circles: CollidableCircle[] = [
72 | player,
73 | platform.point1,
74 | platform.point2,
75 | ];
76 |
77 | let running = true;
78 | scihalt(() => (running = false));
79 |
80 | (function step() {
81 | const dt = 16;
82 |
83 | // gravity!
84 | add(player.acel, player.acel, v2(0, GRAVITY));
85 |
86 | for (let i = 0; i < circles.length; i++) {
87 | const box = circles[i];
88 | accelerate(box, dt);
89 | }
90 |
91 | // Use the ppos to account for tunneling: if the ppos->cpos vector is
92 | // intersecting with the edge, and ppos is still <0, that means the circle
93 | // has collided or even tunneled. But if the similarity is in the opposite
94 | // direction, that could mean this circle has already collided this frame.
95 | const projectedResult = createPointEdgeProjectionResult();
96 | projectPointEdge(
97 | player.ppos,
98 | platform.point1.cpos,
99 | platform.point2.cpos,
100 | projectedResult
101 | );
102 |
103 | // Project the cpos using the radius just for the sake of doing a
104 | // line-intersection. This can be a problem in environments with more than
105 | // one collision happening per frame.
106 | const intersectionPoint = v2();
107 | const cposCapsule = projectCposWithRadius(v2(), player, player.radius);
108 | const intersected = segmentIntersection(player.ppos, cposCapsule, platform.point1.cpos, platform.point2.cpos, intersectionPoint);
109 |
110 | if (intersected && projectedResult.similarity < 0) {
111 | // Do our best to prevent tunneling: rewind by the distance from the cpos
112 | // capsule to the segment intersection point. `Translate` adds, so we have
113 | // to sub intersectionPoint - capsule, which is slightly counterintuitive.
114 | const offset = v2();
115 | sub(offset,intersectionPoint, cposCapsule);
116 | translate(offset, player.cpos, player.ppos);
117 |
118 | const vout1 = v2();
119 | const vout2 = v2();
120 |
121 | // 1: nearly perfectly elastic (we still lose some energy due to gravity)
122 | // 0: dead
123 | const restitution = 1;
124 |
125 | collisionResponseAABB(
126 | player.cpos,
127 | player.ppos,
128 | player.mass,
129 | restitution,
130 | 0.9,
131 | 0.1,
132 | projectedResult.projectedPoint,
133 | projectedResult.projectedPoint,
134 | (platform.point1.mass + platform.point2.mass) * projectedResult.u,
135 | restitution,
136 | 0.9,
137 | 0.1,
138 | projectedResult.edgeNormal,
139 | vout1,
140 | vout2
141 | );
142 |
143 | sub(player.ppos, player.cpos, vout1);
144 | // preserve systemic energy by giving the velocity that would have been
145 | // imparted to the edge (if it were moveable) to the ball instead.
146 | add(player.ppos, player.ppos, vout2);
147 | }
148 |
149 | for (let i = 0; i < circles.length; i++) {
150 | const box = circles[i];
151 | inertia(box);
152 | }
153 |
154 | for (let i = 0; i < 5; i++) {
155 | // Not really necessary since we're never imparting velocity to the
156 | // platform, but could be useful to demonstrate how to keep the shape if
157 | // we did.
158 | solveDistanceConstraint(
159 | platform.point1,
160 | platform.point1.mass,
161 | platform.point2,
162 | platform.point2.mass,
163 | platform.goal
164 | );
165 | }
166 |
167 | render(circles, [platform], ctx);
168 | if (!running) return;
169 | window.requestAnimationFrame(step);
170 | })();
171 |
172 | function render(
173 | circles: CollidableCircle[],
174 | segments: CollidableLine[],
175 | ctx: CanvasRenderingContext2D
176 | ) {
177 | ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
178 | for (let i = 0; i < circles.length; i++) {
179 | const point = circles[i];
180 |
181 | ctx.fillStyle = "red";
182 | ctx.beginPath();
183 | ctx.arc(point.ppos.x, point.ppos.y, point.radius, 0, Math.PI * 2, false);
184 | ctx.fill();
185 |
186 | ctx.fillStyle = "black";
187 | ctx.beginPath();
188 | ctx.arc(point.cpos.x, point.cpos.y, point.radius, 0, Math.PI * 2, false);
189 | ctx.fill();
190 | }
191 |
192 | for (let i = 0; i < segments.length; i++) {
193 | const segment = segments[i];
194 | ctx.strokeStyle = "red";
195 | ctx.beginPath();
196 | ctx.moveTo(segment.point1.ppos.x, segment.point1.ppos.y);
197 | ctx.lineTo(segment.point2.ppos.x, segment.point2.ppos.y);
198 | ctx.stroke();
199 | ctx.beginPath();
200 | ctx.strokeStyle = "black";
201 | ctx.moveTo(segment.point1.cpos.x, segment.point1.cpos.y);
202 | ctx.lineTo(segment.point2.cpos.x, segment.point2.cpos.y);
203 | ctx.stroke();
204 | }
205 | }
206 | };
207 |
--------------------------------------------------------------------------------
/examples/circle-box-collision.ts:
--------------------------------------------------------------------------------
1 | import scihalt from "science-halt";
2 | import {
3 | add,
4 | copy,
5 | normalize,
6 | scale,
7 | sub,
8 | v2,
9 | accelerate,
10 | inertia,
11 | solveGravitation,
12 | overlapCircleCircle,
13 | collideCircleCircle,
14 | collideCircleEdge,
15 | solveDistanceConstraint,
16 | rewindToCollisionPoint,
17 | Vector2,
18 | VelocityDerivable,
19 | distance,
20 | Integratable
21 | } from "../src/index";
22 |
23 | export const start = () => {
24 | const cvs = document.createElement("canvas");
25 | const ctx = cvs.getContext("2d")!;
26 | cvs.width = cvs.height = 800;
27 | cvs.style.border = "1px solid gray";
28 | document.body.appendChild(cvs);
29 |
30 | type CollidableLine = {
31 | point1: CollidableCircle;
32 | point2: CollidableCircle;
33 | };
34 |
35 | type CollidableCircle = {
36 | mass: number;
37 | radius: number;
38 | } & Integratable;
39 |
40 | type DistanceConstraint = {
41 | point1: Exclude;
42 | point2: Exclude;
43 | goal: number;
44 | };
45 |
46 | const CONSTRAINT_ITERATIONS = 2;
47 |
48 | const circles: CollidableCircle[] = [];
49 | const lines: CollidableLine[] = [];
50 | const constraints: DistanceConstraint[] = [];
51 |
52 | const box = makeBox(300, 200, 100, 200);
53 | circles.push(...box.circles);
54 | lines.push(...box.lines);
55 | constraints.push(...box.constraints);
56 |
57 | circles.push({
58 | cpos: v2(500, 390),
59 | ppos: v2(510, 390),
60 | acel: v2(0, 0),
61 | mass: 100,
62 | radius: 10
63 | });
64 |
65 | let running = true;
66 | scihalt(() => (running = false));
67 |
68 | (function step() {
69 | const dt = 16;
70 |
71 | for (let i = 0; i < circles.length; i++) {
72 | const circle = circles[i];
73 | accelerate(circle, dt);
74 | }
75 |
76 | for (let i = 0; i < CONSTRAINT_ITERATIONS; i++) {
77 | for (let j = 0; j < constraints.length; j++) {
78 | const constraint = constraints[j];
79 | solveDistanceConstraint(
80 | constraint.point1,
81 | constraint.point1.mass,
82 | constraint.point2,
83 | constraint.point2.mass,
84 | constraint.goal
85 | );
86 | }
87 | }
88 |
89 | collideCircles(circles, false);
90 | collideEdges(lines, circles, false);
91 |
92 | for (let i = 0; i < circles.length; i++) {
93 | const circle = circles[i];
94 | inertia(circle);
95 | }
96 |
97 | collideCircles(circles, true);
98 | collideEdges(lines, circles, true);
99 |
100 | render(circles, lines, ctx);
101 | if (!running) return;
102 | window.requestAnimationFrame(step);
103 | })();
104 |
105 | function makeBox(x: number, y: number, width: number, height: number) {
106 | const lines: CollidableLine[] = [];
107 | const circles: CollidableCircle[] = [];
108 | const constraints: DistanceConstraint[] = [];
109 | const points = [
110 | v2(x, y),
111 | v2(x + width, y),
112 | v2(x + width, y + height),
113 | v2(x, y + height)
114 | ];
115 | for (let i = 0; i < points.length; i++) {
116 | const point = points[i];
117 | const prev = circles.length === 0 ? null : circles[i - 1];
118 | const circle = {
119 | cpos: copy(v2(), point),
120 | ppos: copy(v2(), point),
121 | acel: v2(0, 0),
122 | mass: i === 0 ? -1 : 1,
123 | radius: 1
124 | };
125 | if (prev) {
126 | lines.push({
127 | point1: prev,
128 | point2: circle
129 | });
130 |
131 | constraints.push({
132 | point1: prev,
133 | point2: circle,
134 | goal: distance(point, prev.cpos)
135 | });
136 | }
137 | circles.push(circle);
138 | }
139 |
140 | lines.push({
141 | point1: circles[circles.length - 1],
142 | point2: circles[0]
143 | });
144 |
145 | constraints.push({
146 | point1: circles[circles.length - 1],
147 | point2: circles[0],
148 | goal: distance(circles[circles.length - 1].cpos, circles[0].cpos)
149 | });
150 |
151 | constraints.push({
152 | point1: circles[0],
153 | point2: circles[2],
154 | goal: distance(circles[0].cpos, circles[2].cpos)
155 | });
156 |
157 | return {
158 | lines,
159 | circles,
160 | constraints
161 | };
162 | }
163 |
164 | function collideCircles(
165 | circles: CollidableCircle[],
166 | preserveInertia: boolean,
167 | damping = 0.9
168 | ) {
169 | for (let i = 0; i < circles.length; i++) {
170 | const a = circles[i];
171 | for (let j = i + 1; j < circles.length; j++) {
172 | const b = circles[j];
173 | if (
174 | !overlapCircleCircle(
175 | a.cpos.x,
176 | a.cpos.y,
177 | a.radius,
178 | b.cpos.x,
179 | b.cpos.y,
180 | b.radius
181 | )
182 | )
183 | continue;
184 | collideCircleCircle(
185 | a,
186 | a.radius,
187 | a.mass,
188 | b,
189 | b.radius,
190 | b.mass,
191 | preserveInertia,
192 | damping
193 | );
194 | }
195 | }
196 | }
197 |
198 | function collideEdges(
199 | lines: CollidableLine[],
200 | circles: CollidableCircle[],
201 | preserveInertia: boolean,
202 | damping = 0.9
203 | ) {
204 | for (let i = 0; i < lines.length; i++) {
205 | const line = lines[i];
206 | for (let j = 0; j < circles.length; j++) {
207 | const circle = circles[j];
208 | // Don't collide with yourself! This would be very very bad.
209 | if (line.point1 == circle || line.point2 === circle) continue;
210 | if (!preserveInertia)
211 | rewindToCollisionPoint(
212 | circle,
213 | circle.radius,
214 | line.point1.cpos,
215 | line.point2.cpos
216 | );
217 | collideCircleEdge(
218 | circle,
219 | circle.radius,
220 | circle.mass,
221 | line.point1,
222 | line.point1.mass,
223 | line.point2,
224 | line.point2.mass,
225 | preserveInertia,
226 | damping
227 | );
228 | }
229 | }
230 | }
231 |
232 | function render(
233 | circles: CollidableCircle[],
234 | segments: CollidableLine[],
235 | ctx: CanvasRenderingContext2D
236 | ) {
237 | ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
238 | for (let i = 0; i < circles.length; i++) {
239 | const point = circles[i];
240 |
241 | ctx.fillStyle = "red";
242 | ctx.beginPath();
243 | ctx.arc(point.ppos.x, point.ppos.y, point.radius, 0, Math.PI * 2, false);
244 | ctx.fill();
245 |
246 | ctx.fillStyle = "black";
247 | ctx.beginPath();
248 | ctx.arc(point.cpos.x, point.cpos.y, point.radius, 0, Math.PI * 2, false);
249 | ctx.fill();
250 | }
251 |
252 | for (let i = 0; i < segments.length; i++) {
253 | const segment = segments[i];
254 | ctx.strokeStyle = "red";
255 | ctx.beginPath();
256 | ctx.moveTo(segment.point1.ppos.x, segment.point1.ppos.y);
257 | ctx.lineTo(segment.point2.ppos.x, segment.point2.ppos.y);
258 | ctx.stroke();
259 | ctx.beginPath();
260 | ctx.strokeStyle = "black";
261 | ctx.moveTo(segment.point1.cpos.x, segment.point1.cpos.y);
262 | ctx.lineTo(segment.point2.cpos.x, segment.point2.cpos.y);
263 | ctx.stroke();
264 | }
265 | }
266 | };
267 |
--------------------------------------------------------------------------------
/src/collision-response-aabb.ts:
--------------------------------------------------------------------------------
1 | import {
2 | add,
3 | dot,
4 | magnitude,
5 | normalize,
6 | scale,
7 | set,
8 | copy,
9 | sub,
10 | v2,
11 | Vector2
12 | } from "./v2";
13 |
14 | // Registers / Preallocations
15 |
16 | const basis = v2();
17 | const basisNeg = v2();
18 |
19 | const vel1 = v2();
20 | const vel1x = v2();
21 | const vel1y = v2();
22 |
23 | const vel2 = v2();
24 | const vel2x = v2();
25 | const vel2y = v2();
26 |
27 | const newVel1 = v2();
28 | const newVel2 = v2();
29 |
30 | const t1 = v2();
31 | const t2 = v2();
32 |
33 | const u1 = v2();
34 | const u2 = v2();
35 |
36 | // TODO: Put this somewhere...
37 | const EPSILON = 0.0001;
38 |
39 | // TODO: change this API to accept numbers (x, y) instead of vectors
40 |
41 | // friction calc: sqrt(friction1*friction2)
42 | // restitution: box2d: https://github.com/erincatto/Box2D/blob/6a69ddbbd59b21c0d6699c43143b4114f7f92e21/Box2D/Box2D/Dynamics/Contacts/b2Contact.h#L42-L47
43 | // Math.max(restitution1, restitution2);
44 |
45 | /**
46 | * Really should be called collisionResponseImpulse, as it has nothing to do
47 | * with the shape of the bodies colliding. It's just two points with mass and
48 | * friction.
49 | * @param cpos1
50 | * @param ppos1
51 | * @param mass1
52 | * @param restitution1 1 == perfectly elastic collision, 0 == all energy is
53 | * killed.
54 | * @param staticFriction1 How much friction must be overcome before the object
55 | * will start moving. 0 == no friction, 1 == max friction. Set this higher, like
56 | * 0.9.
57 | * @param dynamicFriction1 How much constant friction occurs when the object is
58 | * already in motion. 0 == no friction, 1 == max friction. Better to set this
59 | * low, like 0.1.
60 | * @param cpos2
61 | * @param ppos2
62 | * @param mass2
63 | * @param restitution2 1 == perfectly elastic collision, 0 == all energy is
64 | * killed.
65 | * @param staticFriction2 How much friction must be overcome before the object
66 | * will start moving. 0 == no friction, 1 == max friction. Set this higher, like
67 | * 0.9.
68 | * @param dynamicFriction2 How much constant friction occurs when the object is
69 | * already in motion. 0 == no friction, 1 == max friction. Better to set this
70 | * low, like 0.1.
71 | * @param collisionNormal The vector defining the relative axis of collision.
72 | * Leaving this as 0,0 will compute it as the midpoint between positions of the
73 | * two colliding objects (modeling a circular collision). If colliding with a
74 | * known edge or line segment, it's best to provide the edge normal as this
75 | * value.
76 | * @param vel1out The new velocity resulting from reacting to this collison.
77 | * cpos1 - this value == new ppos1.
78 | * @param vel2out The new velocity resulting from reacting to this collison.
79 | * cpos2 - this value == new ppos2.
80 | */
81 | export const collisionResponseAABB = (
82 | cpos1: Vector2,
83 | ppos1: Vector2,
84 | mass1: number,
85 | restitution1: number,
86 | staticFriction1: number,
87 | dynamicFriction1: number,
88 | cpos2: Vector2,
89 | ppos2: Vector2,
90 | mass2: number,
91 | restitution2: number,
92 | staticFriction2: number,
93 | dynamicFriction2: number,
94 | collisionNormal: Vector2,
95 | vel1out: Vector2,
96 | vel2out: Vector2
97 | ) => {
98 | // blank out all preallocated vectors.
99 | set(basis, 0, 0);
100 | set(basisNeg, 0, 0);
101 | set(vel1, 0, 0);
102 | set(vel1x, 0, 0);
103 | set(vel1y, 0, 0);
104 | set(vel2, 0, 0);
105 | set(vel2x, 0, 0);
106 | set(vel2y, 0, 0);
107 | set(newVel1, 0, 0);
108 | set(newVel2, 0, 0);
109 | set(t1, 0, 0);
110 | set(t2, 0, 0);
111 | set(u1, 0, 0);
112 | set(u2, 0, 0);
113 |
114 | // If collisionNormal is provided, use it. Otherwise, use midpoint between
115 | // current positions as axis of collision. Midpoint will model a circular
116 | // collision if used.
117 | if (collisionNormal && (collisionNormal.x !== 0 || collisionNormal.y !== 0)) {
118 | set(basis, collisionNormal.x, collisionNormal.y);
119 | } else {
120 | sub(basis, cpos1, cpos2);
121 | normalize(basis, basis);
122 | }
123 |
124 | scale(basisNeg, basis, -1);
125 |
126 | //const friction;
127 | // Take max of restitutions, like box2d does.
128 | // https://github.com/erincatto/Box2D/blob/6a69ddbbd59b21c0d6699c43143b4114f7f92e21/Box2D/Box2D/Dynamics/Contacts/b2Contact.h#L42-L47
129 | // "for example, a superball bounces on everything"
130 | const restitution = restitution1 > restitution2 ? restitution1 : restitution2;
131 | const massTotal = mass1 + mass2;
132 | const e = 1 + restitution;
133 |
134 | // I = (1+e)*N*(Vr • N) / (1/Ma + 1/Mb)
135 | // Va -= I * 1/Ma
136 | // Vb += I * 1/Mb
137 | //sub(vel1, cpos1, ppos1);
138 | //sub(vel2, cpos2, ppos2);
139 | //const relativeVelocity = sub(vel1, vel2);
140 | //const I = v2();
141 | //scale(I, normal, (1 + restitution) * dot(relativeVelocity, normal));
142 | //scale(I, I, 1 / (1/mass1 + 1/mass2));
143 |
144 | // "x" and "y" in the following sections are shorthand for:
145 | // x: component of the box velocity parallel to the collision normal
146 | // y: the rest of the collision velocity
147 |
148 | // calculate x-direction velocity vector and perpendicular y-vector for box 1
149 | sub(vel1, cpos1, ppos1);
150 | const x1 = dot(basis, vel1);
151 | scale(vel1x, basis, x1);
152 | sub(vel1y, vel1, vel1x);
153 |
154 | // calculate x-direction velocity vector and perpendicular y-vector for box 2
155 | sub(vel2, cpos2, ppos2);
156 | const x2 = dot(basisNeg, vel2);
157 | scale(vel2x, basisNeg, x2);
158 | sub(vel2y, vel2, vel2x);
159 |
160 | // equations of motion for box1
161 | scale(t1, vel1x, (mass1 - mass2) / massTotal);
162 | scale(t2, vel2x, (e * mass2) / massTotal);
163 | add(newVel1, t1, t2);
164 | add(newVel1, newVel1, vel1y);
165 |
166 | // equations of motion for box2
167 | scale(u1, vel1x, (e * mass1) / massTotal);
168 | scale(u2, vel2x, (mass2 - mass1) / massTotal);
169 | add(newVel2, u1, u2);
170 | add(newVel2, newVel2, vel2y);
171 |
172 | // new relative velocity
173 | const rv = add(v2(), newVel1, newVel2);
174 |
175 | // tangent to relative velocity vector
176 | const reg1 = v2();
177 | scale(reg1, basis, dot(rv, basis));
178 | const tangent = sub(v2(), rv, reg1);
179 | normalize(tangent, tangent);
180 |
181 | // magnitude of relative velocity in tangent direction
182 | let jt = -dot(rv, tangent);
183 | jt /= 1 / (mass1 + mass2); // not sure about this...
184 | // https://github.com/RandyGaul/ImpulseEngine/blob/d12af9c95555244a37dce1c7a73e60d5177df652/Manifold.cpp#L103
185 |
186 | const jtMag = Math.abs(jt);
187 |
188 | // only apply significant friction
189 | if (jtMag > EPSILON) {
190 | // magnitudes of velocity along the collision tangent, hopefully.
191 | const vel1ymag = magnitude(vel1y);
192 | const vel2ymag = magnitude(vel2y);
193 |
194 | // compute Coulumb's law (choosing dynamic vs static friction)
195 | const frictionImpulse1 = v2();
196 | const frictionImpulse2 = v2();
197 |
198 | // TODO: may need to use Math.max(Math.abs(vel1ymag, vel2ymag)) when
199 | // choosing to incorporate velocity magnitude into the friction calc.
200 | // A stationary box getting hit currently receives perfect energy
201 | // transfer, since its vel2ymag is 0.
202 |
203 | if (jtMag < vel1ymag * staticFriction1) {
204 | scale(frictionImpulse1, tangent, staticFriction1);
205 | } else {
206 | scale(frictionImpulse1, tangent, -vel1ymag * dynamicFriction1);
207 | }
208 |
209 | if (jtMag < vel2ymag * staticFriction2) {
210 | scale(frictionImpulse2, tangent, staticFriction2);
211 | } else {
212 | scale(frictionImpulse2, tangent, -vel2ymag * dynamicFriction2);
213 | }
214 |
215 | add(newVel1, newVel1, frictionImpulse1);
216 | add(newVel2, newVel2, frictionImpulse2);
217 | }
218 |
219 | // output new velocity of box1 and box2
220 | copy(vel1out, newVel1);
221 | copy(vel2out, newVel2);
222 | };
223 |
--------------------------------------------------------------------------------
/examples/simplified.ts:
--------------------------------------------------------------------------------
1 | // import { add, sub, v2, scale } from "../src";
2 |
3 | // // need to have:
4 | // // verlet accelerate
5 | // // collision detection manifold aka overlap (type, normal, penetration depth, bodies(mass, friction))
6 | // // circle vs circle
7 | // // circle vs edge (which is actually a circle/point)
8 | // // circle vs aabb? it's just an edge, really
9 | // // collision response given a manifold, including friction
10 | // // verlet inertia
11 | // // static edges (aka cannot pass through?) vs dynamic body edges
12 |
13 | // // ideal step:
14 | // // know what is colliding/overlapping <--- very important for a game-like thing
15 | // // decide if it should be handled in engine or just destroy (for example)
16 | // // ECS friendly
17 |
18 | // type Vector2 = {
19 | // x: 0;
20 | // y: 0;
21 | // };
22 |
23 | // type VelocityDerivable = {
24 | // cpos: Vector2;
25 | // ppos: Vector2;
26 | // };
27 |
28 | // type Integratable = {
29 | // acel: Vector2;
30 | // } & VelocityDerivable;
31 |
32 | // type Particle = {
33 | // mass: number;
34 | // radius: number;
35 | // } & Integratable;
36 |
37 | // type ParticleParticleContact = {
38 | // kind: "p-p";
39 | // p1: Particle;
40 | // p2: Particle;
41 | // point: Vector2; // point of collision
42 | // depth: number;
43 | // normal: Vector2;
44 | // };
45 |
46 | // type ParticleDynamicEdgeContact = {
47 | // kind: "p-de";
48 | // p1: Particle;
49 | // p2: DynamicEdge;
50 | // edgeDirection: Vector2;
51 | // projection: number; // aka t, or how far along the edge the collision occurred
52 | // depth: number;
53 | // normal: Vector2;
54 | // };
55 |
56 | // type ParticleStaticEdgeContact = {
57 | // kind: "p-se";
58 | // p1: Particle;
59 | // p2: StaticEdge;
60 | // edgeDirection: Vector2;
61 | // projection: number; // aka t, or how far along the edge the collision occurred
62 | // depth: number;
63 | // normal: Vector2;
64 | // };
65 |
66 | // type Contact =
67 | // | ParticleParticleContact
68 | // | ParticleDynamicEdgeContact
69 | // | ParticleStaticEdgeContact;
70 |
71 | // type ParticleParticleCorrection = {
72 | // kind: 'p-p';
73 | // overlap: ParticleParticleContact;
74 | // // These are the offsets required to put p1/p2 into non-conflicting positions.
75 | // p1: VelocityDerivable;
76 | // p2: VelocityDerivable;
77 | // };
78 |
79 | // type ParticleDynamicEdgeCorrection = {
80 | // kind: 'p-de';
81 | // overlap: ParticleDynamicEdgeContact;
82 | // // offset for the particle
83 | // p: VelocityDerivable;
84 | // // offsets for the endpoints
85 | // e1: VelocityDerivable;
86 | // e2: VelocityDerivable;
87 | // };
88 |
89 | // type ParticleStaticEdgeCorrection = {
90 | // kind: 'p-se';
91 | // overlap: ParticleStaticEdgeContact;
92 | // // offset for the particle
93 | // p: VelocityDerivable;
94 | // };
95 |
96 | // type Correction =
97 | // | ParticleParticleCorrection
98 | // | ParticleDynamicEdgeCorrection
99 | // | ParticleStaticEdgeCorrection;
100 |
101 |
102 | // type DistanceEqualityConstraint = {
103 | // kind: 'd-e';
104 | // p1: Particle;
105 | // p2: Particle;
106 | // goal: number;
107 | // };
108 |
109 | // type Constraint = DistanceEqualityConstraint;
110 |
111 | // type DistanceEqualityConstraintResolver = {
112 | // kind: 'd-e';
113 | // constraint: DistanceEqualityConstraint;
114 | // p1: Vector2;
115 | // p2: Vector2;
116 | // }
117 |
118 | // type ConstraintResolver = DistanceEqualityConstraintResolver;
119 |
120 | // type DynamicEdge = {
121 | // p1: Particle;
122 | // p2: Particle;
123 | // };
124 |
125 | // // Not sure if these should be points or have mass, since it's basically the same as a Dynamic Edge
126 | // type StaticEdge = {
127 | // p1: Vector2;
128 | // p2: Vector2;
129 | // };
130 |
131 | // function accelerate(p1: Particle, dt: number) {}
132 |
133 | // function inertia(p1: Particle) {}
134 |
135 | // function tick(
136 | // dt: number,
137 | // particles: Particle[],
138 | // constraints: DistanceEqualityConstraint[],
139 | // dynamicEdges: DynamicEdge[],
140 | // staticEdges: StaticEdge[],
141 | // collisionIterationCount: number
142 | // ) {
143 |
144 | // for (let i = 0; i < particles.length; i++) {
145 | // accelerate(particles[i], dt);
146 | // }
147 |
148 | // for (let i = 0; i < collisionIterationCount; i++) {
149 | // const m1 = findParticleParticleContacts([], particles);
150 | // const m2 = findParticleDynamicEdgeContacts(
151 | // [],
152 | // particles,
153 | // dynamicEdges
154 | // );
155 | // for (let j = 0; j < m1.length; j++) {
156 | // const manifold = m1[j];
157 | // const resolved = resolveContact(manifold);
158 | // applyResolvedManifold(resolved);
159 | // }
160 | // for (let j = 0; j < m2.length; j++) {
161 | // const manifold = m2[j];
162 | // const resolved = resolveContact(manifold);
163 | // applyResolvedManifold(resolved);
164 | // }
165 | // }
166 |
167 | // for (let i = 0; i < constraints.length; i++) {
168 | // const constraint = constraints[i];
169 | // const resolved = resolveConstraint(constraint);
170 | // applyResolvedConstraint(resolved);
171 | // }
172 |
173 | // for (let i = 0; i < particles.length; i++) {
174 | // inertia(particles[i]);
175 | // }
176 |
177 | // const m3 = findParticleStaticEdgeContacts([], particles, staticEdges);
178 | // for (let j = 0; j < m3.length; j++) {
179 | // const manifold = m3[j];
180 | // const resolved = resolveContact(manifold);
181 | // applyResolvedManifold(resolved);
182 | // }
183 | // }
184 |
185 | // function findParticleParticleContacts(
186 | // manifoldsOut: Contact[],
187 | // particles: Particle[]
188 | // ): ParticleParticleContact[] {
189 | // for (let i = 0; i < particles.length; i++) {
190 | // const p1 = particles[i];
191 | // for (let j = i + 1; j < particles.length; j++) {
192 | // const p2 = particles[j];
193 |
194 | // const x = p2.cpos.x - p1.cpos.x;
195 | // const y = p2.cpos.y - p1.cpos.y;
196 | // const rad = p1.radius + p2.radius;
197 | // const overlapping = x * x + y * y < rad * rad;
198 |
199 | // if (!overlapping) continue;
200 |
201 | // // manifoldsOut.push({
202 | // // p1, p2,
203 | // // });
204 | // }
205 | // }
206 | // }
207 |
208 | // function findParticleDynamicEdgeContacts(
209 | // manifoldsOut: Contact[],
210 | // particle: Particle[],
211 | // edges: DynamicEdge[]
212 | // ): ParticleDynamicEdgeContact[] { return manifoldsOut }
213 |
214 | // function findParticleStaticEdgeContacts(
215 | // manifoldsOut: Contact[],
216 | // particle: Particle[],
217 | // edges: StaticEdge[]
218 | // ): ParticleStaticEdgeContact[] {}
219 |
220 | // function resolveConstraint(constraint: Constraint): ConstraintResolver {
221 | // if (constraint.kind === 'd-e') {
222 |
223 | // }
224 | // }
225 |
226 | // function applyResolvedConstraint(manifold: ConstraintResolver) {
227 | // if (manifold.kind === 'd-e') {
228 |
229 | // }
230 | // }
231 |
232 | // function resolveContact(manifold: Contact): Correction {
233 | // if (manifold.kind === "p-p") {
234 | // // particle-particle
235 | // } else if (manifold.kind === "p-de") {
236 | // // particle-dynamic-edge
237 | // } else if (manifold.kind === "p-se") {
238 | // // particle-static-edge
239 | // }
240 | // }
241 |
242 | // function applyResolvedManifold(
243 | // resolved: Correction
244 | // ) {
245 | // if (resolved.kind === "p-p") {
246 | // // particle-particle
247 | // add(resolved.overlap.p1.cpos, resolved.overlap.p1.cpos, resolved.p1.cpos);
248 | // add(resolved.overlap.p1.ppos, resolved.overlap.p1.ppos, resolved.p1.ppos);
249 | // add(resolved.overlap.p2.cpos, resolved.overlap.p2.cpos, resolved.p2.cpos);
250 | // add(resolved.overlap.p2.ppos, resolved.overlap.p2.ppos, resolved.p2.ppos);
251 | // } else if (resolved.kind === "p-de") {
252 | // // particle-dynamic-edge
253 | // } else if (resolved.kind === "p-se") {
254 | // // particle-static-edge
255 | // }
256 | // }
257 |
--------------------------------------------------------------------------------
/src/collide-circle-edge.ts:
--------------------------------------------------------------------------------
1 | import {
2 | add,
3 | distance2,
4 | set,
5 | sub,
6 | v2,
7 | dot,
8 | scale,
9 | distance,
10 | copy,
11 | normalize,
12 | Vector2
13 | } from "./v2";
14 | import { collideCircleCircle } from "./collide-circle-circle";
15 | import { VelocityDerivable } from "./common-types";
16 |
17 | // Preallocations
18 | const edgeDir = v2();
19 | const edge = v2();
20 | const prevEdge = v2();
21 | const hypo = v2();
22 | const epDiff = v2();
23 | const correction = v2();
24 | const collisionPoint = v2();
25 | const tunnelPoint = v2();
26 |
27 | const ep = {
28 | cpos: v2(),
29 | ppos: v2()
30 | };
31 |
32 | const epBefore = {
33 | cpos: v2(),
34 | ppos: v2()
35 | };
36 |
37 | export function collideCircleEdge(
38 | circle: VelocityDerivable,
39 | radius3: number,
40 | mass3: number,
41 | endpoint1: VelocityDerivable,
42 | mass1: number,
43 | endpoint2: VelocityDerivable,
44 | mass2: number,
45 | preserveInertia: boolean,
46 | damping: number
47 | ) {
48 | // Edge direction (edge in local space)
49 | sub(edge, endpoint2.cpos, endpoint1.cpos);
50 |
51 | // Normalize collision edge (assume collision axis is edge)
52 | normalize(edgeDir, edge);
53 |
54 | // Vector from endpoint1 to particle
55 | sub(hypo, circle.cpos, endpoint1.cpos);
56 |
57 | // Where is the particle on the edge, before, after, or on?
58 | // Also used for interpolation later.
59 | const projection = dot(edge, hypo);
60 | const maxDot = dot(edge, edge);
61 | const edgeMag = Math.sqrt(maxDot);
62 |
63 | // Colliding beyond the edge...
64 | if (projection < 0 || projection > maxDot) return;
65 |
66 | // Create interpolation factor of where point closest
67 | // to particle is on the line.
68 | const t = projection / maxDot;
69 | const u = 1 - t;
70 |
71 | // Find the point of collision on the edge.
72 | scale(collisionPoint, edgeDir, t * edgeMag);
73 | add(collisionPoint, collisionPoint, endpoint1.cpos);
74 | const dist = distance(collisionPoint, circle.cpos);
75 |
76 | // Bail if point and edge are too far apart.
77 | if (dist > radius3) return;
78 |
79 | // Distribute mass of colliding point into two fake points
80 | // and use those to collide against each endpoint independently.
81 |
82 | const standinMass1 = u * mass3;
83 | const standinMass2 = t * mass3;
84 |
85 | const standin1 = {
86 | cpos: v2(),
87 | ppos: v2()
88 | };
89 |
90 | const standin2 = {
91 | cpos: v2(),
92 | ppos: v2()
93 | };
94 |
95 | // Slide standin1 along edge to be in front of endpoint1
96 | scale(standin1.cpos, edgeDir, t * edgeMag);
97 | sub(standin1.cpos, circle.cpos, standin1.cpos);
98 | scale(standin1.ppos, edgeDir, t * edgeMag);
99 | sub(standin1.ppos, circle.ppos, standin1.ppos);
100 |
101 | // Slide standin2 along edge to be in front of endpoint2
102 | scale(standin2.cpos, edgeDir, u * edgeMag);
103 | add(standin2.cpos, circle.cpos, standin2.cpos);
104 | scale(standin2.ppos, edgeDir, u * edgeMag);
105 | add(standin2.ppos, circle.ppos, standin2.ppos);
106 |
107 | const standin1Before = {
108 | cpos: v2(),
109 | ppos: v2()
110 | };
111 |
112 | const standin2Before = {
113 | cpos: v2(),
114 | ppos: v2()
115 | };
116 |
117 | // Stash state of standins
118 | copy(standin1Before.cpos, standin1.cpos);
119 | copy(standin1Before.ppos, standin1.ppos);
120 | copy(standin2Before.cpos, standin2.cpos);
121 | copy(standin2Before.ppos, standin2.ppos);
122 |
123 | const edgeRadius = 0;
124 |
125 | // Collide standins with endpoints
126 | collideCircleCircle(
127 | standin1,
128 | radius3,
129 | standinMass1,
130 | endpoint1,
131 | edgeRadius,
132 | mass1,
133 | preserveInertia,
134 | damping
135 | );
136 |
137 | collideCircleCircle(
138 | standin2,
139 | radius3,
140 | standinMass2,
141 | endpoint2,
142 | edgeRadius,
143 | mass2,
144 | preserveInertia,
145 | damping
146 | );
147 |
148 | const standin1Delta = {
149 | cpos: v2(),
150 | ppos: v2()
151 | };
152 |
153 | const standin2Delta = {
154 | cpos: v2(),
155 | ppos: v2()
156 | };
157 |
158 | // Compute standin1 cpos change
159 | sub(standin1Delta.cpos, standin1.cpos, standin1Before.cpos);
160 |
161 | // Compute standin2 cpos change
162 | sub(standin2Delta.cpos, standin2.cpos, standin2Before.cpos);
163 |
164 | scale(standin1Delta.cpos, standin1Delta.cpos, u);
165 | scale(standin2Delta.cpos, standin2Delta.cpos, t);
166 |
167 | // Apply cpos changes to point3
168 | add(circle.cpos, circle.cpos, standin1Delta.cpos);
169 | add(circle.cpos, circle.cpos, standin2Delta.cpos);
170 |
171 | if (!preserveInertia) return;
172 |
173 | // TODO: instead of adding diff, get magnitude of diff and scale
174 | // in reverse direction of standin velocity from point3.cpos because
175 | // that is what circlecircle does.
176 |
177 | // Compute standin1 ppos change
178 | sub(standin1Delta.ppos, standin1.ppos, standin1Before.ppos);
179 |
180 | // Compute standin2 ppos change
181 | sub(standin2Delta.ppos, standin2.ppos, standin2Before.ppos);
182 |
183 | scale(standin1Delta.ppos, standin1Delta.ppos, u);
184 | scale(standin2Delta.ppos, standin2Delta.ppos, t);
185 |
186 | // Apply ppos changes to point3
187 | add(circle.ppos, circle.ppos, standin1Delta.ppos);
188 | add(circle.ppos, circle.ppos, standin2Delta.ppos);
189 | }
190 |
191 | type Particle = {
192 | mass: number;
193 | radius: number;
194 | } & VelocityDerivable;
195 |
196 | function snapshotDebug(
197 | name: string,
198 | particles: Particle[] = [],
199 | points: [string, Vector2][] = []
200 | ) {
201 | const cvs = document.createElement("canvas");
202 | const ctx = cvs.getContext("2d")!;
203 | document.body.appendChild(cvs);
204 | // let minX = Number.MAX_SAFE_INTEGER;
205 | // let maxX = Number.MIN_SAFE_INTEGER;
206 | // let minY = Number.MAX_SAFE_INTEGER;
207 | // let maxY = Number.MIN_SAFE_INTEGER;
208 | // for (let i = 0; i < particles.length; i++) {
209 | // const particle = particles[i];
210 | // minX = Math.min(
211 | // minX,
212 | // particle.cpos.x - particle.radius,
213 | // particle.ppos.x - particle.radius
214 | // );
215 | // maxX = Math.max(
216 | // maxX,
217 | // particle.cpos.x + particle.radius,
218 | // particle.ppos.x + particle.radius
219 | // );
220 | // minY = Math.min(
221 | // minY,
222 | // particle.cpos.y - particle.radius,
223 | // particle.ppos.y - particle.radius
224 | // );
225 | // maxY = Math.max(
226 | // maxY,
227 | // particle.cpos.y + particle.radius,
228 | // particle.ppos.y + particle.radius
229 | // );
230 | // }
231 |
232 | // for (let i = 0; i < points.length; i++) {
233 | // const [, point] = points[i];
234 | // minX = Math.min(minX, point.x, point.x);
235 | // maxX = Math.max(maxX, point.x, point.x);
236 | // minY = Math.min(minY, point.y, point.y);
237 | // maxY = Math.max(maxY, point.y, point.y);
238 | // }
239 |
240 | // cvs.width = maxX - minX;
241 | // cvs.height = maxY - minY;
242 |
243 | // ctx.translate(-minX, -minY);
244 |
245 | cvs.width = 800;
246 | cvs.height = 800;
247 |
248 | for (let i = 0; i < particles.length; i++) {
249 | const particle = particles[i];
250 | ctx.fillStyle = "rgba(255, 0, 0, 0.5)";
251 | ctx.beginPath();
252 | ctx.arc(
253 | particle.ppos.x,
254 | particle.ppos.y,
255 | particle.radius,
256 | 0,
257 | Math.PI * 2,
258 | false
259 | );
260 | ctx.fill();
261 |
262 | ctx.fillStyle = "rgba(0, 0, 0, 0.5)";
263 | ctx.beginPath();
264 | ctx.arc(
265 | particle.cpos.x,
266 | particle.cpos.y,
267 | particle.radius,
268 | 0,
269 | Math.PI * 2,
270 | false
271 | );
272 | ctx.fill();
273 | }
274 |
275 | for (let i = 0; i < points.length; i++) {
276 | const [name, point] = points[i];
277 | ctx.fillStyle = "purple";
278 | ctx.fillRect(point.x, point.y, 1, 1);
279 | ctx.fillText(`${name} (${point.x},${point.y})`, point.x + 1, point.y + 1);
280 | }
281 |
282 | // ctx.translate(minX, minY);
283 | ctx.fillStyle = "black";
284 | ctx.fillText(name, 10, 10);
285 | }
286 |
--------------------------------------------------------------------------------
/examples/bucket.ts:
--------------------------------------------------------------------------------
1 | import scihalt from "science-halt";
2 | import {
3 | Vector2,
4 | v2,
5 | copy,
6 | accelerate,
7 | inertia,
8 | add,
9 | distance,
10 | collideCircleEdge,
11 | rewindToCollisionPoint,
12 | collideCircleCircle,
13 | overlapCircleCircle,
14 | segmentIntersection,
15 | sub,
16 | normalize,
17 | scale,
18 | normal,
19 | dot,
20 | solveDistanceConstraint,
21 | projectPointEdge,
22 | PointEdgeProjection,
23 | createPointEdgeProjectionResult
24 | } from "../src";
25 |
26 | export const start = () => {
27 | const width = 800;
28 | const cvs = document.createElement("canvas");
29 | const ctx = cvs.getContext("2d")!;
30 | cvs.tabIndex = 1; // for keyboard events
31 | cvs.width = cvs.height = width;
32 | cvs.style.border = "1px solid gray";
33 | document.body.appendChild(cvs);
34 |
35 | type CollidableLine = {
36 | point1: CollidableCircle;
37 | point2: CollidableCircle;
38 | };
39 |
40 | type CollidableCircle = {
41 | cpos: Vector2;
42 | ppos: Vector2;
43 | acel: Vector2;
44 | mass: number;
45 | radius: number;
46 | };
47 |
48 | type DistanceConstraint = {
49 | point1: Exclude;
50 | point2: Exclude;
51 | goal: number;
52 | };
53 |
54 | const GRAVITY = 0.8;
55 | const CONSTRAINT_ITERS = 5;
56 |
57 | const lines: CollidableLine[] = [];
58 | const constraints: DistanceConstraint[] = [];
59 | const circles: CollidableCircle[] = [];
60 |
61 | const bucket = makeStaticMesh([
62 | v2(50, 50),
63 | v2(width - 50, 50),
64 | v2(width - 50, width - 50),
65 | v2(50, width - 50)
66 | ]);
67 |
68 | const midLine = makeStaticMesh([
69 | v2(500, width / 2),
70 | v2(width - 500, width / 2 + 200)
71 | ]);
72 | midLine.pop(); // remove the double link back to beginning
73 | lines.push(...bucket, ...midLine);
74 |
75 | circles.push(...makeCircles(v2(width / 2, width / 2), width / 4, 10, 400));
76 |
77 | const polys = [
78 | makePolygon(6, v2(300, 100), 15),
79 | makePolygon(3, v2(400, 100), 15),
80 | makePolygon(4, v2(600, 100), 15),
81 | ];
82 |
83 | polys.forEach(poly => {
84 | circles.push(...poly.circles);
85 | constraints.push(...poly.constraints);
86 | lines.push(...poly.lines);
87 | });
88 |
89 | let running = true;
90 | scihalt(() => (running = false));
91 |
92 | (function step() {
93 | const dt = 16;
94 |
95 | for (let i = 0; i < circles.length; i++) {
96 | const circle = circles[i];
97 |
98 | if (circle.mass > 0) {
99 | add(circle.acel, circle.acel, v2(0, GRAVITY));
100 | }
101 |
102 | accelerate(circle, dt);
103 | }
104 |
105 | for (let i = 0; i < CONSTRAINT_ITERS; i++) {
106 | for (let j = 0; j < constraints.length; j++) {
107 | const constraint = constraints[j];
108 | solveDistanceConstraint(
109 | constraint.point1,
110 | constraint.point1.mass,
111 | constraint.point2,
112 | constraint.point2.mass,
113 | constraint.goal,
114 | 1
115 | );
116 | }
117 | }
118 |
119 | for (let i = 0; i < lines.length; i++) {
120 | const line = lines[i];
121 | const lineIsBounds = bucket.indexOf(line) > -1;
122 | for (let j = 0; j < circles.length; j++) {
123 | const circle = circles[j];
124 | if (circle === line.point1 || circle === line.point2) continue;
125 | // TODO: this needs to be smarter. Without this rewind, simple circles
126 | // will tunnel through the line. But if done indiscriminately, the rewind
127 | // will cause an infinite build up of velocity, and eventually explode. OR
128 | // it will cause a circle to "stick" to an edge until their velocity
129 | // dissipates.
130 | if (!lineIsBounds) {
131 | rewindToCollisionPoint(circle, circle.radius, line.point1.cpos, line.point2.cpos);
132 | }
133 |
134 | collideCircleEdge(
135 | circle,
136 | circle.radius,
137 | circle.mass,
138 | line.point1,
139 | line.point1.mass,
140 | line.point2,
141 | line.point2.mass,
142 | false,
143 | 0.9
144 | );
145 | }
146 | }
147 |
148 | for (let i = 0; i < circles.length; i++) {
149 | const a = circles[i];
150 | for (let j = i + 1; j < circles.length; j++) {
151 | const b = circles[j];
152 | if (
153 | !overlapCircleCircle(
154 | a.cpos.x,
155 | a.cpos.y,
156 | a.radius,
157 | b.cpos.x,
158 | b.cpos.y,
159 | b.radius
160 | )
161 | )
162 | continue;
163 | collideCircleCircle(
164 | a,
165 | a.radius,
166 | a.mass,
167 | b,
168 | b.radius,
169 | b.mass,
170 | false,
171 | 0.9
172 | );
173 | }
174 | }
175 |
176 | for (let i = 0; i < circles.length; i++) {
177 | const circle = circles[i];
178 | inertia(circle);
179 | }
180 |
181 | for (let i = 0; i < lines.length; i++) {
182 | const line = lines[i];
183 | for (let j = 0; j < circles.length; j++) {
184 | const circle = circles[j];
185 | if (circle === line.point1 || circle === line.point2) continue;
186 | collideCircleEdge(
187 | circle,
188 | circle.radius,
189 | circle.mass,
190 | line.point1,
191 | line.point1.mass,
192 | line.point2,
193 | line.point2.mass,
194 | true,
195 | 0.9
196 | );
197 | }
198 | }
199 |
200 | for (let i = 0; i < circles.length; i++) {
201 | const a = circles[i];
202 | for (let j = i + 1; j < circles.length; j++) {
203 | const b = circles[j];
204 | if (
205 | !overlapCircleCircle(
206 | a.cpos.x,
207 | a.cpos.y,
208 | a.radius,
209 | b.cpos.x,
210 | b.cpos.y,
211 | b.radius
212 | )
213 | )
214 | continue;
215 | collideCircleCircle(
216 | a,
217 | a.radius,
218 | a.mass,
219 | b,
220 | b.radius,
221 | b.mass,
222 | true,
223 | 0.9
224 | );
225 | }
226 | }
227 |
228 | // Ensure nothing actually gets out of the bucket.
229 | // It's impossible to keep everything in the bucket without a strict
230 | // normal for each edge, because you need to know which "side" of the edge
231 | // the particle is on. Additionally, the solver steps above can easily move
232 | // a circle to a non-intersecting position that is beyond the "knowledge" of
233 | // anything checking for collisions with edges. This is especially common
234 | // when lots of collisions are being resolved.
235 | for (let i = 0; i < bucket.length; i++) {
236 | const line = bucket[i];
237 | // HACK: Don't use the midline collection for bounds checking.
238 | // The right way to do this is to create systems.
239 | if (line === midLine[0]) continue;
240 |
241 | for (let j = 0; j < circles.length; j++) {
242 | const circle = circles[j];
243 |
244 | const projection = createPointEdgeProjectionResult();
245 |
246 | // We know which way the edges were wound, so we implicitly know which order
247 | // these points should be used in to compute the normal.
248 | projectPointEdge(circle.cpos, line.point1.cpos, line.point2.cpos, projection);
249 |
250 | // both the edge normal and the segment from edge to circle are
251 | // facing a similar direction
252 | // We only know it is > 0 because of the order line.point,point2 were inputted
253 | // into the projection.
254 | if (projection.similarity > 0) continue;
255 |
256 | // If we get here, the directions so dissimilar that the circle must
257 | // be on the other side of the edge! Move it back!
258 | const offset = v2();
259 | sub(offset, projection.projectedPoint, circle.cpos);
260 | add(circle.cpos, circle.cpos, offset);
261 | // Don't correct ppos, otherwise velocity will continue to increase
262 | // forever.
263 | // add(circle.ppos, circle.ppos, offset);
264 | }
265 | }
266 |
267 | render(circles, lines, constraints, ctx);
268 | if (!running) return;
269 | window.requestAnimationFrame(step);
270 | })();
271 |
272 | function makeStaticMesh(points: Vector2[]) {
273 | const clines: CollidableLine[] = [];
274 | const circles: CollidableCircle[] = [];
275 | for (let i = 0; i < points.length; i++) {
276 | const point = points[i];
277 | const prev = circles.length === 0 ? null : circles[i - 1];
278 | const circle = {
279 | cpos: copy(v2(), point),
280 | ppos: copy(v2(), point),
281 | acel: v2(0, 0),
282 | mass: -1,
283 | radius: 1
284 | };
285 | if (prev) {
286 | const line = {
287 | point1: prev,
288 | point2: circle
289 | };
290 | clines.push(line);
291 | }
292 | circles.push(circle);
293 | }
294 |
295 | clines.push({
296 | point1: circles[circles.length - 1],
297 | point2: circles[0]
298 | });
299 |
300 | return clines;
301 | }
302 |
303 | function makeCircles(
304 | center: Vector2,
305 | spawnRadius: number,
306 | baseRadius: number,
307 | num: number
308 | ): CollidableCircle[] {
309 | const all = [];
310 | const minRadius = 10;
311 | for (let i = 0; i < num; i++) {
312 | const x = center.x + Math.cos(i) * spawnRadius;
313 | const y = center.y + Math.sin(i) * spawnRadius;
314 | all.push({
315 | cpos: { x, y },
316 | ppos: { x, y },
317 | acel: { x: 0, y: 0 },
318 | radius: Math.max(
319 | Math.abs(Math.cos(i) + Math.sin(i)) * baseRadius,
320 | minRadius
321 | ),
322 | mass: Math.max(Math.abs(Math.cos(i) + Math.sin(i)) * 1, 1)
323 | });
324 | }
325 | return all;
326 | }
327 |
328 | function makePolygon(gon: number, center: Vector2, radius: number) {
329 | const clines: CollidableLine[] = [];
330 | const circles: CollidableCircle[] = [];
331 | const constraints: DistanceConstraint[] = [];
332 | for (let i = 0; i < gon; i++) {
333 | const x = center.x + Math.cos((i / gon) * Math.PI * 2) * radius;
334 | const y = center.y + Math.sin((i / gon) * Math.PI * 2) * radius;
335 | circles.push({
336 | cpos: { x, y },
337 | ppos: { x, y },
338 | acel: { x: 0, y: 0 },
339 | radius: 5,
340 | mass: 5
341 | });
342 | }
343 |
344 | for (let i = 0; i < circles.length; i++) {
345 | const point1 = circles[i];
346 | const point2 = i === circles.length - 1 ? circles[0] : circles[i + 1];
347 | clines.push({
348 | point1,
349 | point2
350 | });
351 | }
352 |
353 | for (let i = 0; i < circles.length; i++) {
354 | const point1 = circles[i];
355 | let j = i;
356 | while (true) {
357 | j++;
358 | const point2 = circles[j % circles.length];
359 | if (point2 === point1) break;
360 | if (
361 | constraints.find(
362 | c =>
363 | (c.point1 === point1 && c.point2 === point2) ||
364 | (c.point2 === point1 && c.point1 === point2)
365 | ) !== undefined
366 | )
367 | break;
368 | constraints.push({
369 | point1,
370 | point2,
371 | goal: distance(point1.cpos, point2.cpos)
372 | });
373 | }
374 | }
375 |
376 | return {
377 | circles,
378 | lines: clines,
379 | constraints
380 | };
381 | }
382 |
383 | function render(
384 | circles: CollidableCircle[],
385 | segments: CollidableLine[],
386 | constraints: DistanceConstraint[],
387 | ctx: CanvasRenderingContext2D
388 | ) {
389 | ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
390 | for (let i = 0; i < circles.length; i++) {
391 | const point = circles[i];
392 |
393 | ctx.fillStyle = "red";
394 | ctx.beginPath();
395 | ctx.arc(point.ppos.x, point.ppos.y, point.radius, 0, Math.PI * 2, false);
396 | ctx.fill();
397 |
398 | ctx.fillStyle = "black";
399 | ctx.beginPath();
400 | ctx.arc(point.cpos.x, point.cpos.y, point.radius, 0, Math.PI * 2, false);
401 | ctx.fill();
402 | }
403 |
404 | for (let i = 0; i < segments.length; i++) {
405 | const segment = segments[i];
406 | ctx.strokeStyle = "red";
407 | ctx.beginPath();
408 | ctx.moveTo(segment.point1.ppos.x, segment.point1.ppos.y);
409 | ctx.lineTo(segment.point2.ppos.x, segment.point2.ppos.y);
410 | ctx.stroke();
411 | ctx.beginPath();
412 | ctx.strokeStyle = "black";
413 | ctx.moveTo(segment.point1.cpos.x, segment.point1.cpos.y);
414 | ctx.lineTo(segment.point2.cpos.x, segment.point2.cpos.y);
415 | ctx.stroke();
416 | }
417 |
418 | for (let i = 0; i < constraints.length; i++) {
419 | const c = constraints[i];
420 | ctx.strokeStyle = "magenta";
421 | ctx.beginPath();
422 | ctx.moveTo(c.point1.ppos.x, c.point1.ppos.y);
423 | ctx.lineTo(c.point2.ppos.x, c.point2.ppos.y);
424 | ctx.stroke();
425 | ctx.beginPath();
426 | ctx.strokeStyle = "purple";
427 | ctx.moveTo(c.point1.cpos.x, c.point1.cpos.y);
428 | ctx.lineTo(c.point2.cpos.x, c.point2.cpos.y);
429 | ctx.stroke();
430 | }
431 | }
432 | };
433 |
--------------------------------------------------------------------------------
/src/index.test.ts:
--------------------------------------------------------------------------------
1 | import { accelerate } from "./accelerate";
2 | import { collideCircleCircle } from "./collide-circle-circle";
3 | import { collideCircleEdge } from "./collide-circle-edge";
4 | import { collisionResponseAABB } from "./collision-response-aabb";
5 | import { inertia } from "./inertia";
6 | import { createAABBOverlapResult, overlapAABBAABB } from "./overlap-aabb-aabb";
7 | import { overlapCircleCircle } from "./overlap-circle-circle";
8 | import { rewindToCollisionPoint } from "./rewind-to-collision-point";
9 | import { solveGravitation } from "./solve-gravitation";
10 | import { add, normal, scale, sub, v2 } from "./v2";
11 | //import frictionAABB from './aabb-friction';
12 |
13 | test("2d", () => {
14 | const point = {
15 | cpos: { x: 0, y: 0 },
16 | ppos: { x: 0, y: 0 },
17 | acel: { x: 10, y: 0 },
18 | };
19 |
20 | // 1 is the delta time between steps
21 | accelerate(point, 1);
22 | inertia(point);
23 |
24 | expect(point.cpos.x).toBe(0.02);
25 | expect(point.ppos.x).toBe(0.01);
26 | expect(point.acel.x).toBe(0);
27 | });
28 |
29 | test("2d gravitation", () => {
30 | const point1 = {
31 | cpos: { x: 0, y: 0 },
32 | ppos: { x: 0, y: 0 },
33 | acel: { x: 0, y: 0 },
34 | };
35 |
36 | const p1mass = 1;
37 |
38 | const point2 = {
39 | cpos: { x: 1, y: 0 },
40 | ppos: { x: 0, y: 0 },
41 | acel: { x: 0, y: 0 },
42 | };
43 |
44 | const p2mass = 1;
45 |
46 | solveGravitation(point1, p1mass, point2, p2mass, 1);
47 | solveGravitation(point2, p2mass, point1, p1mass, 1);
48 |
49 | expect(point1.acel.x).toBe(1);
50 | expect(point2.acel.x).toBe(-1);
51 | expect(point1.acel.x).toBe(point2.acel.x * -1);
52 | });
53 |
54 | test("2d collision with equal mass and inertia preserved", () => {
55 | const damping = 0.99;
56 |
57 | const point1 = {
58 | cpos: { x: 0, y: 0 },
59 | ppos: { x: 0, y: 0 },
60 | acel: { x: 0, y: 0 },
61 | };
62 |
63 | const point2 = {
64 | cpos: { x: 9, y: 0 },
65 | ppos: { x: 9, y: 0 },
66 | acel: { x: 0, y: 0 },
67 | };
68 |
69 | const p1mass = 1;
70 | const p2mass = 1;
71 | const p1radius = 5;
72 | const p2radius = 5;
73 |
74 | accelerate(point1, 1);
75 | accelerate(point2, 1);
76 | collideCircleCircle(
77 | point1,
78 | p1radius,
79 | p1mass,
80 | point2,
81 | p2radius,
82 | p2mass,
83 | false,
84 | damping
85 | );
86 |
87 | expect(point1.cpos.x).toBe(-0.5);
88 | expect(point1.cpos.y).toBe(0);
89 | expect(point1.ppos.x).toBe(0);
90 | expect(point1.ppos.y).toBe(0);
91 | expect(point2.cpos.x).toBe(9.5);
92 | expect(point2.cpos.y).toBe(0);
93 | expect(point2.ppos.x).toBe(9);
94 | expect(point2.ppos.y).toBe(0);
95 |
96 | inertia(point1);
97 | inertia(point2);
98 |
99 | expect(point1.cpos.x).toBe(-1);
100 | expect(point1.cpos.y).toBe(0);
101 | expect(point1.ppos.x).toBe(-0.5);
102 | expect(point1.ppos.y).toBe(0);
103 | expect(point2.cpos.x).toBe(10);
104 | expect(point2.cpos.y).toBe(0);
105 | expect(point2.ppos.x).toBe(9.5);
106 | expect(point2.ppos.y).toBe(0);
107 |
108 | collideCircleCircle(
109 | point1,
110 | p1radius,
111 | p1mass,
112 | point2,
113 | p2radius,
114 | p2mass,
115 | true,
116 | damping
117 | );
118 |
119 | expect(point1.cpos.x).toBe(-0.5);
120 | expect(point1.cpos.y).toBe(0);
121 | expect(point1.ppos.x).toBe(-0.9900000000000001);
122 | expect(point1.ppos.y).toBe(0);
123 | expect(point2.cpos.x).toBe(9.5);
124 | expect(point2.cpos.y).toBe(0);
125 | expect(point2.ppos.x).toBe(9.99);
126 | expect(point2.ppos.y).toBe(0);
127 | });
128 |
129 | test("2d collision with inequal mass and inertia preserved", () => {
130 | const damping = 0.99;
131 |
132 | const point1 = {
133 | cpos: { x: 0, y: 0 },
134 | ppos: { x: 0, y: 0 },
135 | acel: { x: 0, y: 0 },
136 | };
137 |
138 | const point2 = {
139 | cpos: { x: 9, y: 0 },
140 | ppos: { x: 9, y: 0 },
141 | acel: { x: 0, y: 0 },
142 | };
143 |
144 | const p1mass = 1;
145 | const p2mass = 3;
146 | const p1radius = 5;
147 | const p2radius = 5;
148 |
149 | accelerate(point1, 1);
150 | accelerate(point2, 1);
151 | collideCircleCircle(
152 | point1,
153 | p1radius,
154 | p1mass,
155 | point2,
156 | p2radius,
157 | p2mass,
158 | false,
159 | damping
160 | );
161 |
162 | expect(point1.cpos.x).toBe(-0.75);
163 | expect(point1.cpos.y).toBe(0);
164 | expect(point1.ppos.x).toBe(0);
165 | expect(point1.ppos.y).toBe(0);
166 | expect(point2.cpos.x).toBe(9.25);
167 | expect(point2.cpos.y).toBe(0);
168 | expect(point2.ppos.x).toBe(9);
169 | expect(point2.ppos.y).toBe(0);
170 |
171 | inertia(point1);
172 | inertia(point2);
173 |
174 | expect(point1.cpos.x).toBe(-1.5);
175 | expect(point1.cpos.y).toBe(0);
176 | expect(point1.ppos.x).toBe(-0.75);
177 | expect(point1.ppos.y).toBe(0);
178 | expect(point2.cpos.x).toBe(9.5);
179 | expect(point2.cpos.y).toBe(0);
180 | expect(point2.ppos.x).toBe(9.25);
181 | expect(point2.ppos.y).toBe(0);
182 |
183 | collideCircleCircle(
184 | point1,
185 | p1radius,
186 | p1mass,
187 | point2,
188 | p2radius,
189 | p2mass,
190 | true,
191 | damping
192 | );
193 |
194 | expect(point1.cpos.x).toBe(-0.75);
195 | expect(point1.cpos.y).toBe(0);
196 | expect(point1.ppos.x).toBe(-0.9900000000000001);
197 | expect(point1.ppos.y).toBe(0);
198 | expect(point2.cpos.x).toBe(9.25);
199 | expect(point2.cpos.y).toBe(0);
200 | expect(point2.ppos.x).toBe(9.33);
201 | expect(point2.ppos.y).toBe(0);
202 | });
203 |
204 | test("2d collision, vs infinite mass and inertia preserved", () => {
205 | const damping = 0.99;
206 |
207 | const point1 = {
208 | cpos: { x: 0, y: 0 },
209 | ppos: { x: 0, y: 0 },
210 | acel: { x: 0, y: 0 },
211 | };
212 |
213 | const point2 = {
214 | cpos: { x: 9, y: 0 },
215 | ppos: { x: 9, y: 0 },
216 | acel: { x: 0, y: 0 },
217 | };
218 |
219 | const p1mass = 1;
220 | const p2mass = Number.MAX_VALUE;
221 | const p1radius = 5;
222 | const p2radius = 5;
223 |
224 | accelerate(point1, 1);
225 | accelerate(point2, 1);
226 | collideCircleCircle(
227 | point1,
228 | p1radius,
229 | p1mass,
230 | point2,
231 | p2radius,
232 | p2mass,
233 | false,
234 | damping
235 | );
236 |
237 | expect(point1.cpos.x).toBe(-1);
238 | expect(point1.cpos.y).toBe(0);
239 | expect(point1.ppos.x).toBe(0);
240 | expect(point1.ppos.y).toBe(0);
241 | expect(point2.cpos.x).toBe(9);
242 | expect(point2.cpos.y).toBe(0);
243 | expect(point2.ppos.x).toBe(9);
244 | expect(point2.ppos.y).toBe(0);
245 |
246 | inertia(point1);
247 | inertia(point2);
248 |
249 | expect(point1.cpos.x).toBe(-2);
250 | expect(point1.cpos.y).toBe(0);
251 | expect(point1.ppos.x).toBe(-1);
252 | expect(point1.ppos.y).toBe(0);
253 | expect(point2.cpos.x).toBe(9);
254 | expect(point2.cpos.y).toBe(0);
255 | expect(point2.ppos.x).toBe(9);
256 | expect(point2.ppos.y).toBe(0);
257 |
258 | collideCircleCircle(
259 | point1,
260 | p1radius,
261 | p1mass,
262 | point2,
263 | p2radius,
264 | p2mass,
265 | true,
266 | damping
267 | );
268 |
269 | expect(point1.cpos.x).toBe(-1);
270 | expect(point1.cpos.y).toBe(0);
271 | expect(point1.ppos.x).toBe(-0.9900000000000001);
272 | expect(point1.ppos.y).toBe(0);
273 | expect(point2.cpos.x).toBe(9);
274 | expect(point2.cpos.y).toBe(0);
275 | expect(point2.ppos.x).toBe(9);
276 | expect(point2.ppos.y).toBe(0);
277 | });
278 |
279 | test("overlap circles", () => {
280 | const point1 = {
281 | cpos: { x: 0, y: 0 },
282 | };
283 |
284 | const point2 = {
285 | cpos: { x: 9, y: 0 },
286 | };
287 |
288 | const p1radius = 5;
289 | const p2radius = 5;
290 |
291 | const overlapping = overlapCircleCircle(
292 | point1.cpos.x,
293 | point1.cpos.y,
294 | p1radius,
295 | point2.cpos.x,
296 | point2.cpos.y,
297 | p2radius
298 | );
299 |
300 | expect(overlapping).toBeTruthy();
301 | });
302 |
303 | test("normal, existing point", () => {
304 | const out = { x: 0, y: 0 };
305 | const n = normal(out, { x: 2, y: 2 }, { x: 4, y: 4 });
306 | expect(n).toBe(out);
307 | });
308 |
309 | test("normal, down", () => {
310 | const n = normal({ x: 0, y: 0 }, { x: 2, y: 2 }, { x: 0, y: 2 });
311 | expect(n).toEqual({ x: 0, y: -1 });
312 | });
313 |
314 | test("normal, up", () => {
315 | const n = normal({ x: 0, y: 0 }, { x: 2, y: 0 }, { x: 4, y: 0 });
316 | expect(n).toEqual({ x: 0, y: 1 });
317 | });
318 |
319 | test("normal, left", () => {
320 | const n = normal({ x: 0, y: 0 }, { x: 2, y: 0 }, { x: 2, y: 2 });
321 | expect(n).toEqual({ x: -1, y: 0 });
322 | });
323 |
324 | test("normal, right", () => {
325 | const n = normal({ x: 0, y: 0 }, { x: 2, y: 0 }, { x: 2, y: -2 });
326 | expect(n).toEqual({ x: 1, y: 0 });
327 | });
328 |
329 | test.skip("collide circle edge, equal mass", () => {
330 | const damping = 0.99;
331 |
332 | const point1 = {
333 | cpos: { x: 0, y: 0 },
334 | ppos: { x: 0, y: 0 },
335 | acel: { x: 0, y: 0 },
336 | };
337 |
338 | const point2 = {
339 | cpos: { x: 5, y: 0 },
340 | ppos: { x: 5, y: 0 },
341 | acel: { x: 0, y: 0 },
342 | };
343 |
344 | const point3 = {
345 | cpos: { x: 2.5, y: 1 },
346 | ppos: { x: 2.5, y: 1 },
347 | acel: { x: 0, y: 0 },
348 | };
349 |
350 | const radius3 = 2;
351 |
352 | const mass1 = 0.5;
353 | const mass2 = 0.5;
354 | const mass3 = 0.5;
355 |
356 | const checkpoint1Top = {
357 | cpos: { x: 0, y: 1 },
358 | ppos: { x: 0, y: 1 },
359 | acel: { x: 0, y: 0 },
360 | };
361 |
362 | const checkpoint1Bottom = {
363 | cpos: { x: 0, y: 0 },
364 | ppos: { x: 0, y: 0 },
365 | acel: { x: 0, y: 0 },
366 | };
367 |
368 | const checkpoint2Top = {
369 | cpos: { x: 5, y: 1 },
370 | ppos: { x: 5, y: 1 },
371 | acel: { x: 0, y: 0 },
372 | };
373 |
374 | const checkpoint2Bottom = {
375 | cpos: { x: 5, y: 0 },
376 | ppos: { x: 5, y: 0 },
377 | acel: { x: 0, y: 0 },
378 | };
379 |
380 | // Just like the edge...
381 | const checkpointBottomRadius = 0;
382 |
383 | accelerate(point1, 1);
384 | accelerate(point2, 1);
385 | accelerate(point3, 1);
386 |
387 | accelerate(checkpoint1Top, 1);
388 | accelerate(checkpoint1Bottom, 1);
389 | accelerate(checkpoint2Top, 1);
390 | accelerate(checkpoint2Bottom, 1);
391 |
392 | collideCircleEdge(
393 | point3,
394 | radius3,
395 | mass3,
396 | point1,
397 | mass1,
398 | point2,
399 | mass2,
400 | false,
401 | damping
402 | );
403 |
404 | collideCircleCircle(
405 | checkpoint1Top,
406 | radius3,
407 | mass3 / 2,
408 | checkpoint1Bottom,
409 | checkpointBottomRadius,
410 | mass1,
411 | false,
412 | damping
413 | );
414 |
415 | collideCircleCircle(
416 | checkpoint2Top,
417 | radius3,
418 | mass3 / 2,
419 | checkpoint2Bottom,
420 | checkpointBottomRadius,
421 | mass2,
422 | false,
423 | damping
424 | );
425 |
426 | expect(point3.cpos.y).toBe(checkpoint1Top.cpos.y);
427 | expect(point1.cpos.y).toBe(checkpoint1Bottom.cpos.y);
428 | expect(point1.ppos.y).toBe(checkpoint1Bottom.ppos.y);
429 | expect(point2.cpos.y).toBe(checkpoint2Bottom.cpos.y);
430 | expect(point2.ppos.y).toBe(checkpoint2Bottom.ppos.y);
431 |
432 | inertia(point1);
433 | inertia(point2);
434 | inertia(point3);
435 |
436 | inertia(checkpoint1Top);
437 | inertia(checkpoint1Bottom);
438 | inertia(checkpoint2Top);
439 | inertia(checkpoint2Bottom);
440 |
441 | expect(point3.cpos.y).toBe(checkpoint1Top.cpos.y);
442 | expect(point1.cpos.y).toBe(checkpoint1Bottom.cpos.y);
443 | expect(point1.ppos.y).toBe(checkpoint1Bottom.ppos.y);
444 | expect(point2.cpos.y).toBe(checkpoint2Bottom.cpos.y);
445 | expect(point2.ppos.y).toBe(checkpoint2Bottom.ppos.y);
446 |
447 | collideCircleEdge(
448 | point3,
449 | radius3,
450 | mass3,
451 | point1,
452 | mass1,
453 | point2,
454 | mass2,
455 | true,
456 | damping
457 | );
458 |
459 | collideCircleCircle(
460 | checkpoint1Top,
461 | radius3,
462 | mass3 / 2,
463 | checkpoint1Bottom,
464 | checkpointBottomRadius,
465 | mass1,
466 | true,
467 | damping
468 | );
469 |
470 | collideCircleCircle(
471 | checkpoint2Top,
472 | radius3,
473 | mass3 / 2,
474 | checkpoint2Bottom,
475 | checkpointBottomRadius,
476 | mass2,
477 | true,
478 | damping
479 | );
480 |
481 | expect(point1.cpos.y).toBe(checkpoint1Bottom.cpos.y);
482 | expect(point1.ppos.y).toBe(checkpoint1Bottom.ppos.y);
483 | expect(point2.cpos.y).toBe(checkpoint2Bottom.cpos.y);
484 | expect(point2.ppos.y).toBe(checkpoint2Bottom.ppos.y);
485 |
486 | expect(point3.cpos.y).toBe(checkpoint1Top.cpos.y);
487 | expect(point3.ppos.y).toBe(checkpoint1Top.ppos.y);
488 |
489 | expect(point1.cpos.x).toBe(0);
490 | expect(point2.cpos.x).toBe(5);
491 | expect(point3.cpos.x).toBe(2.5);
492 | });
493 |
494 | test.skip("collide circle edge, equal mass, start", () => {
495 | const damping = 0.99;
496 |
497 | // (point3)
498 | // v
499 | // (point1) --------- (point2)
500 |
501 | // (checkpoint3)
502 | // v
503 | // (checkpoint2)
504 |
505 | const point1 = {
506 | cpos: { x: 0, y: 0 },
507 | ppos: { x: 0, y: 0 },
508 | acel: { x: 0, y: 0 },
509 | };
510 |
511 | const point2 = {
512 | cpos: { x: 5, y: 0 },
513 | ppos: { x: 5, y: 0 },
514 | acel: { x: 0, y: 0 },
515 | };
516 |
517 | const point3 = {
518 | cpos: { x: 0, y: 1 },
519 | ppos: { x: 0, y: 1 },
520 | acel: { x: 0, y: 0 },
521 | };
522 |
523 | const radius3 = 2;
524 |
525 | const mass1 = 0.5;
526 | const mass2 = 0.5;
527 | const mass3 = 1;
528 |
529 | const checkpoint3 = {
530 | cpos: { x: 0, y: 1 },
531 | ppos: { x: 0, y: 1 },
532 | acel: { x: 0, y: 0 },
533 | };
534 |
535 | const checkpoint2 = {
536 | cpos: { x: 0, y: 0 },
537 | ppos: { x: 0, y: 0 },
538 | acel: { x: 0, y: 0 },
539 | };
540 |
541 | // Just like the edge...
542 | const checkpoint2Radius = 0;
543 |
544 | accelerate(point1, 1);
545 | accelerate(point2, 1);
546 | accelerate(point3, 1);
547 |
548 | accelerate(checkpoint2, 1);
549 | accelerate(checkpoint3, 1);
550 |
551 | collideCircleEdge(
552 | point3,
553 | radius3,
554 | mass3,
555 | point1,
556 | mass1,
557 | point2,
558 | mass2,
559 | false,
560 | damping
561 | );
562 |
563 | collideCircleCircle(
564 | checkpoint3,
565 | radius3,
566 | mass3,
567 | checkpoint2,
568 | checkpoint2Radius,
569 | mass2,
570 | false,
571 | damping
572 | );
573 |
574 | expect(point1.cpos.y).toBe(checkpoint2.cpos.y);
575 | expect(point1.ppos.y).toBe(checkpoint2.ppos.y);
576 | expect(point2.cpos.y).not.toBe(checkpoint2.cpos.y);
577 | expect(point2.ppos.y).toBe(checkpoint2.ppos.y);
578 |
579 | // Do some fakery, just to allow our checkpoints to succeed. The projection
580 | // of the point3 along the edge line will be different the second time, since
581 | // the edge is now at an angle slightly (endpoint1 has moved), meaning some
582 | // of the collision inertia will be applied to endpoint2, reducing the total
583 | // collision force applied to endpoint1, and making it not match checkpoint1.
584 | point2.cpos.y = point1.cpos.y;
585 | point2.ppos.y = point1.ppos.y;
586 |
587 | inertia(point1);
588 | inertia(point2);
589 | inertia(point3);
590 |
591 | inertia(checkpoint2);
592 | inertia(checkpoint3);
593 |
594 | collideCircleEdge(
595 | point3,
596 | radius3,
597 | mass3,
598 | point1,
599 | mass1,
600 | point2,
601 | mass2,
602 | true,
603 | damping
604 | );
605 |
606 | collideCircleCircle(
607 | checkpoint3,
608 | radius3,
609 | mass3,
610 | checkpoint2,
611 | checkpoint2Radius,
612 | mass2,
613 | true,
614 | damping
615 | );
616 |
617 | expect(point1.cpos.y).toBe(checkpoint2.cpos.y);
618 | expect(point1.ppos.y).toBe(checkpoint2.ppos.y);
619 |
620 | expect(point2.cpos.y).not.toBe(checkpoint2.cpos.y);
621 | expect(point2.ppos.y).not.toBe(checkpoint2.ppos.y);
622 |
623 | expect(point3.cpos.y).toBe(checkpoint3.cpos.y);
624 | expect(point3.ppos.y).toBe(checkpoint3.ppos.y);
625 |
626 | expect(point1.cpos.x).toBe(0);
627 | expect(point2.cpos.x).toBe(5);
628 | expect(point3.cpos.x).toBe(0);
629 | });
630 |
631 | test.skip("tunneling", () => {
632 | const point1 = {
633 | cpos: { x: 0, y: 0 },
634 | ppos: { x: 0, y: 0 },
635 | acel: { x: 0, y: 0 },
636 | };
637 |
638 | const point2 = {
639 | cpos: { x: 5, y: 0 },
640 | ppos: { x: 5, y: 0 },
641 | acel: { x: 0, y: 0 },
642 | };
643 |
644 | const point3 = {
645 | cpos: { x: 2.5, y: -2 },
646 | ppos: { x: 2.5, y: 2 },
647 | acel: { x: 0, y: 0 },
648 | };
649 |
650 | rewindToCollisionPoint(point3, 0, point1.cpos, point2.cpos);
651 |
652 | console.log("point1", point1);
653 | console.log("point2", point2);
654 | console.log("point3", point3);
655 | });
656 |
657 | test("aabb2 result types", () => {
658 | type CustomNumber = number & { special: true };
659 | function asCustomVector(n: number) {
660 | return n as CustomNumber;
661 | }
662 |
663 | const result = createAABBOverlapResult();
664 | // @ts-expect-error number is not assignable to CustomVector
665 | result.hitPos.x = 4;
666 | result.hitPos.x = asCustomVector(4);
667 | });
668 |
669 | test("aabb2 overlap, very oblong", () => {
670 | const box1 = {
671 | cpos: { x: 0, y: 0 },
672 | w: 100,
673 | h: 1,
674 | };
675 |
676 | const box2 = {
677 | cpos: { x: 50, y: 0 },
678 | w: 200,
679 | h: 1,
680 | };
681 |
682 | // The amount to move box2 to not overlap with box1.
683 | const resolutionVector = createAABBOverlapResult();
684 |
685 | const isOverlapping = overlapAABBAABB(
686 | box1.cpos.x,
687 | box1.cpos.y,
688 | box1.w,
689 | box1.h,
690 | box2.cpos.x,
691 | box2.cpos.y,
692 | box2.w,
693 | box2.h,
694 | resolutionVector
695 | );
696 |
697 | expect(isOverlapping).toBe(resolutionVector);
698 | expect(resolutionVector.resolve.x).toBe(0);
699 | expect(resolutionVector.resolve.y).toBe(1);
700 | });
701 |
702 | test("aabb2 overlap X", () => {
703 | const box1 = {
704 | cpos: { x: 0, y: 0 },
705 | w: 10,
706 | h: 20,
707 | };
708 |
709 | const box2 = {
710 | cpos: { x: 2, y: 5 },
711 | w: 10,
712 | h: 20,
713 | };
714 |
715 | const result = createAABBOverlapResult();
716 |
717 | const isOverlapping = overlapAABBAABB(
718 | box1.cpos.x,
719 | box1.cpos.y,
720 | box1.w,
721 | box1.h,
722 | box2.cpos.x,
723 | box2.cpos.y,
724 | box2.w,
725 | box2.h,
726 | result
727 | );
728 |
729 | expect(isOverlapping).toBe(result);
730 | expect(result.resolve.x).toBe(8);
731 | expect(result.resolve.y).toBe(0);
732 |
733 | expect(result.hitPos.x).toBe(5);
734 | expect(result.hitPos.y).toBe(5);
735 |
736 | expect(result.normal.x).toBe(1);
737 | });
738 |
739 | test("aabb2 overlap Y", () => {
740 | const box1 = {
741 | cpos: { x: 0, y: 0 },
742 | w: 20,
743 | h: 10,
744 | };
745 |
746 | const box2 = {
747 | cpos: { x: 2, y: 5 },
748 | w: 20,
749 | h: 10,
750 | };
751 |
752 | const result = createAABBOverlapResult();
753 |
754 | const isOverlapping = overlapAABBAABB(
755 | box1.cpos.x,
756 | box1.cpos.y,
757 | box1.w,
758 | box1.h,
759 | box2.cpos.x,
760 | box2.cpos.y,
761 | box2.w,
762 | box2.h,
763 | result
764 | );
765 |
766 | expect(isOverlapping).toBe(result);
767 | expect(result.resolve.x).toBe(0);
768 | expect(result.resolve.y).toBe(5);
769 |
770 | expect(result.hitPos.x).toBe(2);
771 | expect(result.hitPos.y).toBe(5);
772 |
773 | expect(result.normal.y).toBe(1);
774 | });
775 |
776 | test("aabb collision-response", () => {
777 | const box1 = {
778 | cpos: { x: 0, y: 0 },
779 | ppos: { x: -5, y: -5 },
780 | w: 5,
781 | h: 5,
782 | mass: 1,
783 | restitution: 1,
784 | staticFriction: 0,
785 | dynamicFriction: 0,
786 | };
787 |
788 | const box2 = {
789 | cpos: { x: 5, y: 0 },
790 | ppos: { x: 10, y: -5 },
791 | w: 5,
792 | h: 5,
793 | mass: 1,
794 | restitution: 1,
795 | staticFriction: 0,
796 | dynamicFriction: 0,
797 | };
798 |
799 | const collision = createAABBOverlapResult();
800 | const isOverlapping = overlapAABBAABB(
801 | box1.cpos.x,
802 | box1.cpos.y,
803 | box1.w,
804 | box1.h,
805 | box2.cpos.x,
806 | box2.cpos.y,
807 | box2.w,
808 | box2.h,
809 | collision
810 | );
811 |
812 | const box1v = v2();
813 | const box2v = v2();
814 |
815 | collisionResponseAABB(
816 | box1.cpos,
817 | box1.ppos,
818 | box1.mass,
819 | box1.restitution,
820 | box1.staticFriction,
821 | box1.dynamicFriction,
822 | box2.cpos,
823 | box2.ppos,
824 | box2.mass,
825 | box2.restitution,
826 | box2.staticFriction,
827 | box2.dynamicFriction,
828 | collision.normal,
829 | box1v,
830 | box2v
831 | );
832 |
833 | // Apply the new velocity
834 | sub(box1.ppos, box1.cpos, box1v);
835 | sub(box2.ppos, box2.cpos, box2v);
836 |
837 | expect(box1.cpos).toEqual({ x: 0, y: 0 });
838 | expect(box2.cpos).toEqual({ x: 5, y: 0 });
839 |
840 | expect(box1.ppos).toEqual({ x: 5, y: -5 });
841 | expect(box2.ppos).toEqual({ x: 0, y: -5 });
842 | });
843 |
844 | test("aabb collision-response: very inequal masses", () => {
845 | const box1 = {
846 | cpos: { x: 0, y: 0 },
847 | ppos: { x: -5, y: -5 },
848 | w: 5,
849 | h: 5,
850 | mass: 10000000000,
851 | restitution: 1,
852 | staticFriction: 0,
853 | dynamicFriction: 0,
854 | };
855 |
856 | const box2 = {
857 | cpos: { x: 5, y: 0 },
858 | ppos: { x: 10, y: -5 },
859 | w: 5,
860 | h: 5,
861 | mass: 1,
862 | restitution: 1,
863 | staticFriction: 0,
864 | dynamicFriction: 0,
865 | };
866 |
867 | const collision = createAABBOverlapResult();
868 | const isOverlapping = overlapAABBAABB(
869 | box1.cpos.x,
870 | box1.cpos.y,
871 | box1.w,
872 | box1.h,
873 | box2.cpos.x,
874 | box2.cpos.y,
875 | box2.w,
876 | box2.h,
877 | collision
878 | );
879 |
880 | const box1v = v2();
881 | const box2v = v2();
882 |
883 | collisionResponseAABB(
884 | box1.cpos,
885 | box1.ppos,
886 | box1.mass,
887 | box1.restitution,
888 | box1.staticFriction,
889 | box1.dynamicFriction,
890 | box2.cpos,
891 | box2.ppos,
892 | box2.mass,
893 | box2.restitution,
894 | box2.staticFriction,
895 | box2.dynamicFriction,
896 | collision.normal,
897 | box1v,
898 | box2v
899 | );
900 |
901 | // Apply the new velocity
902 | sub(box1.ppos, box1.cpos, box1v);
903 | sub(box2.ppos, box2.cpos, box2v);
904 |
905 | expect(box1.cpos).toEqual({ x: 0, y: 0 });
906 | expect(box2.cpos).toEqual({ x: 5, y: 0 });
907 |
908 | expect(box1.ppos).toEqual({ x: -4.999999998, y: -5 });
909 | expect(box2.ppos).toEqual({ x: -9.999999998, y: -5 });
910 | });
911 |
912 | test("aabb friction", () => {
913 | const box1 = {
914 | cpos: { x: 0, y: 0 },
915 | ppos: { x: -5, y: -5 },
916 | w: 5,
917 | h: 5,
918 | mass: 1,
919 | restitution: 1,
920 | staticFriction: 0.5,
921 | dynamicFriction: 0.5,
922 | };
923 |
924 | const box2 = {
925 | cpos: { x: 4, y: 0 },
926 | ppos: { x: 9, y: -5 },
927 | w: 5,
928 | h: 5,
929 | mass: 1,
930 | restitution: 1,
931 | staticFriction: 0.5,
932 | dynamicFriction: 0.5,
933 | };
934 |
935 | const collision = createAABBOverlapResult();
936 |
937 | // TODO: no need for isOverlapping, just use a known collision normal
938 | // + already computed collision response ppos(s).
939 |
940 | const isOverlapping = overlapAABBAABB(
941 | box1.cpos.x,
942 | box1.cpos.y,
943 | box1.w,
944 | box1.h,
945 | box2.cpos.x,
946 | box2.cpos.y,
947 | box2.w,
948 | box2.h,
949 | collision
950 | );
951 |
952 | console.log(collision);
953 |
954 | // move to non-overlapping position
955 | const overlapHalf = scale(v2(), collision.resolve, 0.5);
956 | add(box2.cpos, box2.cpos, overlapHalf);
957 | add(box2.ppos, box2.ppos, overlapHalf);
958 | sub(box1.cpos, box1.cpos, overlapHalf);
959 | sub(box1.ppos, box1.ppos, overlapHalf);
960 |
961 | const box1v = v2();
962 | const box2v = v2();
963 |
964 | collisionResponseAABB(
965 | box1.cpos,
966 | box1.ppos,
967 | box1.mass,
968 | box1.restitution,
969 | box1.staticFriction,
970 | box1.dynamicFriction,
971 | box2.cpos,
972 | box2.ppos,
973 | box2.mass,
974 | box2.restitution,
975 | box2.staticFriction,
976 | box2.dynamicFriction,
977 | collision.normal,
978 | box1v,
979 | box2v
980 | );
981 |
982 | // Apply the new velocity
983 | sub(box1.ppos, box1.cpos, box1v);
984 | sub(box2.ppos, box2.cpos, box2v);
985 |
986 | console.log(box1v);
987 | console.log(box2v);
988 |
989 | console.log(box1.ppos);
990 | console.log(box2.ppos);
991 |
992 | expect(box1.cpos).toEqual({ x: -0.5, y: 0 });
993 | expect(box2.cpos).toEqual({ x: 4.5, y: 0 });
994 |
995 | expect(box1.ppos).toEqual({ x: 4.5, y: -2.5 });
996 | expect(box2.ppos).toEqual({ x: -0.5, y: -2.5 });
997 | });
998 |
--------------------------------------------------------------------------------