folk-canvas/src/arrows/fc-rope.ts

258 lines
7.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { AbstractArrow } from './abstract-arrow.ts';
import { Vertex } from './utils.ts';
// This is a direct port from https://github.com/guerrillacontra/html5-es6-physics-rope/blob/master/js/microlib.js
const lerp = (first, second, percentage) => first + (second - first) * percentage;
class Vector {
static zero() {
return { x: 0, y: 0 };
}
static sub(a, b) {
return { x: a.x - b.x, y: a.y - b.y };
}
static add(a, b) {
return { x: a.x + b.x, y: a.y + b.y };
}
static mult(a, b) {
return { x: a.x * b.x, y: a.y * b.y };
}
static scale(v, scaleFactor) {
return { x: v.x * scaleFactor, y: v.y * scaleFactor };
}
static mag(v) {
return Math.sqrt(v.x * v.x + v.y * v.y);
}
static normalized(v) {
const mag = Vector.mag(v);
if (mag === 0) {
return Vector.zero();
}
return { x: v.x / mag, y: v.y / mag };
}
}
// Each rope part is one of these uses a high precision variant of StörmerVerlet integration to keep the simulation consistent otherwise it would "explode"!
interface RopePoint {
pos: Vertex;
distanceToNextPoint: number;
isFixed: boolean;
oldPos: Vertex;
velocity: Vertex;
mass: number;
damping: number;
prev: RopePoint | null;
next: RopePoint | null;
}
declare global {
interface HTMLElementTagNameMap {
'fc-rope': FolkRope;
}
}
export class FolkRope extends AbstractArrow {
static override tagName = 'fc-rope';
#canvas = document.createElement('canvas');
#context = this.#canvas.getContext('2d')!;
#shadow = this.attachShadow({ mode: 'open' });
#lastTime = 0;
#currentTime = 0;
#deltaTime = 0;
#previousDelta = 0;
#interval = 1000 / 60; // ms per frame
#gravity = { x: 0, y: 3000 };
#points: RopePoint[] = [];
constructor() {
super();
this.#canvas.width = this.clientWidth;
this.#canvas.height = this.clientHeight;
this.#shadow.appendChild(this.#canvas);
this.tick = this.tick.bind(this);
}
tick(timestamp: number) {
requestAnimationFrame(this.tick);
this.#currentTime = timestamp;
this.#deltaTime = this.#currentTime - this.#lastTime;
if (this.#deltaTime > this.#interval) {
//delta time in seconds
const dts = this.#deltaTime * 0.001;
for (const point of this.#points) {
this.#integratePoint(point, this.#gravity, dts, this.#previousDelta);
}
for (let iteration = 0; iteration < 600; iteration++) {
for (const point of this.#points) {
this.#constrainPoint(point);
}
}
this.#previousDelta = dts;
this.drawRopePoints();
this.#lastTime = this.#currentTime - (this.#deltaTime % this.#interval);
}
}
render(sourceRect: DOMRectReadOnly, targetRect: DOMRectReadOnly) {
if (this.#points.length === 0) {
this.#points = this.generatePoints(
{ x: 100, y: this.#canvas.height / 2 },
{ x: this.#canvas.width - 100, y: this.#canvas.height / 2 },
5
);
this.#lastTime = 0;
this.#currentTime = 0;
this.#deltaTime = 0;
this.tick(performance.now());
}
const startingPoint = this.#points.at(0);
const endingPoint = this.#points.at(-1);
if (startingPoint === undefined || endingPoint === undefined) return;
startingPoint.pos.x = sourceRect.x + sourceRect.width / 2;
startingPoint.pos.y = sourceRect.y + sourceRect.height / 2;
endingPoint.pos.x = targetRect.x + targetRect.width / 2;
endingPoint.pos.y = targetRect.y + targetRect.height / 2;
}
drawRopePoints() {
this.#context.clearRect(0, 0, this.#canvas.width, this.#canvas.height);
for (let i = 0; i < this.#points.length; i++) {
let p = this.#points[i];
const prev = i > 0 ? this.#points[i - 1] : null;
if (prev) {
this.#context.beginPath();
this.#context.moveTo(prev.pos.x, prev.pos.y);
this.#context.lineTo(p.pos.x, p.pos.y);
this.#context.lineWidth = 2;
this.#context.strokeStyle = 'black';
this.#context.stroke();
}
}
}
generatePoints(start: Vertex, end: Vertex, resolution: number) {
const delta = Vector.sub(end, start);
const len = Vector.mag(delta);
let points: RopePoint[] = [];
const pointsLen = Math.floor(len / resolution);
for (let i = 0; i < pointsLen; i++) {
const percentage = i / (pointsLen - 1);
const pos = {
x: lerp(start.x, end.x, percentage),
y: lerp(start.y, end.y, percentage),
};
// new RopePoint({ x: lerpX, y: lerpY }, resolution, mass, damping, isFixed)
points.push({
pos,
oldPos: { ...pos },
distanceToNextPoint: resolution,
mass: 1,
damping: 0.99,
velocity: Vector.zero(),
isFixed: i === 0 || i === pointsLen - 1,
prev: null,
next: null,
});
}
//Link nodes into a doubly linked list
for (let i = 0; i < pointsLen; i++) {
const prev = i != 0 ? points[i - 1] : null;
const curr = points[i];
const next = i != pointsLen - 1 ? points[i + 1] : null;
curr.prev = prev;
curr.next = next;
}
return points;
}
// Integrate motion equations per node without taking into account relationship with other nodes...
#integratePoint(point: RopePoint, gravity, dt, previousFrameDt) {
if (!point.isFixed) {
point.velocity = Vector.sub(point.pos, point.oldPos);
point.oldPos = { ...point.pos };
//drastically improves stability
let timeCorrection = previousFrameDt != 0.0 ? dt / previousFrameDt : 0.0;
let accel = Vector.add(gravity, { x: 0, y: point.mass });
const velCoef = timeCorrection * point.damping;
const accelCoef = Math.pow(dt, 2);
point.pos.x += point.velocity.x * velCoef + accel.x * accelCoef;
point.pos.y += point.velocity.y * velCoef + accel.y * accelCoef;
} else {
point.velocity = Vector.zero();
point.oldPos = { ...point.pos };
}
}
//Apply constraints related to other nodes next to it (keeps each node within distance)
#constrainPoint(point: RopePoint) {
if (point.next) {
const delta = Vector.sub(point.next.pos, point.pos);
const len = Vector.mag(delta);
const diff = len - point.distanceToNextPoint;
const normal = Vector.normalized(delta);
if (!point.isFixed) {
point.pos.x += normal.x * diff * 0.25;
point.pos.y += normal.y * diff * 0.25;
}
if (!point.next.isFixed) {
point.next.pos.x -= normal.x * diff * 0.25;
point.next.pos.y -= normal.y * diff * 0.25;
}
}
if (point.prev) {
const delta = Vector.sub(point.prev.pos, point.pos);
const len = Vector.mag(delta);
const diff = len - point.distanceToNextPoint;
const normal = Vector.normalized(delta);
if (!point.isFixed) {
point.pos.x += normal.x * diff * 0.25;
point.pos.y += normal.y * diff * 0.25;
}
if (!point.prev.isFixed) {
point.prev.pos.x -= normal.x * diff * 0.25;
point.prev.pos.y -= normal.y * diff * 0.25;
}
}
}
}