403 lines
17 KiB
JavaScript
403 lines
17 KiB
JavaScript
"use strict";
|
|
|
|
// Constraint solver for link-like tools.
|
|
// Owns attachment resolution and distance constraints for flexible ropes and rigid rods.
|
|
(function (global) {
|
|
function reciprocatorAxis(item) {
|
|
if (global.TarinaiMechanicalSystem?.axis) return global.TarinaiMechanicalSystem.axis(item);
|
|
const fallback = typeof itemAngleFor === "function" ? itemAngleFor(item) : (Number(item?.angle) || 0);
|
|
const a = typeof normalizedItemAngle === "function" ? normalizedItemAngle(item?.reciprocatorAxisAngle, fallback) : (Number.isFinite(Number(item?.reciprocatorAxisAngle)) ? Number(item.reciprocatorAxisAngle) : fallback);
|
|
return { x: Math.cos(a), y: Math.sin(a), angle: a };
|
|
}
|
|
|
|
function reciprocatorHalfTravel(item) {
|
|
return Math.max(24, Number(item?.reciprocatorTravel || 150) || 150) * 0.5;
|
|
}
|
|
|
|
function resetReciprocatorAnchorIfMissing(item) {
|
|
if (!Number.isFinite(Number(item.reciprocatorAnchorX))) item.reciprocatorAnchorX = item.x || 0;
|
|
if (!Number.isFinite(Number(item.reciprocatorAnchorY))) item.reciprocatorAnchorY = item.y || 0;
|
|
}
|
|
|
|
function reciprocatorPhaseFromPosition(item) {
|
|
resetReciprocatorAnchorIfMissing(item);
|
|
const axis = reciprocatorAxis(item);
|
|
const travel = reciprocatorHalfTravel(item);
|
|
const dx = (Number(item.x || 0) || 0) - (Number(item.reciprocatorAnchorX || 0) || 0);
|
|
const dy = (Number(item.y || 0) || 0) - (Number(item.reciprocatorAnchorY || 0) || 0);
|
|
return clamp((dx * axis.x + dy * axis.y) / Math.max(10, travel), -1, 1);
|
|
}
|
|
|
|
function resolveEndpointObject(endpoint, worldRef) {
|
|
if (!endpoint || !worldRef) return null;
|
|
const allowFuzzy = endpoint.fuzzyResolve === true || endpoint.legacyFuzzy === true;
|
|
if (endpoint.kind === "tarinai") {
|
|
let found = (worldRef.tarinai || []).find(t => t && !t.dead && t.id === endpoint.id);
|
|
if (!found && allowFuzzy && Number.isFinite(endpoint.x) && Number.isFinite(endpoint.y)) {
|
|
found = (worldRef.tarinai || []).find(t => t && !t.dead && distXY(t.x, t.y, endpoint.x, endpoint.y) <= Math.max(40, (t.radius || 20) * 1.6));
|
|
if (found) {
|
|
endpoint.id = found.id;
|
|
endpoint.fuzzyResolve = false;
|
|
endpoint.legacyFuzzy = false;
|
|
}
|
|
}
|
|
return found || null;
|
|
}
|
|
let found = (worldRef.items || []).find(it => it && !it.dead && it.id === endpoint.id);
|
|
if (!found && allowFuzzy && Number.isFinite(endpoint.x) && Number.isFinite(endpoint.y)) {
|
|
found = (worldRef.items || []).find(it => it && !it.dead && it.type === endpoint.type && distXY(it.x, it.y, endpoint.x, endpoint.y) <= Math.max(44, (it.r || 20) * 1.8));
|
|
if (found) {
|
|
endpoint.id = found.id;
|
|
endpoint.fuzzyResolve = false;
|
|
endpoint.legacyFuzzy = false;
|
|
}
|
|
}
|
|
return found || null;
|
|
}
|
|
|
|
function itemAngle(item) {
|
|
return typeof itemAngleFor === "function" ? itemAngleFor(item) : (Number(item?.angle) || 0);
|
|
}
|
|
|
|
function isMechanicalLinkTarget(item) {
|
|
return Boolean(item && !item.dead && global.TarinaiMechanicalSystem?.isMechanicalType?.(item.type));
|
|
}
|
|
|
|
function localToWorld(item, lx, ly) {
|
|
const a = itemAngle(item);
|
|
const c = Math.cos(a), s = Math.sin(a);
|
|
return {
|
|
x: (Number(item?.x || 0) || 0) + lx * c - ly * s,
|
|
y: (Number(item?.y || 0) || 0) + lx * s + ly * c,
|
|
};
|
|
}
|
|
|
|
function worldToLocal(item, x, y) {
|
|
const a = itemAngle(item);
|
|
const dx = (Number(x || 0) || 0) - (Number(item?.x || 0) || 0);
|
|
const dy = (Number(y || 0) || 0) - (Number(item?.y || 0) || 0);
|
|
const c = Math.cos(-a), s = Math.sin(-a);
|
|
return { x: dx * c - dy * s, y: dx * s + dy * c };
|
|
}
|
|
|
|
function nearestMechanicalLocalPoint(item, lx, ly) {
|
|
if (!isMechanicalLinkTarget(item)) return null;
|
|
const segments = global.TarinaiMechanicalSystem?.sanitizeSegments?.(item) || [];
|
|
let best = null;
|
|
for (const seg of segments) {
|
|
if (!Array.isArray(seg) || seg.length < 4) continue;
|
|
const x1 = Number(seg[0]) || 0, y1 = Number(seg[1]) || 0;
|
|
const x2 = Number(seg[2]) || 0, y2 = Number(seg[3]) || 0;
|
|
const vx = x2 - x1, vy = y2 - y1;
|
|
const len2 = vx * vx + vy * vy;
|
|
if (len2 < 16) continue;
|
|
const t = clamp(((lx - x1) * vx + (ly - y1) * vy) / len2, 0, 1);
|
|
const x = x1 + vx * t;
|
|
const y = y1 + vy * t;
|
|
const d = Math.hypot(lx - x, ly - y);
|
|
if (!best || d < best.d) best = { x, y, d };
|
|
}
|
|
return best;
|
|
}
|
|
|
|
function snapEndpointToTarget(endpoint, worldRef) {
|
|
const obj = resolveEndpointObject(endpoint, worldRef);
|
|
if (!endpoint || !isMechanicalLinkTarget(obj)) return endpoint;
|
|
let local;
|
|
if (!endpoint.center && (Number.isFinite(Number(endpoint.localX)) || Number.isFinite(Number(endpoint.localY)))) {
|
|
local = { x: Number(endpoint.localX || 0) || 0, y: Number(endpoint.localY || 0) || 0 };
|
|
} else if (Number.isFinite(Number(endpoint.x)) && Number.isFinite(Number(endpoint.y))) {
|
|
local = worldToLocal(obj, endpoint.x, endpoint.y);
|
|
} else {
|
|
local = { x: 0, y: 0 };
|
|
}
|
|
const snapped = nearestMechanicalLocalPoint(obj, local.x, local.y);
|
|
if (!snapped) return endpoint;
|
|
const world = localToWorld(obj, snapped.x, snapped.y);
|
|
endpoint.localX = snapped.x;
|
|
endpoint.localY = snapped.y;
|
|
endpoint.x = world.x;
|
|
endpoint.y = world.y;
|
|
endpoint.center = false;
|
|
return endpoint;
|
|
}
|
|
|
|
function remapLinksForEditedMechanicalItem(worldRef, target, reason = "mechanical-shape-edited") {
|
|
if (!worldRef || !isMechanicalLinkTarget(target)) return 0;
|
|
let changed = 0;
|
|
const remap = (endpoint) => {
|
|
if (!endpoint || endpoint.kind !== "item" || endpoint.id !== target.id) return false;
|
|
const beforeX = Number(endpoint.localX || 0) || 0;
|
|
const beforeY = Number(endpoint.localY || 0) || 0;
|
|
const beforeWorldX = Number(endpoint.x || 0) || 0;
|
|
const beforeWorldY = Number(endpoint.y || 0) || 0;
|
|
snapEndpointToTarget(endpoint, worldRef);
|
|
const localDelta = Math.hypot((Number(endpoint.localX || 0) || 0) - beforeX, (Number(endpoint.localY || 0) || 0) - beforeY);
|
|
const worldDelta = Math.hypot((Number(endpoint.x || 0) || 0) - beforeWorldX, (Number(endpoint.y || 0) || 0) - beforeWorldY);
|
|
return localDelta > 0.001 || worldDelta > 0.001;
|
|
};
|
|
for (const item of worldRef.items || []) {
|
|
if (!item || item.dead || (item.type !== "rope" && item.type !== "rod")) continue;
|
|
const a = remap(item.linkA);
|
|
const b = remap(item.linkB);
|
|
if (a || b) {
|
|
item.world = worldRef;
|
|
changed += 1;
|
|
}
|
|
}
|
|
if (remap(worldRef.pendingLinkEndpoint)) changed += 1;
|
|
if (changed) {
|
|
worldRef.drawListDirty = true;
|
|
worldRef.markSpatialDirty?.(reason);
|
|
}
|
|
return changed;
|
|
}
|
|
|
|
function endpointWorld(endpoint, worldRef, depth = 0, seen = null) {
|
|
const obj = resolveEndpointObject(endpoint, worldRef);
|
|
if (obj && (obj.type === "rope" || obj.type === "rod") && worldRef) obj.world = worldRef;
|
|
if (!obj) return Number.isFinite(endpoint?.x) && Number.isFinite(endpoint?.y) ? { x: endpoint.x, y: endpoint.y, obj: null } : null;
|
|
if ((obj.type === "rope" || obj.type === "rod") && Number.isFinite(Number(endpoint.attachT))) {
|
|
if (depth > 6) return { x: obj.x || 0, y: obj.y || 0, obj };
|
|
const guard = seen || new Set();
|
|
if (guard.has(obj.id)) return { x: obj.x || 0, y: obj.y || 0, obj };
|
|
guard.add(obj.id);
|
|
const a = endpointWorld(obj.linkA, worldRef, depth + 1, guard);
|
|
const b = endpointWorld(obj.linkB, worldRef, depth + 1, guard);
|
|
guard.delete(obj.id);
|
|
if (a && b) {
|
|
const t = clamp(Number(endpoint.attachT || 0), 0, 1);
|
|
return { x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t, obj };
|
|
}
|
|
}
|
|
const lx = Number(endpoint.localX || 0) || 0;
|
|
const ly = Number(endpoint.localY || 0) || 0;
|
|
if (endpoint.center || (!lx && !ly)) return { x: obj.x || 0, y: obj.y || 0, obj };
|
|
const a = itemAngle(obj);
|
|
const c = Math.cos(a), s = Math.sin(a);
|
|
return { x: (obj.x || 0) + lx * c - ly * s, y: (obj.y || 0) + lx * s + ly * c, obj };
|
|
}
|
|
|
|
function endpointMass(obj) {
|
|
if (!obj || obj.dead) return Infinity;
|
|
if (obj._heldByPlayer || obj.playerHeld) return Infinity;
|
|
if (obj.radius && obj.name) return 1.0;
|
|
if (obj.type === "ball") return 0.7;
|
|
if (obj.type === "zunchi") return 0.55;
|
|
if (obj.type === "rope" || obj.type === "rod") return 1.6;
|
|
if (global.TarinaiMechanicalSystem?.isMechanicalType?.(obj.type)) return 3.8;
|
|
if (obj.type && (obj.type.includes("fence") || obj.type === "bed" || obj.type === "nest_box")) return obj.type.includes("fence") ? Infinity : 5.5;
|
|
return 2.2;
|
|
}
|
|
|
|
function moveEndpointObject(obj, dx, dy, endpoint, dt, strength = 1, depth = 0) {
|
|
if (!obj || !Number.isFinite(dx) || !Number.isFinite(dy)) return false;
|
|
if (obj._heldByPlayer || obj.playerHeld) return false;
|
|
if (depth > 5) return false;
|
|
const moveX = dx * strength;
|
|
const moveY = dy * strength;
|
|
if (Math.hypot(moveX, moveY) < 0.001) return false;
|
|
if ((obj.type === "rope" || obj.type === "rod") && Number.isFinite(Number(endpoint?.attachT))) {
|
|
const t = clamp(Number(endpoint.attachT || 0), 0, 1);
|
|
let changed = false;
|
|
const wA = 1 - t;
|
|
const wB = t;
|
|
if (obj.linkA) changed = moveEndpointObject(resolveEndpointObject(obj.linkA, obj.world || null), moveX * wA, moveY * wA, obj.linkA, dt, 0.72, depth + 1) || changed;
|
|
if (obj.linkB) changed = moveEndpointObject(resolveEndpointObject(obj.linkB, obj.world || null), moveX * wB, moveY * wB, obj.linkB, dt, 0.72, depth + 1) || changed;
|
|
return changed;
|
|
}
|
|
if (obj.radius && obj.name) {
|
|
obj.x += moveX;
|
|
obj.y += moveY;
|
|
obj.vx = clamp((obj.vx || 0) + moveX / Math.max(0.016, dt || 0.016) * 0.18, -180, 180);
|
|
obj.vy = clamp((obj.vy || 0) + moveY / Math.max(0.016, dt || 0.016) * 0.18, -180, 180);
|
|
obj.target = null;
|
|
return true;
|
|
}
|
|
if (obj.type === "reciprocator") {
|
|
const mech = global.TarinaiMechanicalSystem;
|
|
mech?.resetAnchor?.(obj) || resetReciprocatorAnchorIfMissing(obj);
|
|
const p = endpointWorld(endpoint, obj.world || null) || { x: obj.x, y: obj.y };
|
|
if (mech?.applyImpulse?.(obj, p.x || obj.x, p.y || obj.y, moveX * 34, moveY * 34, 0.55)) return true;
|
|
const axis = mech?.axis?.(obj) || reciprocatorAxis(obj);
|
|
const along = moveX * axis.x + moveY * axis.y;
|
|
obj.reciprocatorVelocity = clamp((obj.reciprocatorVelocity || 0) + along / Math.max(0.016, dt || 0.016) * 0.035, -150, 150);
|
|
return Math.abs(along) > 0.001;
|
|
}
|
|
if (obj.type === "rotator") {
|
|
const p = endpointWorld(endpoint, obj.world || null) || { x: obj.x, y: obj.y };
|
|
if (global.TarinaiMechanicalSystem?.applyImpulse?.(obj, p.x || obj.x, p.y || obj.y, moveX * 38, moveY * 38, 0.58)) return true;
|
|
const rx = (p.x || obj.x) - obj.x;
|
|
const ry = (p.y || obj.y) - obj.y;
|
|
const torque = rx * moveY - ry * moveX;
|
|
obj.rotatorAngularVelocity = clamp((obj.rotatorAngularVelocity || 0) + torque * 0.0009, -2.0, 2.0);
|
|
return true;
|
|
}
|
|
if (obj.type && obj.type.includes("fence")) return false;
|
|
obj.prevX = obj.x;
|
|
obj.prevY = obj.y;
|
|
obj.x += moveX;
|
|
obj.y += moveY;
|
|
obj.vx = clamp((obj.vx || 0) + moveX / Math.max(0.016, dt || 0.016) * 0.10, -140, 140);
|
|
obj.vy = clamp((obj.vy || 0) + moveY / Math.max(0.016, dt || 0.016) * 0.10, -140, 140);
|
|
return true;
|
|
}
|
|
|
|
function updateFlexibleMidParticle(item, a, b, dt, length) {
|
|
if (!a || !b || item.type !== "rope") return false;
|
|
const ax = a.x, ay = a.y, bx = b.x, by = b.y;
|
|
const dx = bx - ax, dy = by - ay;
|
|
const d = Math.max(0.001, Math.hypot(dx, dy));
|
|
if (!Number.isFinite(item.linkMidX) || !Number.isFinite(item.linkMidY)) {
|
|
item.linkMidX = (ax + bx) * 0.5;
|
|
item.linkMidY = (ay + by) * 0.5 + Math.max(4, (length - d) * 0.25);
|
|
item.linkMidVX = 0;
|
|
item.linkMidVY = 0;
|
|
}
|
|
const prevX = item.linkMidX;
|
|
const prevY = item.linkMidY;
|
|
const step = Math.max(0.008, Math.min(0.05, Number(dt || 0.016) || 0.016));
|
|
item.linkMidVX = (Number(item.linkMidVX || 0) || 0) * Math.pow(0.62, step * 60);
|
|
item.linkMidVY = (Number(item.linkMidVY || 0) || 0) * Math.pow(0.62, step * 60) + 90 * step;
|
|
item.linkMidX += item.linkMidVX * step;
|
|
item.linkMidY += item.linkMidVY * step;
|
|
const half = Math.max(12, length * 0.5);
|
|
if (d >= length * 0.985) {
|
|
item.linkMidX = (ax + bx) * 0.5;
|
|
item.linkMidY = (ay + by) * 0.5;
|
|
item.linkMidVX *= 0.25;
|
|
item.linkMidVY *= 0.25;
|
|
} else {
|
|
for (let i = 0; i < 4; i += 1) {
|
|
for (const pnt of [[ax, ay], [bx, by]]) {
|
|
const vx = item.linkMidX - pnt[0];
|
|
const vy = item.linkMidY - pnt[1];
|
|
const dd = Math.max(0.001, Math.hypot(vx, vy));
|
|
if (dd > half) {
|
|
item.linkMidX = pnt[0] + vx / dd * half;
|
|
item.linkMidY = pnt[1] + vy / dd * half;
|
|
}
|
|
}
|
|
}
|
|
const minY = Math.min(ay, by) - length * 0.35;
|
|
const maxY = Math.max(ay, by) + length * 0.70;
|
|
item.linkMidY = clamp(item.linkMidY, minY, maxY);
|
|
}
|
|
item.linkMidVX = (item.linkMidX - prevX) / step;
|
|
item.linkMidVY = (item.linkMidY - prevY) / step;
|
|
return Math.hypot(item.linkMidX - prevX, item.linkMidY - prevY) > 0.01;
|
|
}
|
|
|
|
function applyDistanceConstraint(item, a, b, dt, length, mode) {
|
|
const dx = b.x - a.x;
|
|
const dy = b.y - a.y;
|
|
const d = Math.max(0.001, Math.hypot(dx, dy));
|
|
const delta = d - length;
|
|
if (mode === "rope" && delta <= 0) return false;
|
|
if (mode === "rod" && Math.abs(delta) < 0.35) return false;
|
|
const nx = dx / d;
|
|
const ny = dy / d;
|
|
const ma = endpointMass(a.obj);
|
|
const mb = endpointMass(b.obj);
|
|
const ia = Number.isFinite(ma) ? 1 / Math.max(0.1, ma) : 0;
|
|
const ib = Number.isFinite(mb) ? 1 / Math.max(0.1, mb) : 0;
|
|
const sum = ia + ib || 1;
|
|
let changed = false;
|
|
if (mode === "rope") {
|
|
const corr = Math.min(delta, Math.max(2, length * 0.08));
|
|
changed = moveEndpointObject(a.obj, nx * corr * (ia / sum), ny * corr * (ia / sum), item.linkA, dt, 0.85) || changed;
|
|
changed = moveEndpointObject(b.obj, -nx * corr * (ib / sum), -ny * corr * (ib / sum), item.linkB, dt, 0.85) || changed;
|
|
} else {
|
|
const iterations = 3;
|
|
for (let i = 0; i < iterations; i += 1) {
|
|
const corr = clamp(delta / iterations, -Math.max(3, length * 0.18), Math.max(3, length * 0.18));
|
|
changed = moveEndpointObject(a.obj, nx * corr * (ia / sum), ny * corr * (ia / sum), item.linkA, dt, 1.05) || changed;
|
|
changed = moveEndpointObject(b.obj, -nx * corr * (ib / sum), -ny * corr * (ib / sum), item.linkB, dt, 1.05) || changed;
|
|
}
|
|
}
|
|
return changed;
|
|
}
|
|
|
|
function primeLinkState(item, a, b) {
|
|
const d = Math.max(0.001, distXY(a.x, a.y, b.x, b.y));
|
|
const length = Math.max(24, Number(item.linkLength || d) || d);
|
|
item.x = (a.x + b.x) * 0.5;
|
|
item.y = (a.y + b.y) * 0.5;
|
|
item.r = Math.max(18, length * 0.5);
|
|
item.linkLength = length;
|
|
item.linkA.x = a.x; item.linkA.y = a.y;
|
|
item.linkB.x = b.x; item.linkB.y = b.y;
|
|
return length;
|
|
}
|
|
|
|
function updateFlexibleLink(item, dt, worldRef) {
|
|
if (!item || item.dead || item.type !== "rope") return false;
|
|
item.amount = 999;
|
|
item.world = worldRef || item.world || null;
|
|
const a = endpointWorld(item.linkA, worldRef);
|
|
const b = endpointWorld(item.linkB, worldRef);
|
|
if (!a || !b || !a.obj || !b.obj) {
|
|
item.amount = 0;
|
|
if (worldRef) worldRef.drawListDirty = true;
|
|
return true;
|
|
}
|
|
const length = primeLinkState(item, a, b);
|
|
let changed = updateFlexibleMidParticle(item, a, b, dt, length);
|
|
changed = applyDistanceConstraint(item, a, b, dt, length, "rope") || changed;
|
|
if (changed && worldRef) {
|
|
worldRef.drawListDirty = true;
|
|
worldRef.markSpatialDirty?.("rope-tension");
|
|
}
|
|
return changed;
|
|
}
|
|
|
|
function updateRigidLink(item, dt, worldRef) {
|
|
if (!item || item.dead || item.type !== "rod") return false;
|
|
item.amount = 999;
|
|
item.world = worldRef || item.world || null;
|
|
const a = endpointWorld(item.linkA, worldRef);
|
|
const b = endpointWorld(item.linkB, worldRef);
|
|
if (!a || !b || !a.obj || !b.obj) {
|
|
item.amount = 0;
|
|
if (worldRef) worldRef.drawListDirty = true;
|
|
return true;
|
|
}
|
|
const length = primeLinkState(item, a, b);
|
|
const changed = applyDistanceConstraint(item, a, b, dt, length, "rod");
|
|
if (changed && worldRef) {
|
|
worldRef.drawListDirty = true;
|
|
worldRef.markSpatialDirty?.("rod-constraint");
|
|
}
|
|
return changed;
|
|
}
|
|
|
|
function updateLink(item, dt, worldRef) {
|
|
if (item?.type === "rope") return updateFlexibleLink(item, dt, worldRef);
|
|
if (item?.type === "rod") return updateRigidLink(item, dt, worldRef);
|
|
return false;
|
|
}
|
|
|
|
function pointSegmentDistance(px, py, ax, ay, bx, by) {
|
|
const vx = bx - ax;
|
|
const vy = by - ay;
|
|
const len2 = vx * vx + vy * vy || 1;
|
|
const t = clamp(((px - ax) * vx + (py - ay) * vy) / len2, 0, 1);
|
|
return Math.hypot(px - (ax + vx * t), py - (ay + vy * t));
|
|
}
|
|
|
|
const api = Object.freeze({
|
|
endpointWorld,
|
|
resolveEndpointObject,
|
|
endpointMass,
|
|
moveEndpointObject,
|
|
snapEndpointToTarget,
|
|
remapLinksForEditedMechanicalItem,
|
|
updateFlexibleLink,
|
|
updateRigidLink,
|
|
updateLink,
|
|
pointSegmentDistance,
|
|
});
|
|
|
|
global.TarinaiConstraintSystem = api;
|
|
global.TarinaiLinkRuntime = Object.freeze({ endpointWorld, pointSegmentDistance, snapEndpointToTarget, remapLinksForEditedMechanicalItem });
|
|
})(typeof window !== "undefined" ? window : globalThis);
|