From fff6e9a619b5c89c131adf8d678dcb040008d508 Mon Sep 17 00:00:00 2001 From: Gaston Morixe Date: Wed, 23 Oct 2024 13:18:56 -0400 Subject: [PATCH] refactor spring as entity --- index.html | 4 +- src/chart.ts | 202 +++++++++++ src/components.ts | 71 ++++ src/dom.ts | 230 +++++++++++++ src/ecs.ts | 39 +++ src/engine.ts | 66 ++++ src/main.ts | 857 ++++++---------------------------------------- src/spring.ts | 320 +++++++++++++++++ src/style.css | 23 +- src/systems.ts | 65 ++++ 10 files changed, 1123 insertions(+), 754 deletions(-) create mode 100644 src/chart.ts create mode 100644 src/components.ts create mode 100644 src/dom.ts create mode 100644 src/ecs.ts create mode 100644 src/engine.ts create mode 100644 src/spring.ts create mode 100644 src/systems.ts diff --git a/index.html b/index.html index 9c72e12..6613bf0 100644 --- a/index.html +++ b/index.html @@ -6,7 +6,9 @@ Motion -
+
1
+
2
+
3
diff --git a/src/chart.ts b/src/chart.ts new file mode 100644 index 0000000..d7dbb14 --- /dev/null +++ b/src/chart.ts @@ -0,0 +1,202 @@ +import { Entity, System } from "./ecs"; +import { AccumulatedForceComponent } from "./components"; + +import Chart, { ChartConfiguration, ChartItem } from "chart.js/auto"; + +export class ChartSystem extends System { + private chart: Chart; + private data: { + time: number; + // positionX: number; + // positionY: number; + // velocityX: number; + // velocityY: number; + // forceX: number; + // forceY: number; + accumulatedForceX: number; + accumulatedForceY: number; + }[] = []; + private time: number = 0; + private lastUpdateTime: number = 0; + private updateInterval: number = 20; // Update every X ms + private windowSize: number = 200; // Rolling window of X data points + private samplingRate: number = 2; + + constructor(chartContext: ChartItem) { + super(); + const chartConfig: ChartConfiguration<"line"> = { + type: "line", + data: { + datasets: [ + // { + // // showLine: false, + // label: "Position X", + // borderColor: "red", + // borderWidth: 1, + // data: [], + // pointStyle: "circle", + // pointRadius: 3, + // pointBorderWidth: 0, + // }, + // { + // // showLine: false, + // label: "Position Y", + // borderColor: "blue", + // borderWidth: 1, + // data: [], + // pointStyle: "circle", + // pointRadius: 3, + // pointBorderWidth: 0, + // }, + // { + // // showLine: false, + // label: "Velocity X", + // borderColor: "red", + // borderWidth: 1, + // data: [], + // pointStyle: "circle", + // pointRadius: 3, + // pointBorderWidth: 0, + // }, + // { + // // showLine: false, + // label: "Velocity Y", + // borderColor: "blue", + // borderWidth: 1, + // data: [], + // pointStyle: "circle", + // pointRadius: 3, + // pointBorderWidth: 0, + // }, + // { + // // showLine: false, + // label: "Force X", + // borderColor: "orange", + // borderWidth: 1, + // data: [], + // pointStyle: "circle", + // pointRadius: 2, + // pointBorderWidth: 0, + // }, + // { + // // showLine: false, + // label: "Force Y", + // borderColor: "brown", + // borderWidth: 1, + // data: [], + // pointStyle: "circle", + // pointRadius: 2, + // pointBorderWidth: 0, + // }, + { + // showLine: false, + label: "Accumulated Force X", + borderColor: "red", + borderWidth: 1, + data: [], + pointStyle: "circle", + pointRadius: 2, + pointBorderWidth: 0, + }, + { + // showLine: false, + label: "Accumulated Force Y", + borderColor: "blue", + borderWidth: 1, + data: [], + pointStyle: "circle", + pointRadius: 2, + pointBorderWidth: 0, + }, + ], + }, + options: { + animation: false, + responsive: true, + // stacked: false, + interaction: { intersect: false }, + scales: { + x: { type: "linear", title: { display: true, text: "Time" } }, + y: { title: { display: true, text: "Value" } }, + }, + }, + }; + this.chart = new Chart(chartContext, chartConfig); + } + + update(entities: Entity[]): void { + const currentTime = performance.now(); + + // Collect data at every update call + if (this.time % this.samplingRate === 0) { + entities + .filter((e) => e.name === "box1") + .forEach((entity) => { + // const position = entity.getComponent(PositionComponent); + // const velocity = entity.getComponent(VelocityComponent); + // const force = entity.getComponent(ForceComponent); + const accumulatedForce = entity.getComponent( + AccumulatedForceComponent, + ); + + // if (position && velocity && force) { + if (accumulatedForce) { + this.data.push({ + time: this.time, + // positionX: position.x, + // positionY: position.y, + // velocityX: velocity.vx, + // velocityY: velocity.vy, + // forceX: force.fx, + // forceY: force.fy, + accumulatedForceX: accumulatedForce.accumulatedFx, + accumulatedForceY: accumulatedForce.accumulatedFy, + }); + } + }); + } + + // Only update the chart every updateInterval (e.g., 1000ms) + if (currentTime - this.lastUpdateTime >= this.updateInterval) { + this.data.forEach((entry) => { + // this.chart.data.datasets[0].data.push({ + // x: entry.time, + // y: entry.positionX, + // }); + // this.chart.data.datasets[1].data.push({ + // x: entry.time, + // y: entry.positionY, + // }); + // this.chart.data.datasets[0].data.push({ + // x: entry.time, + // y: entry.velocityX, + // }); + // this.chart.data.datasets[1].data.push({ + // x: entry.time, + // y: entry.velocityY, + // }); + this.chart.data.datasets[0].data.push({ + x: entry.time, + y: entry.accumulatedForceX, + }); + this.chart.data.datasets[1].data.push({ + x: entry.time, + y: entry.accumulatedForceY, + }); + }); + + // Enforce rolling window by slicing the dataset to ensure it doesn't grow beyond windowSize + this.chart.data.datasets.forEach((dataset) => { + if (dataset.data.length > this.windowSize) { + dataset.data = dataset.data.slice(-this.windowSize); + } + }); + + this.chart.update(); + this.data = []; // Clear the collected data after batching it to the chart + this.lastUpdateTime = currentTime; // Update the last update time + } + + this.time++; + } +} diff --git a/src/components.ts b/src/components.ts new file mode 100644 index 0000000..b9c2ed0 --- /dev/null +++ b/src/components.ts @@ -0,0 +1,71 @@ +import { Component } from "./ecs"; + +export class PositionComponent extends Component { + x: number; + y: number; + + constructor(x: number, y: number) { + super(); + this.x = x; + this.y = y; + } +} + +export class VelocityComponent extends Component { + vx: number; + vy: number; + + constructor(vx: number = 0, vy: number = 0) { + super(); + this.vx = vx; + this.vy = vy; + } +} + +export class MassComponent extends Component { + mass: number; + + constructor(mass: number) { + super(); + this.mass = mass; + } +} + +export class ForceComponent extends Component { + fx: number = 0; + fy: number = 0; +} + +export class AccumulatedForceComponent extends Component { + accumulatedFx: number = 0; + accumulatedFy: number = 0; +} + +export class FrictionComponent extends Component { + frictionCoefficient: number; + + constructor(frictionCoefficient: number = 0.05) { + super(); + this.frictionCoefficient = frictionCoefficient; + } +} + +// export class SpringForceComponent extends Component { +// entityA: Entity; +// entityB: Entity; +// stiffness: number; +// damping: number; +// +// constructor( +// entityA: Entity, +// entityB: Entity, +// stiffness: number = 0.1, +// damping: number = 0.05, +// ) { +// super(); +// this.entityA = entityA; +// this.entityB = entityB; +// this.stiffness = stiffness; +// this.damping = damping; +// } +// } diff --git a/src/dom.ts b/src/dom.ts new file mode 100644 index 0000000..6480c49 --- /dev/null +++ b/src/dom.ts @@ -0,0 +1,230 @@ +import { Entity, Component, System } from "./ecs"; +import { + PositionComponent, + VelocityComponent, + ForceComponent, +} from "./components"; + +export class MouseForceSystem extends System { + dragStrength: number; + damping: number; + + constructor(dragStrength: number = 0.2, damping: number = 0.1) { + super(); + this.dragStrength = dragStrength; + this.damping = damping; + } + + update(entities: Entity[]) { + entities.forEach((entity) => { + const position = entity.getComponent(PositionComponent); + const velocity = entity.getComponent(VelocityComponent); + const force = entity.getComponent(ForceComponent); + const mouseDragForce = entity.getComponent(MouseDragComponent); + + if ( + position && + velocity && + force && + mouseDragForce && + mouseDragForce.isDragging + ) { + // console.log( + // "[MouseForceSystem]", + // structuredClone({ + // position, + // velocity, + // force, + // mouseDrag, + // }), + // ); + const dx = mouseDragForce.targetX - position.x; + const dy = mouseDragForce.targetY - position.y; + + // Apply force proportional to the distance (like a spring) + const fx = this.dragStrength * dx - this.damping * velocity.vx; + const fy = this.dragStrength * dy - this.damping * velocity.vy; + + // Apply the drag force + force.fx += fx; + force.fy += fy; + + // Reactivate the entity if the forces are significant + // if (Math.abs(fx) > FORCE_THRESHOLD || Math.abs(fy) > FORCE_THRESHOLD) { + // entity.setActiveState(true); + // } + } + }); + } +} + +export class MouseDragComponent extends Component { + isDragging: boolean = false; + targetX: number = 0; + targetY: number = 0; + offsetX: number = 0; // Offset from the mouse click to the box's position + offsetY: number = 0; + + setTarget(x: number, y: number) { + this.targetX = x; + this.targetY = y; + } + + startDrag(offsetX: number, offsetY: number) { + this.isDragging = true; + this.offsetX = offsetX; + this.offsetY = offsetY; + } + + stopDrag() { + this.isDragging = false; + } +} + +export class DOMComponent extends Component { + domElement: HTMLElement; + + constructor(domElement: HTMLElement) { + super(); + this.domElement = domElement; + } +} + +export class DOMUpdateSystem extends System { + private domLinks: Map = new Map(); + + // Link an entity with a DOM element + linkEntityToDOM(entity: Entity, domElement: HTMLElement) { + this.domLinks.set(entity.id, domElement); + } + + // Update the DOM element positions based on the entity's position component + update(entities: Entity[]) { + entities.forEach((entity) => { + // const position = entity.getComponent(PositionComponent); + // if (position && this.domLinks.has(entity.id)) { + // const domElement = this.domLinks.get(entity.id); + // if (domElement) { + // domElement.style.left = `${position.x}px`; + // domElement.style.top = `${position.y}px`; + // } + // } + const position = entity.getComponent(PositionComponent); + if (position && this.domLinks.has(entity.id)) { + const domElement = this.domLinks.get(entity.id); + if (domElement) { + domElement.style.transform = `translate(${position.x}px, ${position.y}px)`; + } + } + }); + } +} + +export class DOMMouseDragHandler { + initializeDragListeners(entity: Entity) { + const domComponent = entity.getComponent(DOMComponent); + const mouseDrag = entity.getComponent(MouseDragComponent); + const position = entity.getComponent(PositionComponent); + + if (!domComponent || !mouseDrag || !position) return; + + const domElement = domComponent.domElement; + + // Attach mouse event listeners + domElement.addEventListener("mousedown", (event) => + this.onMouseDown(event, mouseDrag, position, domElement), + ); + window.addEventListener("mousemove", (event) => + this.onMouseMove(event, mouseDrag), + ); + window.addEventListener("mouseup", () => + this.onMouseUp(mouseDrag, domElement), + ); + + // Attach touch event listeners + domElement.addEventListener("touchstart", (event) => + this.onTouchStart(event, mouseDrag, position, domElement), + ); + window.addEventListener("touchmove", (event) => + this.onTouchMove(event, mouseDrag), + ); + window.addEventListener("touchend", () => + this.onTouchEnd(mouseDrag, domElement), + ); + } + + onMouseDown( + event: MouseEvent, + mouseDragComponent: MouseDragComponent, + position: PositionComponent, + element: HTMLElement, + ) { + element.classList.add("dragging"); + + // Calculate the offset between the mouse position and the top-left corner of the box + const offsetX = event.clientX - position.x; + const offsetY = event.clientY - position.y; + + mouseDragComponent.startDrag(offsetX, offsetY); + mouseDragComponent.setTarget( + event.clientX - mouseDragComponent.offsetX, + event.clientY - mouseDragComponent.offsetY, + ); // Set initial target + } + + onMouseMove(event: MouseEvent, mouseDragComponent: MouseDragComponent) { + if (mouseDragComponent.isDragging) { + // Update the target, accounting for the initial offset + mouseDragComponent.setTarget( + event.clientX - mouseDragComponent.offsetX, + event.clientY - mouseDragComponent.offsetY, + ); + } + } + + onMouseUp(mouseDragComponent: MouseDragComponent, element: HTMLElement) { + element.classList.remove("dragging"); + mouseDragComponent.stopDrag(); + } + + // Touch event equivalents + onTouchStart( + event: TouchEvent, + mouseDragComponent: MouseDragComponent, + position: PositionComponent, + element: HTMLElement, + ) { + event.preventDefault(); // Prevent scrolling + + const touch = event.touches[0]; // Get the first touch point + element.classList.add("dragging"); + + // Calculate the offset between the touch position and the top-left corner of the box + const offsetX = touch.clientX - position.x; + const offsetY = touch.clientY - position.y; + + mouseDragComponent.startDrag(offsetX, offsetY); + mouseDragComponent.setTarget( + touch.clientX - mouseDragComponent.offsetX, + touch.clientY - mouseDragComponent.offsetY, + ); // Set initial target + } + + onTouchMove(event: TouchEvent, mouseDragComponent: MouseDragComponent) { + if (mouseDragComponent.isDragging) { + event.preventDefault(); // Prevent scrolling + const touch = event.touches[0]; // Get the first touch point + + // Update the target, accounting for the initial offset + mouseDragComponent.setTarget( + touch.clientX - mouseDragComponent.offsetX, + touch.clientY - mouseDragComponent.offsetY, + ); + } + } + + onTouchEnd(mouseDragComponent: MouseDragComponent, element: HTMLElement) { + element.classList.remove("dragging"); + mouseDragComponent.stopDrag(); + } +} diff --git a/src/ecs.ts b/src/ecs.ts new file mode 100644 index 0000000..da5a6ef --- /dev/null +++ b/src/ecs.ts @@ -0,0 +1,39 @@ +export const VELOCITY_THRESHOLD = 0.01; // Minimum velocity to keep updating +export const FORCE_THRESHOLD = 0.01; // Minimum force to keep applying updates + +export class Entity { + private static idCounter = 0; + id: number; + name: string | undefined; + components: Map; + // isActive: boolean; // Tracks if the entity is active or inactive + + constructor() { + this.id = Entity.idCounter++; + this.components = new Map(); + console.log("[Entity] Created entity", { entity: this }); + // this.isActive = true; // Start as active + } + + addComponent(component: Component) { + this.components.set(component.constructor.name, component); + } + + getComponent( + componentClass: new (...args: any[]) => T, + ): T | undefined { + return this.components.get(componentClass.name) as T; + } + + // setActiveState(state: boolean) { + // this.isActive = state; + // } +} + +export abstract class Component { + // All components extend from this base +} + +export abstract class System { + abstract update(entities: Entity[]): unknown; +} diff --git a/src/engine.ts b/src/engine.ts new file mode 100644 index 0000000..3f19f25 --- /dev/null +++ b/src/engine.ts @@ -0,0 +1,66 @@ +import { Entity, System } from "./ecs"; + +export class AnimationEngine { + private entities: Entity[] = []; + private systems: System[] = []; + + addEntity(entity: Entity) { + this.entities.push(entity); + } + + addSystem(system: System) { + this.systems.push(system); + } + + update() { + // let anyActive = false; + + // Update all systems, but only for active entities + this.systems.forEach((system) => { + // system.update(this.entities.filter((entity) => entity.isActive)); + system.update(this.entities); + }); + + // After updating, check if any entity should remain active + // this.entities.forEach((entity) => { + // if (this.shouldEntityRemainActive(entity)) { + // entity.setActiveState(true); + // anyActive = true; // At least one entity is active + // } else { + // entity.setActiveState(false); + // } + // }); + + // If no entities are active, the loop will simply continue without updates + // This means the engine remains running but skips unnecessary updates + } + + // Determines if an entity should remain active based on velocity and force thresholds + // shouldEntityRemainActive(entity: Entity): boolean { + // const velocity = entity.getComponent(VelocityComponent); + // const force = entity.getComponent(ForceComponent); + // + // if (velocity && force) { + // // Check if either velocity or force exceeds the threshold + // return ( + // Math.abs(velocity.vx) > VELOCITY_THRESHOLD || + // Math.abs(velocity.vy) > VELOCITY_THRESHOLD || + // Math.abs(force.fx) > FORCE_THRESHOLD || + // Math.abs(force.fy) > FORCE_THRESHOLD + // ); + // } + // + // return false; + // } + + // Animation loop, always running + private loop() { + this.update(); + requestAnimationFrame(() => this.loop()); + } + + // Start the loop on initialization + start() { + this.loop(); + } +} diff --git a/src/main.ts b/src/main.ts index 6c53509..f8a0ed1 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,777 +1,99 @@ import "./style.css"; -import Chart, { - ChartConfiguration, - ChartItem, - ChartTypeRegistry, -} from "chart.js/auto"; -// -// Component -// -abstract class Component { - // All components extend from this base -} - -class PositionComponent extends Component { - x: number; - y: number; - - constructor(x: number, y: number) { - super(); - this.x = x; - this.y = y; - } -} - -class VelocityComponent extends Component { - vx: number; - vy: number; - - constructor(vx: number = 0, vy: number = 0) { - super(); - this.vx = vx; - this.vy = vy; - } -} - -class MassComponent extends Component { - mass: number; - - constructor(mass: number) { - super(); - this.mass = mass; - } -} - -class ForceComponent extends Component { - fx: number = 0; - fy: number = 0; -} - -class AccumulatedForceComponent extends Component { - accumulatedFx: number = 0; - accumulatedFy: number = 0; -} - -class FrictionComponent extends Component { - frictionCoefficient: number; - - constructor(frictionCoefficient: number = 0.05) { - super(); - this.frictionCoefficient = frictionCoefficient; - } -} - -class MouseDragComponent extends Component { - isDragging: boolean = false; - targetX: number = 0; - targetY: number = 0; - offsetX: number = 0; // Offset from the mouse click to the box's position - offsetY: number = 0; - - setTarget(x: number, y: number) { - this.targetX = x; - this.targetY = y; - } - - startDrag(offsetX: number, offsetY: number) { - this.isDragging = true; - this.offsetX = offsetX; - this.offsetY = offsetY; - } - - stopDrag() { - this.isDragging = false; - } -} - -class SpringForceComponent extends Component { - entityA: Entity; - entityB: Entity; - stiffness: number; - damping: number; - - constructor( - entityA: Entity, - entityB: Entity, - stiffness: number = 0.1, - damping: number = 0.05, - ) { - super(); - this.entityA = entityA; - this.entityB = entityB; - this.stiffness = stiffness; - this.damping = damping; - } -} - -class DOMComponent extends Component { - domElement: HTMLElement; - - constructor(domElement: HTMLElement) { - super(); - this.domElement = domElement; - } -} - -// -// Entity -// -class Entity { - private static idCounter = 0; - id: number; - name: string | undefined; - components: Map; - // isActive: boolean; // Tracks if the entity is active or inactive - - constructor() { - this.id = Entity.idCounter++; - this.components = new Map(); - console.log("[Entity] Created entity", { entity: this }); - // this.isActive = true; // Start as active - } - - addComponent(component: Component) { - this.components.set(component.constructor.name, component); - } - - getComponent( - componentClass: new (...args: any[]) => T, - ): T | undefined { - return this.components.get(componentClass.name) as T; - } - - // setActiveState(state: boolean) { - // this.isActive = state; - // } -} - -// // Engine -// -const VELOCITY_THRESHOLD = 0.01; // Minimum velocity to keep updating -const FORCE_THRESHOLD = 0.01; // Minimum force to keep applying updates - -abstract class System { - abstract update(entities: Entity[]): unknown; -} - -class AnimationEngine { - private entities: Entity[] = []; - private systems: System[] = []; - - addEntity(entity: Entity) { - this.entities.push(entity); - } - - addSystem(system: System) { - this.systems.push(system); - } - - update() { - // let anyActive = false; - - // Update all systems, but only for active entities - this.systems.forEach((system) => { - // system.update(this.entities.filter((entity) => entity.isActive)); - system.update(this.entities); - }); - - // After updating, check if any entity should remain active - // this.entities.forEach((entity) => { - // if (this.shouldEntityRemainActive(entity)) { - // entity.setActiveState(true); - // anyActive = true; // At least one entity is active - // } else { - // entity.setActiveState(false); - // } - // }); - - // If no entities are active, the loop will simply continue without updates - // This means the engine remains running but skips unnecessary updates - } - - // Determines if an entity should remain active based on velocity and force thresholds - // shouldEntityRemainActive(entity: Entity): boolean { - // const velocity = entity.getComponent(VelocityComponent); - // const force = entity.getComponent(ForceComponent); - // - // if (velocity && force) { - // // Check if either velocity or force exceeds the threshold - // return ( - // Math.abs(velocity.vx) > VELOCITY_THRESHOLD || - // Math.abs(velocity.vy) > VELOCITY_THRESHOLD || - // Math.abs(force.fx) > FORCE_THRESHOLD || - // Math.abs(force.fy) > FORCE_THRESHOLD - // ); - // } - // - // return false; - // } - - // Animation loop, always running - private loop() { - this.update(); - requestAnimationFrame(() => this.loop()); - } - - // Start the loop on initialization - start() { - this.loop(); - } -} +import { AnimationEngine } from "./engine"; + +// ECS +import { Entity } from "./ecs"; +import { + PositionComponent, + VelocityComponent, + MassComponent, + ForceComponent, + AccumulatedForceComponent, + FrictionComponent, +} from "./components"; +import { MovementSystem, FrictionSystem } from "./systems"; + +// Specific Components and Systems +import { + MouseDragComponent, + MouseForceSystem, + DOMComponent, + DOMUpdateSystem, + DOMMouseDragHandler, +} from "./dom"; +import { SpringEntity, SpringPhysicsSystem } from "./spring"; +import { ChartSystem } from "./chart"; // -// Systems +// Application Logic // -class SpringForceSystem extends System { - update(entities: Entity[]) { - entities.forEach((entity) => { - const spring = entity.getComponent(SpringForceComponent); - if (spring) { - const positionA = spring.entityA.getComponent(PositionComponent); - const positionB = spring.entityB.getComponent(PositionComponent); - const velocityA = spring.entityA.getComponent(VelocityComponent); - const forceA = spring.entityA.getComponent(ForceComponent); - const velocityB = spring.entityB.getComponent(VelocityComponent); - const forceB = spring.entityB.getComponent(ForceComponent); - - // console.clear(); - // console.log( - // `[SpringForceSystem] log:`, - // structuredClone({ - // positionA, - // positionB, - // velocityA, - // forceA, - // velocityB, - // forceB, - // }), - // ); - - if (!positionA) { - throw new Error("PositionComponent missing for entityA"); - } - if (!positionB) { - throw new Error("PositionComponent missing for entityB"); - } - if (!velocityA) { - throw new Error("VelocityComponent missing for entityA"); - // return; - } - if (!forceA) { - throw new Error("ForceComponent missing for entityA"); - } - if (!velocityB) { - throw new Error("VelocityComponent missing for entityB"); - // return; - } - if (!forceB) { - throw new Error("ForceComponent missing for entityB"); - } - - const dx = positionB.x - positionA.x; - const dy = positionB.y - positionA.y; - - // Apply spring force proportional to the distance between the two entities - const fx = spring.stiffness * dx - spring.damping * velocityA.vx; - const fy = spring.stiffness * dy - spring.damping * velocityA.vy; - - // Apply force to entity A (pulling towards entity B) - forceA.fx += fx; - forceA.fy += fy; - - // Apply equal and opposite force to entity B (pulling towards entity A) - forceB.fx -= fx; - forceB.fy -= fy; - - // console.log("[SpringForceSystem]", { - // forceA: forceA, - // forceB: forceB, - // dx: dx, - // dy: dy, - // fx: fx, - // fy: fy, - // }); - } - }); - } -} - -class FrictionSystem extends System { - update(entities: Entity[]) { - entities.forEach((entity) => { - const velocity = entity.getComponent(VelocityComponent); - const friction = entity.getComponent(FrictionComponent); - const force = entity.getComponent(ForceComponent); - - if (velocity && friction && force) { - // Apply friction force opposite to the direction of the velocity - const speed = Math.sqrt( - velocity.vx * velocity.vx + velocity.vy * velocity.vy, - ); - if (speed > VELOCITY_THRESHOLD) { - const frictionMagnitude = friction.frictionCoefficient * speed; - const fx = -(velocity.vx / speed) * frictionMagnitude; - const fy = -(velocity.vy / speed) * frictionMagnitude; - - force.fx += fx; - force.fy += fy; - } - } - }); - } -} - -class MovementSystem extends System { - update(entities: Entity[]) { - entities.forEach((entity) => { - const position = entity.getComponent(PositionComponent); - const velocity = entity.getComponent(VelocityComponent); - const force = entity.getComponent(ForceComponent); - const mass = entity.getComponent(MassComponent); - const accumulatedForce = entity.getComponent(AccumulatedForceComponent); - - if (position && velocity && force && mass && accumulatedForce) { - const ax = force.fx / mass.mass; - const ay = force.fy / mass.mass; - - velocity.vx += ax; - velocity.vy += ay; - - position.x += velocity.vx; - position.y += velocity.vy; - - // Record the accumulated force before resetting - accumulatedForce.accumulatedFx = force.fx; - accumulatedForce.accumulatedFy = force.fy; - - // Reset forces after applying - force.fx = 0; - force.fy = 0; - } - }); - } -} - -class MouseForceSystem extends System { - dragStrength: number; - damping: number; - - constructor(dragStrength: number = 0.2, damping: number = 0.1) { - super(); - this.dragStrength = dragStrength; - this.damping = damping; - } - - update(entities: Entity[]) { - entities.forEach((entity) => { - const position = entity.getComponent(PositionComponent); - const velocity = entity.getComponent(VelocityComponent); - const force = entity.getComponent(ForceComponent); - const mouseDragForce = entity.getComponent(MouseDragComponent); - - if ( - position && - velocity && - force && - mouseDragForce && - mouseDragForce.isDragging - ) { - // console.log( - // "[MouseForceSystem]", - // structuredClone({ - // position, - // velocity, - // force, - // mouseDrag, - // }), - // ); - const dx = mouseDragForce.targetX - position.x; - const dy = mouseDragForce.targetY - position.y; - - // Apply force proportional to the distance (like a spring) - const fx = this.dragStrength * dx - this.damping * velocity.vx; - const fy = this.dragStrength * dy - this.damping * velocity.vy; - - // Apply the drag force - force.fx += fx; - force.fy += fy; - - // Reactivate the entity if the forces are significant - // if (Math.abs(fx) > FORCE_THRESHOLD || Math.abs(fy) > FORCE_THRESHOLD) { - // entity.setActiveState(true); - // } - } - }); - } -} - -class DOMUpdateSystem extends System { - private domLinks: Map = new Map(); - - // Link an entity with a DOM element - linkEntityToDOM(entity: Entity, domElement: HTMLElement) { - this.domLinks.set(entity.id, domElement); - } - - // Update the DOM element positions based on the entity's position component - update(entities: Entity[]) { - entities.forEach((entity) => { - // const position = entity.getComponent(PositionComponent); - // if (position && this.domLinks.has(entity.id)) { - // const domElement = this.domLinks.get(entity.id); - // if (domElement) { - // domElement.style.left = `${position.x}px`; - // domElement.style.top = `${position.y}px`; - // } - // } - const position = entity.getComponent(PositionComponent); - if (position && this.domLinks.has(entity.id)) { - const domElement = this.domLinks.get(entity.id); - if (domElement) { - domElement.style.transform = `translate(${position.x}px, ${position.y}px)`; - } - } - }); - } -} - -class ChartSystem extends System { - private chart: Chart; - private data: { - time: number; - // positionX: number; - // positionY: number; - // velocityX: number; - // velocityY: number; - // forceX: number; - // forceY: number; - accumulatedForceX: number; - accumulatedForceY: number; - }[] = []; - private time: number = 0; - private lastUpdateTime: number = 0; - private updateInterval: number = 20; // Update every X ms - private windowSize: number = 200; // Rolling window of X data points - private samplingRate: number = 2; - - constructor(chartContext: ChartItem) { - super(); - const chartConfig: ChartConfiguration<"line"> = { - type: "line", - data: { - datasets: [ - // { - // // showLine: false, - // label: "Position X", - // borderColor: "red", - // borderWidth: 1, - // data: [], - // pointStyle: "circle", - // pointRadius: 3, - // pointBorderWidth: 0, - // }, - // { - // // showLine: false, - // label: "Position Y", - // borderColor: "blue", - // borderWidth: 1, - // data: [], - // pointStyle: "circle", - // pointRadius: 3, - // pointBorderWidth: 0, - // }, - // { - // // showLine: false, - // label: "Velocity X", - // borderColor: "red", - // borderWidth: 1, - // data: [], - // pointStyle: "circle", - // pointRadius: 3, - // pointBorderWidth: 0, - // }, - // { - // // showLine: false, - // label: "Velocity Y", - // borderColor: "blue", - // borderWidth: 1, - // data: [], - // pointStyle: "circle", - // pointRadius: 3, - // pointBorderWidth: 0, - // }, - // { - // // showLine: false, - // label: "Force X", - // borderColor: "orange", - // borderWidth: 1, - // data: [], - // pointStyle: "circle", - // pointRadius: 2, - // pointBorderWidth: 0, - // }, - // { - // // showLine: false, - // label: "Force Y", - // borderColor: "brown", - // borderWidth: 1, - // data: [], - // pointStyle: "circle", - // pointRadius: 2, - // pointBorderWidth: 0, - // }, - { - // showLine: false, - label: "Accumulated Force X", - borderColor: "red", - borderWidth: 1, - data: [], - pointStyle: "circle", - pointRadius: 2, - pointBorderWidth: 0, - }, - { - // showLine: false, - label: "Accumulated Force Y", - borderColor: "blue", - borderWidth: 1, - data: [], - pointStyle: "circle", - pointRadius: 2, - pointBorderWidth: 0, - }, - ], - }, - options: { - animation: false, - responsive: true, - // stacked: false, - interaction: { intersect: false }, - scales: { - x: { type: "linear", title: { display: true, text: "Time" } }, - y: { title: { display: true, text: "Value" } }, - }, - }, - }; - this.chart = new Chart(chartContext, chartConfig); - } - - update(entities: Entity[]): void { - const currentTime = performance.now(); - - // Collect data at every update call - if (this.time % this.samplingRate === 0) { - entities - .filter((e) => e.name === "box") - .forEach((entity) => { - // const position = entity.getComponent(PositionComponent); - // const velocity = entity.getComponent(VelocityComponent); - // const force = entity.getComponent(ForceComponent); - const accumulatedForce = entity.getComponent( - AccumulatedForceComponent, - ); - - // if (position && velocity && force) { - if (accumulatedForce) { - this.data.push({ - time: this.time, - // positionX: position.x, - // positionY: position.y, - // velocityX: velocity.vx, - // velocityY: velocity.vy, - // forceX: force.fx, - // forceY: force.fy, - accumulatedForceX: accumulatedForce.accumulatedFx, - accumulatedForceY: accumulatedForce.accumulatedFy, - }); - } - }); - } - - // Only update the chart every updateInterval (e.g., 1000ms) - if (currentTime - this.lastUpdateTime >= this.updateInterval) { - this.data.forEach((entry) => { - // this.chart.data.datasets[0].data.push({ - // x: entry.time, - // y: entry.positionX, - // }); - // this.chart.data.datasets[1].data.push({ - // x: entry.time, - // y: entry.positionY, - // }); - // this.chart.data.datasets[0].data.push({ - // x: entry.time, - // y: entry.velocityX, - // }); - // this.chart.data.datasets[1].data.push({ - // x: entry.time, - // y: entry.velocityY, - // }); - this.chart.data.datasets[0].data.push({ - x: entry.time, - y: entry.accumulatedForceX, - }); - this.chart.data.datasets[1].data.push({ - x: entry.time, - y: entry.accumulatedForceY, - }); - }); - - // Enforce rolling window by slicing the dataset to ensure it doesn't grow beyond windowSize - this.chart.data.datasets.forEach((dataset) => { - if (dataset.data.length > this.windowSize) { - dataset.data = dataset.data.slice(-this.windowSize); - } - }); - - this.chart.update(); - this.data = []; // Clear the collected data after batching it to the chart - this.lastUpdateTime = currentTime; // Update the last update time - } - - this.time++; - } -} - -class DOMMouseDragHandler { - initializeDragListeners(entity: Entity) { - const domComponent = entity.getComponent(DOMComponent); - const mouseDrag = entity.getComponent(MouseDragComponent); - const position = entity.getComponent(PositionComponent); - - if (!domComponent || !mouseDrag || !position) return; - - const domElement = domComponent.domElement; - - // Attach mouse event listeners - domElement.addEventListener("mousedown", (event) => - this.onMouseDown(event, mouseDrag, position, domElement), - ); - window.addEventListener("mousemove", (event) => - this.onMouseMove(event, mouseDrag), - ); - window.addEventListener("mouseup", () => - this.onMouseUp(mouseDrag, domElement), - ); - - // Attach touch event listeners - domElement.addEventListener("touchstart", (event) => - this.onTouchStart(event, mouseDrag, position, domElement), - ); - window.addEventListener("touchmove", (event) => - this.onTouchMove(event, mouseDrag), - ); - window.addEventListener("touchend", () => - this.onTouchEnd(mouseDrag, domElement), - ); - } - - onMouseDown( - event: MouseEvent, - mouseDragComponent: MouseDragComponent, - position: PositionComponent, - element: HTMLElement, - ) { - element.classList.add("dragging"); - - // Calculate the offset between the mouse position and the top-left corner of the box - const offsetX = event.clientX - position.x; - const offsetY = event.clientY - position.y; - - mouseDragComponent.startDrag(offsetX, offsetY); - mouseDragComponent.setTarget( - event.clientX - mouseDragComponent.offsetX, - event.clientY - mouseDragComponent.offsetY, - ); // Set initial target - } - - onMouseMove(event: MouseEvent, mouseDragComponent: MouseDragComponent) { - if (mouseDragComponent.isDragging) { - // Update the target, accounting for the initial offset - mouseDragComponent.setTarget( - event.clientX - mouseDragComponent.offsetX, - event.clientY - mouseDragComponent.offsetY, - ); - } - } - - onMouseUp(mouseDragComponent: MouseDragComponent, element: HTMLElement) { - element.classList.remove("dragging"); - mouseDragComponent.stopDrag(); - } - - // Touch event equivalents - onTouchStart( - event: TouchEvent, - mouseDragComponent: MouseDragComponent, - position: PositionComponent, - element: HTMLElement, - ) { - event.preventDefault(); // Prevent scrolling - - const touch = event.touches[0]; // Get the first touch point - element.classList.add("dragging"); - - // Calculate the offset between the touch position and the top-left corner of the box - const offsetX = touch.clientX - position.x; - const offsetY = touch.clientY - position.y; - - mouseDragComponent.startDrag(offsetX, offsetY); - mouseDragComponent.setTarget( - touch.clientX - mouseDragComponent.offsetX, - touch.clientY - mouseDragComponent.offsetY, - ); // Set initial target - } - - onTouchMove(event: TouchEvent, mouseDragComponent: MouseDragComponent) { - if (mouseDragComponent.isDragging) { - event.preventDefault(); // Prevent scrolling - const touch = event.touches[0]; // Get the first touch point - - // Update the target, accounting for the initial offset - mouseDragComponent.setTarget( - touch.clientX - mouseDragComponent.offsetX, - touch.clientY - mouseDragComponent.offsetY, - ); - } - } - - onTouchEnd(mouseDragComponent: MouseDragComponent, element: HTMLElement) { - element.classList.remove("dragging"); - mouseDragComponent.stopDrag(); - } -} - // Create the ECS engine const engine = new AnimationEngine(); // Create the box entity const boxEntity = new Entity(); -boxEntity.name = "box"; +boxEntity.name = "box1"; boxEntity.addComponent(new PositionComponent(100, 100)); // Initial position boxEntity.addComponent(new VelocityComponent(0, 0)); // Initial velocity boxEntity.addComponent(new MassComponent(1)); // Mass of the entity boxEntity.addComponent(new ForceComponent()); // Force acting on the entity -boxEntity.addComponent(new AccumulatedForceComponent()); // Force acting on the entity +boxEntity.addComponent(new AccumulatedForceComponent()); // Force acting on the entity TODO: may not be needed boxEntity.addComponent(new MouseDragComponent()); // Component for mouse dragging boxEntity.addComponent(new FrictionComponent(0.05)); -const boxElement = document.getElementById("box") as HTMLElement; +const boxElement = document.getElementById("box1") as HTMLElement; boxEntity.addComponent(new DOMComponent(boxElement)); // Creating the spring force const anchorEntity = new Entity(); +anchorEntity.name = "anchor"; anchorEntity.addComponent(new PositionComponent(100, 100)); // Fixed point for the spring anchorEntity.addComponent(new VelocityComponent(0, 0)); // Initial velocity of the anchor point anchorEntity.addComponent(new ForceComponent()); // Force acting on the anchor point anchorEntity.addComponent(new AccumulatedForceComponent()); // Force acting on the anchor point -// Add spring force to the entity, attached between box and anchor -boxEntity.addComponent( - new SpringForceComponent(boxEntity, anchorEntity, 0.2, 0.05), -); + +// Create a spring entity that connects entityA and entityB +const springEntity = new SpringEntity(boxEntity, anchorEntity, 0.2, 0.05, 1.0); +springEntity.name = "spring"; + +// Create second box entity +const boxEntity2 = new Entity(); +boxEntity2.name = "box2"; +boxEntity2.addComponent(new PositionComponent(250, 100)); // Initial position +boxEntity2.addComponent(new VelocityComponent(0, 0)); // Initial velocity +boxEntity2.addComponent(new MassComponent(1)); // Mass of the entity +boxEntity2.addComponent(new ForceComponent()); // Force acting on the entity +boxEntity2.addComponent(new AccumulatedForceComponent()); // Force acting on the entity TODO: may not be needed +boxEntity2.addComponent(new MouseDragComponent()); // Component for mouse dragging +boxEntity2.addComponent(new FrictionComponent(0.05)); + +const boxElement2 = document.getElementById("box2") as HTMLElement; +boxEntity2.addComponent(new DOMComponent(boxElement2)); + +// Creating the spring force connecting box and box2 +const springEntity2 = new SpringEntity(boxEntity, boxEntity2, 0.2, 0.05, 2.0); +springEntity2.name = "spring2"; + +// Create third box entity +const boxEntity3 = new Entity(); +boxEntity3.name = "box3"; +boxEntity3.addComponent(new PositionComponent(400, 100)); // Initial position +boxEntity3.addComponent(new VelocityComponent(0, 0)); // Initial velocity +boxEntity3.addComponent(new MassComponent(1)); // Mass of the entity +boxEntity3.addComponent(new ForceComponent()); // Force acting on the entity +boxEntity3.addComponent(new AccumulatedForceComponent()); // Force acting on the entity +boxEntity3.addComponent(new MouseDragComponent()); // Component for mouse dragging +boxEntity3.addComponent(new FrictionComponent(0.05)); + +const boxElement3 = document.getElementById("box3") as HTMLElement; +boxEntity3.addComponent(new DOMComponent(boxElement3)); + +// Creating the spring force connecting box2 and box3 +const springEntity3 = new SpringEntity(boxEntity2, boxEntity3, 0.1, 0.05, 1.0); +springEntity3.name = "spring3"; // Set up the movement system (handles physics and movement) const movementSystem = new MovementSystem(); @@ -779,8 +101,8 @@ const movementSystem = new MovementSystem(); // Creating the friction component and system const frictionSystem = new FrictionSystem(); -// Spring Force System -const springForceSystem = new SpringForceSystem(); +// Spring physics system +const springPhysicsSystem = new SpringPhysicsSystem(); // Set up the mouse force system (handles the spring-like dragging effect) const mouseForceSystem = new MouseForceSystem(0.2, 0.1); // Drag strength and damping @@ -788,24 +110,37 @@ const mouseForceSystem = new MouseForceSystem(0.2, 0.1); // Drag strength and da // Set up the DOM update system (handles syncing the DOM with the entity position) const domUpdateSystem = new DOMUpdateSystem(); domUpdateSystem.linkEntityToDOM(boxEntity, boxElement); +domUpdateSystem.linkEntityToDOM(boxEntity2, boxElement2); +domUpdateSystem.linkEntityToDOM(boxEntity3, boxElement3); // Set up the DOM mouse drag handler to handle mouse events via the DOM component const domMouseDragHandler = new DOMMouseDragHandler(); domMouseDragHandler.initializeDragListeners(boxEntity); +const domMouseDragHandler2 = new DOMMouseDragHandler(); +domMouseDragHandler2.initializeDragListeners(boxEntity2); + +const domMouseDragHandler3 = new DOMMouseDragHandler(); +domMouseDragHandler3.initializeDragListeners(boxEntity3); + // Add Entities to the engine engine.addEntity(anchorEntity); engine.addEntity(boxEntity); +engine.addEntity(boxEntity2); +engine.addEntity(boxEntity3); +engine.addEntity(springEntity); +engine.addEntity(springEntity2); +engine.addEntity(springEntity3); // Add systems to the engine -engine.addSystem(springForceSystem); +engine.addSystem(springPhysicsSystem); engine.addSystem(frictionSystem); engine.addSystem(mouseForceSystem); engine.addSystem(movementSystem); engine.addSystem(domUpdateSystem); // Initialize the chart system -const chartContext = document.getElementById("chart") as ChartItem; +const chartContext = document.getElementById("chart") as HTMLCanvasElement; if (chartContext) { const chartSystem = new ChartSystem(chartContext); engine.addSystem(chartSystem); @@ -813,3 +148,23 @@ if (chartContext) { // Start the engine engine.start(); + +// TODO +// -[ ] Prepare specific entities kinds (anchor, box, etc). The components should be added in the constructor like the spring, this minimizes the amount of components to add manually to an entity. +// -[ ] Cleanup creation examples, it's easy to forget to add entities to the engine if not added right after creation. +// -[ ] Take initial and drop velocity into account +// -[ ] Attach multiple entities to the spring force. Make a cloth like simulation. +// -[ ] Add a start and stop button to the simulation. +// -[ ] Render springs +// -[ ] On spring click it will remove them. +// -[ ] On box click it will fix it. how? a very strong spring force or any strong force.? +// -[ ] Make the forces method "add" or "apply" instead of directly mutating it +// -[ ] Use verlet integration instead of Euler integration. +// -[ ] Measure performance and optimize it wherever possible. +// -[ ] If it's a DOM element write itself in web, or canvas, etc, make this abstract and platform independent. +// -[ ] Make the dragging interaction or any input interaction abstract and platform independent. +// -[ ] Make the chart system abstract and platform independent. +// -[ ] Make the mouse force be a spring by using spring component/entities/systems. +// -[ ] Add collision system. +// -[ ] Add magnetic force system. +// -[ ] Add wind force diff --git a/src/spring.ts b/src/spring.ts new file mode 100644 index 0000000..2957eb7 --- /dev/null +++ b/src/spring.ts @@ -0,0 +1,320 @@ +import { Component, Entity, System } from "./ecs"; +import { + PositionComponent, + VelocityComponent, + ForceComponent, +} from "./components"; + +// Define SpringComponent +export class SpringComponent extends Component { + stiffness: number; + damping: number; + restLengthRatio: number; // Ratio of rest length relative to initial length + initialLength?: number; // Initial length derived from connected entities + + constructor( + stiffness: number, + damping: number, + restLengthRatio: number = 1.0, + ) { + super(); + this.stiffness = stiffness; + this.damping = damping; + this.restLengthRatio = restLengthRatio; + } +} + +// Define SpringEndpointsComponent to store references to connected entities +class SpringEndpointsComponent extends Component { + entityA: Entity; + entityB: Entity; + + constructor(entityA: Entity, entityB: Entity) { + super(); + this.entityA = entityA; + this.entityB = entityB; + } +} + +// Define SpringEntity to represent a spring force between two entities +export class SpringEntity extends Entity { + constructor( + entityA: Entity, + entityB: Entity, + stiffness: number, + damping: number, + restLengthRatio: number, + ) { + super(); + // Add SpringComponent to the spring entity + this.addComponent(new SpringComponent(stiffness, damping, restLengthRatio)); + // Add SpringEndpointsComponent to store references to connected entities + this.addComponent(new SpringEndpointsComponent(entityA, entityB)); + } +} + +export class SpringPhysicsSystem extends System { + update(entities: Entity[]): void { + entities.forEach((entity) => { + const springComponent = entity.getComponent(SpringComponent); + const endpoints = entity.getComponent(SpringEndpointsComponent); + + if (springComponent && endpoints) { + const positionA = endpoints.entityA.getComponent(PositionComponent); + const positionB = endpoints.entityB.getComponent(PositionComponent); + const velocityA = endpoints.entityA.getComponent(VelocityComponent); + const velocityB = endpoints.entityB.getComponent(VelocityComponent); + const forceA = endpoints.entityA.getComponent(ForceComponent); + const forceB = endpoints.entityB.getComponent(ForceComponent); + + if ( + positionA && + positionB && + velocityA && + velocityB && + forceA && + forceB + ) { + // Calculate the current length between the connected entities + const dx = positionB.x - positionA.x; + const dy = positionB.y - positionA.y; + const currentLength = Math.sqrt(dx * dx + dy * dy); + + // Prevent division by zero when currentLength is zero + if (currentLength === 0) { + // console.warn( + // "[SpringPhysicsSystem] Current length is zero, skipping force calculation.", + // ); + return; + } + + // Calculate the initial length if not yet set + if (springComponent.initialLength === undefined) { + springComponent.initialLength = currentLength; + } + + // Calculate the rest length using the restLengthRatio + const restLength = + springComponent.restLengthRatio * springComponent.initialLength; + + // Calculate the spring force magnitude using Hooke's Law + const springForceMagnitude = + springComponent.stiffness * (currentLength - restLength); + + // Calculate the spring forces in x and y directions + const fx = springForceMagnitude * (dx / currentLength); + const fy = springForceMagnitude * (dy / currentLength); + + // Apply damping to the velocities directly along each axis + const dampingForceX = -springComponent.damping * velocityA.vx; + const dampingForceY = -springComponent.damping * velocityA.vy; + + // Total forces in the direction of each axis + const totalForceX = fx + dampingForceX; + const totalForceY = fy + dampingForceY; + + // Apply equal and opposite forces to each connected entity + forceA.fx += totalForceX; + forceA.fy += totalForceY; + forceB.fx -= totalForceX; + forceB.fy -= totalForceY; + + // Logging for debugging + // console.log("[SpringPhysicsSystem]", { + // currentLength, + // restLength, + // springForceMagnitude, + // fx, + // fy, + // dampingForceX, + // dampingForceY, + // totalForceX, + // totalForceY, + // }); + } + } + }); + } +} + +// Define the SpringPhysicsSystem +// export class SpringPhysicsSystem extends System { +// update(entities: Entity[]): void { +// entities.forEach((entity) => { +// const springComponent = entity.getComponent(SpringComponent); +// const endpoints = entity.getComponent(SpringEndpointsComponent); +// +// if (springComponent && endpoints) { +// const positionA = endpoints.entityA.getComponent(PositionComponent); +// const positionB = endpoints.entityB.getComponent(PositionComponent); +// const velocityA = endpoints.entityA.getComponent(VelocityComponent); +// const velocityB = endpoints.entityB.getComponent(VelocityComponent); +// const forceA = endpoints.entityA.getComponent(ForceComponent); +// const forceB = endpoints.entityB.getComponent(ForceComponent); +// +// if ( +// positionA && +// positionB && +// velocityA && +// velocityB && +// forceA && +// forceB +// ) { +// // Calculate the current length between the connected entities +// const dx = positionB.x - positionA.x; +// const dy = positionB.y - positionA.y; +// const currentLength = Math.sqrt(dx * dx + dy * dy); +// +// // Prevent division by zero when currentLength is zero +// if (currentLength === 0) { +// console.warn( +// "[SpringPhysicsSystem] Current length is zero, skipping force calculation.", +// ); +// return; +// } +// +// // Calculate the initial length if not yet set +// if (springComponent.initialLength === undefined) { +// springComponent.initialLength = currentLength; +// } +// +// // Calculate the rest length using the restLengthRatio +// const restLength = +// springComponent.restLengthRatio * springComponent.initialLength; +// +// // Calculate the spring force magnitude using Hooke's Law +// const springForceMagnitude = +// springComponent.stiffness * (currentLength - restLength); +// +// // Normalize the direction vector +// const lengthInverse = 1 / currentLength; +// const directionX = dx * lengthInverse; +// const directionY = dy * lengthInverse; +// +// // Calculate the relative velocity along the spring direction +// const relativeVelocityX = velocityB.vx - velocityA.vx; +// const relativeVelocityY = velocityB.vy - velocityA.vy; +// const relativeVelocityAlongSpring = +// relativeVelocityX * directionX + relativeVelocityY * directionY; +// +// // Apply damping force along the spring direction +// const dampingForce = +// -springComponent.damping * relativeVelocityAlongSpring; +// const dampingForceX = dampingForce * directionX; +// const dampingForceY = dampingForce * directionY; +// +// // Total forces in the direction of the spring +// const forceX = springForceMagnitude * directionX + dampingForceX; +// const forceY = springForceMagnitude * directionY + dampingForceY; +// +// // Apply equal and opposite forces to each connected entity +// forceA.fx += forceX; +// forceA.fy += forceY; +// forceB.fx -= forceX; +// forceB.fy -= forceY; +// +// // Logging for debugging +// console.log("[SpringPhysicsSystem]", { +// currentLength, +// restLength, +// springForceMagnitude, +// directionX, +// directionY, +// dampingForceX, +// dampingForceY, +// forceX, +// forceY, +// }); +// } +// } +// }); +// } +// } + +// Define SpringPhysicsSystem to calculate spring forces between +// export class SpringPhysicsSystem extends System { +// update(entities: Entity[]): void { +// entities.forEach((entity) => { +// const springComponent = entity.getComponent(SpringComponent); +// const endpoints = entity.getComponent(SpringEndpointsComponent); +// +// if (springComponent && endpoints) { +// const positionA = endpoints.entityA.getComponent(PositionComponent); +// const positionB = endpoints.entityB.getComponent(PositionComponent); +// const velocityA = endpoints.entityA.getComponent(VelocityComponent); +// const velocityB = endpoints.entityB.getComponent(VelocityComponent); +// const forceA = endpoints.entityA.getComponent(ForceComponent); +// const forceB = endpoints.entityB.getComponent(ForceComponent); +// +// if ( +// positionA && +// positionB && +// velocityA && +// velocityB && +// forceA && +// forceB +// ) { +// // Calculate the current length between the connected entities +// const dx = positionB.x - positionA.x; +// const dy = positionB.y - positionA.y; +// const currentLength = Math.sqrt(dx * dx + dy * dy); +// +// // Prevent division by zero when currentLength is zero +// if (currentLength === 0) { +// console.warn( +// "[SpringPhysicsSystem] Current length is zero, skipping force calculation.", +// ); +// return; +// } +// +// // Calculate the initial length if not yet set +// if (springComponent.initialLength === undefined) { +// springComponent.initialLength = currentLength; +// } +// +// // Calculate the rest length using the restLengthRatio +// const restLength = +// springComponent.restLengthRatio * springComponent.initialLength; +// +// // Calculate the spring force magnitude using Hooke's Law +// const springForceMagnitude = +// springComponent.stiffness * (currentLength - restLength); +// +// // Normalize the direction vector +// const lengthInverse = 1 / currentLength; +// const directionX = dx * lengthInverse; +// const directionY = dy * lengthInverse; +// +// // Apply damping to the velocities +// const relativeVelocityX = velocityB.vx - velocityA.vx; +// const relativeVelocityY = velocityB.vy - velocityA.vy; +// const dampingForceX = -springComponent.damping * relativeVelocityX; +// const dampingForceY = -springComponent.damping * relativeVelocityY; +// +// // Total forces in the direction of the spring +// const forceX = springForceMagnitude * directionX + dampingForceX; +// const forceY = springForceMagnitude * directionY + dampingForceY; +// +// // Apply equal and opposite forces to each connected entity +// forceA.fx += forceX; +// forceA.fy += forceY; +// forceB.fx -= forceX; +// forceB.fy -= forceY; +// +// // Logging for debugging +// console.log("[SpringPhysicsSystem]", { +// currentLength, +// restLength, +// springForceMagnitude, +// directionX, +// directionY, +// dampingForceX, +// dampingForceY, +// forceX, +// forceY, +// }); +// } +// } +// }); +// } +// } diff --git a/src/style.css b/src/style.css index c9cbfb3..75bd84f 100644 --- a/src/style.css +++ b/src/style.css @@ -13,8 +13,9 @@ -moz-osx-font-smoothing: grayscale; } -#box { - background-color: blue; +.box { + --bg-color: blue; + background-color: var(--bg-color); width: 100px; height: 100px; position: absolute; @@ -22,12 +23,30 @@ left: 0; display: flex; flex-direction: column; + user-select: none; cursor: grab; + z-index: 1; + box-shadow: 0 0 10px rgba(0, 0, 0, 0); + transition: box-shadow 0.2s ease; + border-radius: 10px; + align-items: center; + justify-content: center; + font-size: 1.5rem; + font-weight: 600; &.dragging { cursor: grabbing; + box-shadow: 0 15px 15px rgba(0, 0, 0, 0.15); } } +#box2 { + --bg-color: red; +} + +#box3 { + --bg-color: green; +} + #chart { pointer-events: none; user-select: none; diff --git a/src/systems.ts b/src/systems.ts new file mode 100644 index 0000000..e89f767 --- /dev/null +++ b/src/systems.ts @@ -0,0 +1,65 @@ +import { Entity, System, VELOCITY_THRESHOLD } from "./ecs"; +import { + VelocityComponent, + PositionComponent, + ForceComponent, + FrictionComponent, + MassComponent, + AccumulatedForceComponent, +} from "./components"; + +export class FrictionSystem extends System { + update(entities: Entity[]) { + entities.forEach((entity) => { + const velocity = entity.getComponent(VelocityComponent); + const friction = entity.getComponent(FrictionComponent); + const force = entity.getComponent(ForceComponent); + + if (velocity && friction && force) { + // Apply friction force opposite to the direction of the velocity + const speed = Math.sqrt( + velocity.vx * velocity.vx + velocity.vy * velocity.vy, + ); + if (speed > VELOCITY_THRESHOLD) { + const frictionMagnitude = friction.frictionCoefficient * speed; + const fx = -(velocity.vx / speed) * frictionMagnitude; + const fy = -(velocity.vy / speed) * frictionMagnitude; + + force.fx += fx; + force.fy += fy; + } + } + }); + } +} + +export class MovementSystem extends System { + update(entities: Entity[]) { + entities.forEach((entity) => { + const position = entity.getComponent(PositionComponent); + const velocity = entity.getComponent(VelocityComponent); + const force = entity.getComponent(ForceComponent); + const mass = entity.getComponent(MassComponent); + const accumulatedForce = entity.getComponent(AccumulatedForceComponent); + + if (position && velocity && force && mass && accumulatedForce) { + const ax = force.fx / mass.mass; + const ay = force.fy / mass.mass; + + velocity.vx += ax; + velocity.vy += ay; + + position.x += velocity.vx; + position.y += velocity.vy; + + // Record the accumulated force before resetting + accumulatedForce.accumulatedFx = force.fx; + accumulatedForce.accumulatedFy = force.fy; + + // Reset forces after applying + force.fx = 0; + force.fy = 0; + } + }); + } +}