334 lines
9.0 KiB
TypeScript
334 lines
9.0 KiB
TypeScript
// This is a rewrite of https://github.com/guerrillacontra/html5-es6-physics-rope
|
||
|
||
import { Vector } from './common/Vector.ts';
|
||
import type { Point } from './common/types.ts';
|
||
import { DOMRectTransform } from './common/DOMRectTransform.ts';
|
||
import { FolkBaseConnection } from './folk-base-connection.ts';
|
||
import { css, PropertyValues } from '@lit/reactive-element';
|
||
import { AnimationFrameController, AnimationFrameControllerHost } from './common/animation-frame-controller.ts';
|
||
import { property } from '@lit/reactive-element/decorators.js';
|
||
|
||
// 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: Point;
|
||
distanceToNextPoint: number;
|
||
isFixed: boolean;
|
||
oldPos: Point;
|
||
velocity: Point;
|
||
mass: number;
|
||
damping: number;
|
||
prev: RopePoint | null;
|
||
next: RopePoint | null;
|
||
}
|
||
|
||
declare global {
|
||
interface HTMLElementTagNameMap {
|
||
'folk-rope': FolkRope;
|
||
}
|
||
}
|
||
|
||
export class FolkRope extends FolkBaseConnection implements AnimationFrameControllerHost {
|
||
static override tagName = 'folk-rope';
|
||
|
||
static #resolution = 5;
|
||
|
||
static styles = [
|
||
FolkBaseConnection.styles,
|
||
css`
|
||
svg {
|
||
height: 100%;
|
||
pointer-events: none;
|
||
width: 100%;
|
||
}
|
||
|
||
path {
|
||
fill: none;
|
||
pointer-events: none;
|
||
stroke: var(--folk-rope-color, black);
|
||
stroke-width: var(--folk-rope-width, 3);
|
||
stroke-linecap: var(--folk-rope-linecap, round);
|
||
}
|
||
`,
|
||
];
|
||
|
||
#rAF = new AnimationFrameController(this);
|
||
|
||
#svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||
#path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
||
#path2 = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
||
|
||
#points: RopePoint[] = [];
|
||
|
||
get points() {
|
||
return this.#points;
|
||
}
|
||
|
||
@property({ type: Object }) gravity = { x: 0, y: 3000 };
|
||
|
||
override createRenderRoot() {
|
||
const root = super.createRenderRoot();
|
||
|
||
this.#svg.append(this.#path, this.#path2);
|
||
|
||
root.appendChild(this.#svg);
|
||
|
||
return root;
|
||
}
|
||
|
||
tick() {
|
||
for (const point of this.#points) {
|
||
this.#integratePoint(point, this.gravity);
|
||
}
|
||
|
||
// 3 constraint iterations is enough for fixed timestep
|
||
for (let iteration = 0; iteration < 3; iteration++) {
|
||
for (const point of this.#points) {
|
||
this.#constrainPoint(point);
|
||
}
|
||
}
|
||
}
|
||
|
||
override update(changedProperties: PropertyValues<this>) {
|
||
super.update(changedProperties);
|
||
|
||
const { sourceRect, targetRect } = this;
|
||
|
||
if (sourceRect === null || targetRect === null) {
|
||
this.#rAF.stop();
|
||
this.#points = [];
|
||
this.#path.removeAttribute('d');
|
||
this.#path2.removeAttribute('d');
|
||
return;
|
||
}
|
||
|
||
let source: Point;
|
||
let target: Point;
|
||
|
||
if (sourceRect instanceof DOMRectTransform) {
|
||
source = sourceRect.center;
|
||
} else {
|
||
source = {
|
||
x: sourceRect.x + sourceRect.width / 2,
|
||
y: sourceRect.y + sourceRect.height / 2,
|
||
};
|
||
}
|
||
|
||
if (targetRect instanceof DOMRectTransform) {
|
||
target = targetRect.center;
|
||
} else {
|
||
target = {
|
||
x: targetRect.x + targetRect.width / 2,
|
||
y: targetRect.y + targetRect.height / 2,
|
||
};
|
||
}
|
||
|
||
if (this.#points.length === 0) {
|
||
this.#points = this.#generatePoints(source, target);
|
||
this.#rAF.start();
|
||
}
|
||
|
||
const startingPoint = this.#points.at(0);
|
||
const endingPoint = this.#points.at(-1);
|
||
|
||
if (startingPoint === undefined || endingPoint === undefined) return;
|
||
|
||
startingPoint.pos = source;
|
||
endingPoint.pos = target;
|
||
}
|
||
|
||
/** add/remove points based on distance between source and target rects */
|
||
stretch() {
|
||
if (this.sourceRect === null || this.targetRect === null || this.#points.length < 2) return;
|
||
|
||
// Calculate desired length based on source and target positions
|
||
const distance = Vector.distance(this.sourceRect, this.targetRect);
|
||
// Apply a nonlinear scaling to gradually increase point spacing with distance
|
||
const scaleFactor = Math.log10(distance + 10) / 2; // adjust the '+10' and '/2' to tune the curve
|
||
const effectiveResolution = FolkRope.#resolution * scaleFactor;
|
||
const desiredPoints = Math.floor(distance / effectiveResolution);
|
||
|
||
while (this.#points.length < desiredPoints) {
|
||
const lastPoint = this.#points.at(-1)!;
|
||
lastPoint.isFixed = false;
|
||
const newPoint = {
|
||
pos: { ...lastPoint.pos },
|
||
oldPos: { ...lastPoint.pos },
|
||
distanceToNextPoint: FolkRope.#resolution,
|
||
mass: 1,
|
||
damping: 0.99,
|
||
velocity: Vector.zero(),
|
||
isFixed: true,
|
||
prev: lastPoint,
|
||
next: null,
|
||
};
|
||
lastPoint.next = newPoint;
|
||
this.#points.push(newPoint);
|
||
}
|
||
|
||
while (this.#points.length > desiredPoints) {
|
||
this.#points.pop();
|
||
this.#points.at(-1)!.isFixed = true;
|
||
}
|
||
}
|
||
|
||
render() {
|
||
if (this.#points.length < 2) return;
|
||
|
||
let pathData = `M ${this.#points[0].pos.x} ${this.#points[0].pos.y}`;
|
||
|
||
let path2Data = '';
|
||
let isBroken = false;
|
||
|
||
for (let i = 1; i < this.#points.length; i++) {
|
||
const point = this.#points[i];
|
||
|
||
if (point.prev === null) {
|
||
isBroken = true;
|
||
path2Data = `M ${point.pos.x} ${point.pos.y}`;
|
||
} else if (isBroken) {
|
||
path2Data += ` L ${point.pos.x} ${point.pos.y}`;
|
||
} else {
|
||
pathData += ` L ${point.pos.x} ${point.pos.y}`;
|
||
}
|
||
}
|
||
|
||
this.#path.setAttribute('d', pathData);
|
||
|
||
if (path2Data) {
|
||
this.#path2.setAttribute('d', path2Data);
|
||
} else {
|
||
this.#path2.removeAttribute('d');
|
||
}
|
||
}
|
||
|
||
#generatePoints(start: Point, end: Point) {
|
||
const delta = Vector.sub(end, start);
|
||
const len = Vector.mag(delta);
|
||
const points: RopePoint[] = [];
|
||
const pointsLen = Math.floor(len / FolkRope.#resolution);
|
||
|
||
for (let i = 0; i < pointsLen; i++) {
|
||
const percentage = i / (pointsLen - 1);
|
||
const pos = Vector.lerp(start, end, percentage);
|
||
|
||
points.push({
|
||
pos,
|
||
oldPos: { ...pos },
|
||
distanceToNextPoint: FolkRope.#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;
|
||
}
|
||
|
||
#integratePoint(point: RopePoint, gravity: Point) {
|
||
if (!point.isFixed) {
|
||
point.velocity = Vector.sub(point.pos, point.oldPos);
|
||
point.oldPos = { ...point.pos };
|
||
|
||
const accel = Vector.add(gravity, { x: 0, y: point.mass });
|
||
const tsSq = this.#rAF.fixedTimestep ** 2;
|
||
|
||
point.pos.x += point.velocity.x * point.damping + accel.x * tsSq;
|
||
point.pos.y += point.velocity.y * point.damping + accel.y * tsSq;
|
||
} 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) applyConstraint(point, point.next);
|
||
if (point.prev) applyConstraint(point, point.prev);
|
||
}
|
||
|
||
#cutIndex = -1;
|
||
|
||
get isCut() {
|
||
return this.#cutIndex !== -1;
|
||
}
|
||
|
||
cut(atPercentage = 0.5) {
|
||
if (this.isCut) return;
|
||
|
||
this.#cutIndex = this.#getPointIndexAt(atPercentage);
|
||
|
||
this.#points[this.#cutIndex].next = null;
|
||
this.#points[this.#cutIndex + 1].prev = null;
|
||
this.#rAF.reset();
|
||
}
|
||
|
||
mend() {
|
||
if (!this.isCut) return;
|
||
|
||
this.#points[this.#cutIndex].next = this.#points[this.#cutIndex + 1];
|
||
this.#points[this.#cutIndex + 1].prev = this.#points[this.#cutIndex];
|
||
this.#cutIndex = -1;
|
||
this.#rAF.reset();
|
||
}
|
||
|
||
getPointAt(percentage: number) {
|
||
return this.#points[this.#getPointIndexAt(percentage)];
|
||
}
|
||
|
||
#getPointIndexAt(percentage: number) {
|
||
const clamped = Math.min(Math.max(percentage, 0), 1);
|
||
return Math.floor(this.#points.length * clamped);
|
||
}
|
||
|
||
getPercentageFromPoint(hitPoint: Point): number | null {
|
||
for (let i = 0; i < this.#points.length - 1; i++) {
|
||
const point = this.#points[i];
|
||
const nextPoint = point.next;
|
||
|
||
if (nextPoint === null) return null;
|
||
|
||
if (
|
||
Vector.distance(point.pos, hitPoint) +
|
||
Vector.distance(hitPoint, nextPoint.pos) -
|
||
Vector.distance(point.pos, nextPoint.pos) <
|
||
1
|
||
) {
|
||
return i / this.#points.length;
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function applyConstraint(p1: RopePoint, p2: RopePoint) {
|
||
const delta = Vector.sub(p2.pos, p1.pos);
|
||
const len = Vector.mag(delta);
|
||
|
||
// Prevent division by zero
|
||
if (len < 0.0001) return;
|
||
|
||
const diff = len - p1.distanceToNextPoint;
|
||
const normal = Vector.normalized(delta);
|
||
const adjustment = Vector.scale(normal, diff * 0.75);
|
||
|
||
if (!p1.isFixed) {
|
||
p1.pos = Vector.add(p1.pos, adjustment);
|
||
}
|
||
if (!p2.isFixed) {
|
||
p2.pos = Vector.sub(p2.pos, adjustment);
|
||
}
|
||
}
|