import { MassObject } from './object.js'; import { MASS_CREATION_RATE, DISPLAY_OBJECTS_INFO, MOTION_TIME_SCALE, GRAVITATIONAL_CONSTANT, ZOOM_TO_FIT_PADDING, } from './config.js'; export class Objects { objects = []; creatingObject = undefined; selectedObject = undefined; paused = false; constructor(sim) { this.sim = sim; } pause() { if (this.sim.playing) { this.sim.playing = false; this.paused = true; } } resume() { if (this.paused) { this.sim.playing = true; this.paused = false; } } // Create an object with mass that grows as pointer is held down createObject(x, y) { const idx = this.objects.length; const obj = new MassObject(x, y, idx); this.creatingObject = idx; this.objects.push(obj); // Pause the simulation during mass creation; this avoids some complex local dynamics // TODO: Make this optional (toolbar item to enable) if (this.sim.getOption('pauseDuring.creation')) { this.pause(); } } doneCreatingObject() { if (this.creatingObject !== undefined) { const obj = this.objects[this.creatingObject]; this.creatingObject = undefined; this.resume(); } } object(i) { return this.objects[i]; } selectObject(i) { this.selectedObject = i; if (this.sim.getOption('pauseDuring.selection')) { this.pause(); } } deselect() { this.selectedObject = undefined; this.resume(); } getSelectedOrCreating() { let i = this.creatingObject ?? this.selectedObject; if (i !== undefined) { return this.objects[i]; } } get length() { return this.objects.length; } get boundingBox() { const box = { start: {x: undefined, y: undefined}, end: {x: undefined, y: undefined}, }; this.forEachObject(obj => { if (box.start.x === undefined) { box.start = {...obj.position}; box.end = {...obj.position}; } else { if (obj.position.x < box.start.x) box.start.x = obj.position.x; if (obj.position.x > box.end.x) box.end.x = obj.position.x; if (obj.position.y < box.start.y) box.start.y = obj.position.y; if (obj.position.y > box.end.y) box.end.y = obj.position.y; } }); box.start.x -= ZOOM_TO_FIT_PADDING; box.start.y -= ZOOM_TO_FIT_PADDING; box.end.x += ZOOM_TO_FIT_PADDING; box.end.y += ZOOM_TO_FIT_PADDING; return box; } objectAtLocation(x, y) { let idx = undefined; this.forEachObject((obj, i) => { // If distance to object is less than object's radius, we are touching the object const dist = Math.pow((obj.position.x - x)**2 + (obj.position.y - y)**2, 1/2); if (dist <= obj.radius) { idx = i; return null; } }); } handlePointerDown({x, y}) { // If pointer is touching an object, select the object const touchingObject = this.objectAtLocation(x, y); if (touchingObject !== undefined) { this.selectObject(touchingObject); } else { // Otherwise, create a new object this.createObject(x, y); } } handlePointerUp({x, y}) { this.doneCreatingObject(); this.deselect(); } handlePointerMove({x, y, vx, vy}) { // If the cursor moves while creating an object, or while an object is selected, // update the position and velocity of the object const obj = this.getSelectedOrCreating(); if (obj !== undefined) { obj.position.x = x; obj.position.y = y; obj.velocity.x = vx; obj.velocity.y = vy; } } // cb: (obj, idx) => {} // TODO: Reducer forEachObject(cb, alive = true, startWith = 0) { for (let i = startWith; i < this.objects.length; i++) { const obj = this.objects[i]; if (alive === null || alive == obj.alive) { const ret = cb(obj, i); if (ret === null) break; } } } computeForces() { if (this.objects.length < 2) return; this.forEachObject(obj => { obj.forces = []; }); this.forEachObject((A, i) => { this.forEachObject(B => { const dx = (B.position.x - A.position.x); const dy = (B.position.y - A.position.y); const dSquared = dx ** 2 + dy ** 2; const d = Math.sqrt(dSquared); const F = GRAVITATIONAL_CONSTANT * A.mass * B.mass / dSquared; const Fx = F * dx / d; const Fy = F * dy / d; A.forces.push({ x: Fx, y: Fy }); B.forces.push({ x: -Fx, y: -Fy }); }, true, i + 1); }); // Also compute acceleration this.forEachObject(obj => { obj.acceleration = obj.getAcceleration(); }); } computeFrame(elapsedTime) { // If we're creating an object, increment its mass // with the mass creation rate accelerating over time if (this.creatingObject !== undefined) { const obj = this.objects[this.creatingObject]; const rate = MASS_CREATION_RATE * obj.age; obj.mass += rate * elapsedTime; } // Calculate forces due to gravity. this.computeForces(); if (this.sim.playing) { // Predict positions (Velocity verlet method) this.forEachObject(obj => { obj.currentAcceleration = {...obj.acceleration}; obj.position.x += elapsedTime * (obj.velocity.x + 1/2 * obj.currentAcceleration.x * elapsedTime); obj.position.y += elapsedTime * (obj.velocity.y + 1/2 * obj.currentAcceleration.y * elapsedTime); }); // Collisions if (this.sim.getOption('collision.merge')) { this.forEachObject((A, i) => { this.forEachObject((B, j) => { const dx = (B.position.x - A.position.x); const dy = (B.position.y - A.position.y); const dSquared = dx ** 2 + dy ** 2; const d = Math.sqrt(dSquared); if (d < A.radius + B.radius) { // Merge B into A: // Set position = center of mass // Set velocity = total momentum / total mass // Combine forces // Add masses // Average color? // If B was selected or being created, select A instead // Remove B using splice A.position.x = (A.position.x * A.mass + B.position.x * B.mass) / (A.mass + B.mass); A.position.y = (A.position.y * A.mass + B.position.y * B.mass) / (A.mass + B.mass); A.velocity.x = (A.velocity.x * A.mass + B.velocity.x * B.mass) / (A.mass + B.mass); A.velocity.y = (A.velocity.y * A.mass + B.velocity.y * B.mass) / (A.mass + B.mass); A.forces.push(...B.forces); A.mass += B.mass; A.color = { r: (A.color.r + B.color.r) / 2, g: (A.color.g + B.color.g) / 2, b: (A.color.b + B.color.b) / 2, }; if (this.creatingObject === j) this.creatingObject = i; if (this.selectedObject === j) this.selectedObject = i; B.alive = false; B.forces = []; } }, true, i + 1); }); } // Recompute forces this.computeForces(); // Predict velocities this.forEachObject(obj => { const acceleration = {...obj.acceleration}; obj.acceleration = { x: (obj.currentAcceleration.x + acceleration.x) / 2, y: (obj.currentAcceleration.y + acceleration.y) / 2, }; obj.velocity.x += obj.acceleration.x * elapsedTime; obj.velocity.y += obj.acceleration.y * elapsedTime; // Append to object history // TODO: enforce object history length // TODO: store object color changes in history obj.history.push({position: {...obj.position}}); }); } // Display objects info if (DISPLAY_OBJECTS_INFO) { this.forEachObject((obj, i) => { const speed = Math.pow(obj.velocity.x ** 2 + obj.velocity.y ** 2, 1/2); // Invert y so that the angle is counterclockwise from x-axis const direction = Math.atan2(-obj.velocity.y, obj.velocity.x) * 180 / Math.PI; this.sim.info[`Object ${i}`] = [ `${obj.position.x.toPrecision(6)}, `, `${obj.position.y.toPrecision(6)}, `, `${obj.mass.toPrecision(6)} kg, `, `${speed.toPrecision(2)} m/s, ${direction.toPrecision(2)}°`, ]; }); } } }