├── 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 |
`;
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 |
--------------------------------------------------------------------------------