tarinai/js/constraint_system.js

403 lines
17 KiB
JavaScript
Raw Normal View History

2026-06-26 19:04:32 +09:00
"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;
2026-06-26 22:35:26 +09:00
const allowFuzzy = endpoint.fuzzyResolve === true || endpoint.legacyFuzzy === true;
2026-06-26 19:04:32 +09:00
if (endpoint.kind === "tarinai") {
let found = (worldRef.tarinai || []).find(t => t && !t.dead && t.id === endpoint.id);
2026-06-26 22:35:26 +09:00
if (!found && allowFuzzy && Number.isFinite(endpoint.x) && Number.isFinite(endpoint.y)) {
2026-06-26 19:04:32 +09:00
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));
2026-06-26 22:35:26 +09:00
if (found) {
endpoint.id = found.id;
endpoint.fuzzyResolve = false;
endpoint.legacyFuzzy = false;
}
2026-06-26 19:04:32 +09:00
}
return found || null;
}
let found = (worldRef.items || []).find(it => it && !it.dead && it.id === endpoint.id);
2026-06-26 22:35:26 +09:00
if (!found && allowFuzzy && Number.isFinite(endpoint.x) && Number.isFinite(endpoint.y)) {
2026-06-26 19:04:32 +09:00
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));
2026-06-26 22:35:26 +09:00
if (found) {
endpoint.id = found.id;
endpoint.fuzzyResolve = false;
endpoint.legacyFuzzy = false;
}
2026-06-26 19:04:32 +09:00
}
return found || null;
}
2026-06-26 22:35:26 +09:00
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;
}
2026-06-26 19:04:32 +09:00
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 };
2026-06-26 22:35:26 +09:00
const a = itemAngle(obj);
2026-06-26 19:04:32 +09:00
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,
2026-06-26 22:35:26 +09:00
snapEndpointToTarget,
remapLinksForEditedMechanicalItem,
2026-06-26 19:04:32 +09:00
updateFlexibleLink,
updateRigidLink,
updateLink,
pointSegmentDistance,
});
global.TarinaiConstraintSystem = api;
2026-06-26 22:35:26 +09:00
global.TarinaiLinkRuntime = Object.freeze({ endpointWorld, pointSegmentDistance, snapEndpointToTarget, remapLinksForEditedMechanicalItem });
2026-06-26 19:04:32 +09:00
})(typeof window !== "undefined" ? window : globalThis);