├── .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 | --------------------------------------------------------------------------------