258 lines
7.0 KiB
TypeScript
258 lines
7.0 KiB
TypeScript
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örmer–Verlet 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;
|
||
}
|
||
}
|
||
}
|
||
}
|