├── demo.gif ├── package.json ├── README.md ├── LICENSE ├── whiskey-kinectics-element.mjs └── whiskey-kinetics.mjs /demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cd/whiskey-kinetics/master/demo.gif -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "whiskey-kinetics", 3 | "version": "0.1.1" 4 | } 5 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # whiskey-kinetics 2 | 3 | Simple 2D physics engine in JavaScript (fun project for private use). 4 | 5 | Introduction and interactive examples: https://www.diede.dev/blog/kinetics-for-web-developers 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 - 2024 Christian Diederich 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /whiskey-kinectics-element.mjs: -------------------------------------------------------------------------------- 1 | // Create a class for the element 2 | class WhiskeyKineticsElement extends HTMLElement { 3 | constructor() { 4 | super(); 5 | 6 | // Get mandatory attributes 7 | this.workerPath = this.getAttribute("data-worker"); 8 | this.width = this.getAttribute("width"); 9 | this.height = this.getAttribute("height"); 10 | 11 | // Get custom attributes 12 | this.wkAttributes = this.getAttributeNames() 13 | .filter((e) => e.startsWith("data-attr-")) 14 | .reduce((pre, cur) => ({ ...pre, [cur.slice(10)]: this.getAttribute(cur) }), {}); 15 | 16 | // Create Shadow DOM 17 | this.attachShadow({ mode: "open" }); 18 | 19 | // Add content 20 | this.shadowRoot.innerHTML = ` 21 | 69 |
70 | 71 |
72 |
73 |
74 |
`; 75 | 76 | // Create web worker that does the calculation of all frames 77 | this.worker = new Worker(this.workerPath, { type: "module" }); 78 | 79 | // Start simulation 80 | this.worker.postMessage(this.wkAttributes); 81 | } 82 | 83 | async connectedCallback() { 84 | const module = await import(this.workerPath); 85 | 86 | // Draw calculated frames 87 | this.worker.onmessage = async (e) => { 88 | // Draw progress bar 89 | if (e.data.progress) { 90 | this.shadowRoot.querySelector(".progress-bar").style.width = e.data.progress * 200 + "px"; 91 | return; 92 | } 93 | 94 | // Hide progress bar and draw frames 95 | this.shadowRoot.querySelector(".canvas-wrapper").classList.remove("loading"); 96 | this.shadowRoot.querySelector(".progress-bar").style.width = "1px"; 97 | const start = Date.now(); 98 | const animateFrame = () => { 99 | const frameIndex = Math.floor(((Date.now() - start) / 1000) * e.data.fps); 100 | const continueAnimation = module.draw( 101 | this.shadowRoot.querySelector("canvas").getContext("2d"), 102 | frameIndex, 103 | e.data 104 | ); 105 | if (!continueAnimation) return; 106 | window.requestAnimationFrame(animateFrame); 107 | }; 108 | animateFrame(); 109 | }; 110 | } 111 | } 112 | 113 | customElements.define("whiskey-kinetics", WhiskeyKineticsElement); 114 | -------------------------------------------------------------------------------- /whiskey-kinetics.mjs: -------------------------------------------------------------------------------- 1 | /** 2 | * Class for links (to connect two particles). 3 | * @class 4 | */ 5 | export class Link { 6 | /** 7 | * Create a link between two particles. 8 | * @param {Particle} from 9 | * @param {Particle} to 10 | * @param {object} [properties] 11 | * @param {number} [properties.initialTime] Starting time in seconds (default: `0`). 12 | * @param {boolean} [properties.compressible] For rope-like behavior (no absorption of compressive forces), this 13 | * value must be set to `false` (default: `true`). 14 | * @param {number} [properties.norminalLength] Unstressed length (in m) (default: distance between the particles). 15 | * @param {number} [properties.maxTensileForce] Tensile force in N at which the element is destroyed 16 | * (default: `35550`). 17 | * 18 | * Example: If the link is to represent a construction steel bar (tensile strength of ~450 N/mm²) with a 19 | * diameter of 10 mm (area 79 mm²), the value would be 35550 N (450 N/mm² ⋅ 79 mm²). 20 | * @param {number} [properties.tensileStiffness] Arithmetic product of modulus of elasticity and 21 | * cross sectional area (E ⋅ A) in N (default: `16590000`). 22 | * 23 | * Example: If the link is to represent a construction steel bar (modulus of elasticity 210000 N/mm²) with a 24 | * diameter of 10 mm (area 79 mm²), the value would be 16590000 N (210000 N/mm² ⋅ 79 mm²). 25 | * @param {number} [properties.dampingCoefficient] Parameter to control the damping effect, based on the velocity of 26 | * the change of the length (default: `0`). 27 | */ 28 | constructor(from, to, properties = {}) { 29 | this._fromParticle = from; 30 | this._toParticle = to; 31 | this._lastUpdate = properties.initialTime || 0; 32 | this.compressible = properties.compressible === false ? false : true; 33 | this.norminalLength = 34 | properties.norminalLength || this._toParticle.position.clone().subtract(this._fromParticle.position).magnitude; 35 | this.springConstant = (properties.tensileStiffness || 16590000) / this.norminalLength; 36 | this.dampingCoefficient = properties.dampingCoefficient || 0; 37 | this.maxTensileForce = properties.maxTensileForce || 35550; 38 | this.destroyed = false; 39 | this._dampingForce = 0; 40 | } 41 | 42 | /** 43 | * Get vector between 'from' and 'to' particle. 44 | * @return {Vec2D} 45 | */ 46 | getVector() { 47 | return this._toParticle.position.clone().subtract(this._fromParticle.position); 48 | } 49 | 50 | /** 51 | * Get distance between the 'from' and 'to' particles. 52 | * @return {number} 53 | */ 54 | get length() { 55 | return this.getVector().magnitude; 56 | } 57 | 58 | /** 59 | * Get spring force. 60 | * @return {Vec2D} 61 | */ 62 | get springForce() { 63 | if (this.destroyed) return null; 64 | const lengthDiff = this.length - this.norminalLength; 65 | if (lengthDiff < 0 && !this.compressible) return 0; 66 | return lengthDiff * this.springConstant; 67 | } 68 | 69 | /** 70 | * Get damping force. 71 | * @return {Vec2D} 72 | */ 73 | get dampingForce() { 74 | if (this.destroyed) return null; 75 | return this._dampingForce; 76 | } 77 | 78 | /** 79 | * Apply the spring and damping force to the destroy status and the 'from' and 'to' particles. 80 | * @param {number} timestamp 81 | */ 82 | update(timestamp) { 83 | if (this.destroyed) return; 84 | 85 | // Calc damping force 86 | const lastLength = this._toParticle._lastPosition.clone().subtract(this._fromParticle._lastPosition).magnitude; 87 | this._dampingForce = ((lastLength - this.length) / (timestamp - this._lastUpdate)) * this.dampingCoefficient; 88 | const dampingForceVec = this.getVector().unitVector.multiply(-this._dampingForce); 89 | 90 | // Calc spring force 91 | const springForceVec = this.getVector().unitVector.multiply(this.springForce); 92 | 93 | // Calc total force 94 | const totalForceVec = dampingForceVec.clone().add(springForceVec); 95 | if (totalForceVec.magnitude > this.maxTensileForce) { 96 | this.destroyed = true; 97 | return; 98 | } 99 | 100 | // Apply total force to particles 101 | this._fromParticle.addForce(totalForceVec); 102 | this._toParticle.addForce(totalForceVec.clone().multiply(-1)); 103 | 104 | this._lastUpdate = timestamp; 105 | } 106 | } 107 | 108 | /** 109 | * Class for mass points / particles. 110 | * @class 111 | */ 112 | export class Particle { 113 | /** 114 | * Create a particle. 115 | * @param {Vec2D} position 116 | * @param {number} mass Mass in kg. 117 | * @param {object} [properties] 118 | * @param {number} [properties.initialTime] Starting time in seconds (default: `0`) 119 | * @param {Vec2D} [properties.velocity] Starting velocity in m/s (default: `0`) 120 | * @param {number} [properties.dragForceFactor] Arithmetic product (in kg) of drag coefficient, reference area and 121 | * mass density of the fluid (default: `0`). This value is used to calculate the flow resistance force when the 122 | * particle moves during update(). Learn more at https://en.wikipedia.org/wiki/Drag_coefficient 123 | * 124 | * Example: Simulation of a golf ball 125 | * - drag coefficient ~0.5 126 | * - reference area ~0.0014 m³ (π * (42.7 mm)² / 4) 127 | * - mass density (of the air) ~1.2 kg/m³ 128 | * 129 | * The particle representing the ball would therefore have a dragForceFactor of 0.00084 kg (0.5 * 0.0014 * 1.2). 130 | * However, if the particle is only 1 of 1000 particles that represent the ball (finite element method), then 131 | * the value must be divided by the number of particles. In this example, this would be 0.00000084 kg. 132 | */ 133 | constructor(position, mass, properties = {}) { 134 | this._accelerations = []; 135 | this._forces = []; 136 | this._position = position.clone(); 137 | this._lastPosition = position.clone(); 138 | this.mass = mass; 139 | this._lastUpdate = properties.initialTime || 0; 140 | this._velocity = properties.velocity || new Vec2D(0, 0); 141 | this.dragForceFactor = properties.dragForceFactor || 0; 142 | } 143 | 144 | /** 145 | * Get velocity of the particle. 146 | * @return {Vec2D} 147 | */ 148 | get velocity() { 149 | return this._velocity; 150 | } 151 | 152 | /** 153 | * Get position of the particle. 154 | * @return {Vec2D} 155 | */ 156 | get position() { 157 | return this._position; 158 | } 159 | 160 | /** 161 | * Add external force to the particle. This only takes effect on the next update(). 162 | * @param {Vec2D} value (in N) 163 | * @return {Particle} this 164 | */ 165 | addForce(value) { 166 | this._forces.push(value); 167 | return this; 168 | } 169 | 170 | /** 171 | * Add external acceleration (e. g. gravity) to the particle. This only takes effect on the next update(). 172 | * @param {Vec2D} value (in m/s²) 173 | * @return {Particle} this 174 | */ 175 | addAcceleration(value) { 176 | this._accelerations.push(value); 177 | return this; 178 | } 179 | 180 | /** 181 | * Update position and speed depending on external accelerations, external forces and the speed resistance. 182 | * @param {number} time (in s) 183 | */ 184 | update(time) { 185 | // Sum accelerations 186 | const accelerationSum = new Vec2D(0, 0); 187 | 188 | // Speed Resistance 189 | accelerationSum.add( 190 | this.velocity.unitVector.multiply((Math.pow(this.velocity.magnitude, 2) * this.dragForceFactor) / -2 / this.mass) 191 | ); 192 | 193 | // Further accelerations 194 | this._accelerations.forEach((acceleration) => { 195 | accelerationSum.add(acceleration); 196 | }); 197 | 198 | // External forces 199 | this._forces.forEach((force) => { 200 | accelerationSum.add(force.clone().multiply(1 / this.mass)); 201 | }); 202 | 203 | // Update position + speed 204 | const duration = time - this._lastUpdate; 205 | this._lastPosition = this._position.clone(); 206 | this._position 207 | .add(accelerationSum.clone().multiply(0.5 * Math.pow(duration, 2))) 208 | .add(this._velocity.clone().multiply(duration)); 209 | this._velocity.add(accelerationSum.clone().multiply(duration)); 210 | 211 | // Clear stack 212 | this._accelerations = []; 213 | this._forces = []; 214 | 215 | // Update time 216 | this._lastUpdate = time; 217 | } 218 | } 219 | 220 | /** 221 | * Helper class for working with two-dimensional vectors. 222 | * @class 223 | */ 224 | export class Vec2D { 225 | /** 226 | * Create a 2D vector. 227 | * @param {number} x 228 | * @param {number} y 229 | */ 230 | constructor(x, y) { 231 | this.x = x; 232 | this.y = y; 233 | } 234 | 235 | /** 236 | * Get magnitude of the vector. 237 | * @return {number} 238 | */ 239 | get magnitude() { 240 | return Math.sqrt(Math.pow(this.x, 2) + Math.pow(this.y, 2)); 241 | } 242 | 243 | /** 244 | * Set magnitude of the vector. 245 | * @param {number} val 246 | */ 247 | set magnitude(val) { 248 | if (this.x === 0) { 249 | this.y = val; 250 | return; 251 | } 252 | const ratio = this.y / this.x; 253 | this.x = Math.sqrt(Math.pow(val, 2) / (1 + Math.pow(ratio, 2))); 254 | this.y = ratio * this.x; 255 | } 256 | 257 | /** 258 | * Get rotation of the vector. 259 | * @return {number} in rad 260 | */ 261 | get rotation() { 262 | if (this.y >= 0) { 263 | if (this.x >= 0) { 264 | return Math.atan(this.y / this.x); 265 | } else { 266 | return Math.atan(-this.x / this.y) + Math.PI / 2; 267 | } 268 | } else { 269 | if (this.x >= 0) { 270 | return Math.atan(this.x / -this.y) + (Math.PI * 3) / 2; 271 | } else { 272 | return Math.atan(-this.y / -this.x) + Math.PI; 273 | } 274 | } 275 | } 276 | 277 | /** 278 | * Get unit vector copy of the vector. 279 | * @return {Vec2D} 280 | */ 281 | get unitVector() { 282 | const magnitude = this.magnitude; 283 | if (magnitude === 0) return new Vec2D(0, 0); 284 | return new Vec2D(this.x / magnitude, this.y / magnitude); 285 | } 286 | 287 | /** 288 | * Multiply vector. 289 | * @param {number} number 290 | * @return {Vec2D} 291 | */ 292 | multiply(number) { 293 | this.x *= number; 294 | this.y *= number; 295 | return this; 296 | } 297 | 298 | /** 299 | * Set the X and Y coordinates according to the specified vector. 300 | * @param {Vec2D} vec from which the X and Y coordinates are to be taken. 301 | * @return {Vec2D} 302 | */ 303 | set(vec) { 304 | this.x = vec.x; 305 | this.y = vec.y; 306 | return this; 307 | } 308 | 309 | /** 310 | * Get copy of the vector. 311 | * @return {Vec2D} 312 | */ 313 | clone() { 314 | return new Vec2D(this.x, this.y); 315 | } 316 | 317 | /** 318 | * Apply rotation to the vector. 319 | * @param {number} val (in rad) by which the vector is to be rotated 320 | * @return {Vec2D} 321 | */ 322 | rotate(val) { 323 | const x = this.x * Math.cos(val) - this.y * Math.sin(val); 324 | const y = this.x * Math.sin(val) + this.y * Math.cos(val); 325 | this.x = x; 326 | this.y = y; 327 | return this; 328 | } 329 | 330 | /** 331 | * Add vector. 332 | * @param {Vec2D} vec 333 | * @return {Vec2D} 334 | */ 335 | add(vec) { 336 | this.x += vec.x; 337 | this.y += vec.y; 338 | return this; 339 | } 340 | 341 | /** 342 | * Subtract vector. 343 | * @param {Vec2D} vec 344 | * @return {Vec2D} 345 | */ 346 | subtract(vec) { 347 | this.x -= vec.x; 348 | this.y -= vec.y; 349 | return this; 350 | } 351 | } 352 | --------------------------------------------------------------------------------