tarinai/js/constraint_system.js
2026-07-01 14:41:05 +09:00

997 lines
47 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 bodyApi() { return global.TarinaiPhysicsBodySystem || null; }
function ps(item, key, fallback = 0) { return bodyApi()?.scalar?.(item, key, fallback) ?? fallback; }
function pset(item, key, value, reason = "constraint-body-write") { return bodyApi()?.setScalar?.(item, key, value, reason) || false; }
function ls(item, key, fallback = 0) { return bodyApi()?.linkScalar?.(item, key, fallback) ?? fallback; }
function lset(item, key, value) { return bodyApi()?.setLinkScalar?.(item, key, value) || false; }
function ep(item, index) { return bodyApi()?.endpoint?.(item, index) || null; }
function parts(item) { return bodyApi()?.particles?.(item) || null; }
function setParts(item, list) { return bodyApi()?.setParticles?.(item, list) || false; }
function ensureConstraint(item, worldRef = null, opts = {}) {
return bodyApi()?.ensureConstraint?.(item, worldRef || item?.world || null, { syncFromLegacy: opts.syncFromLegacy === true }) || item?.physicsConstraint || null;
}
function applyConstraintState(item) { return bodyApi()?.applyConstraintState?.(item, item?.physicsConstraint) || false; }
function normalizeConstraintState(item) { return bodyApi()?.normalizeConstraintState?.(item, item?.physicsConstraint) || item?.physicsConstraint || null; }
function commitConstraint(item) {
if (!item || (item.type !== "rope" && item.type !== "rod")) return null;
const c = ensureConstraint(item, item.world || null, { syncFromLegacy: false });
if (!c) return null;
normalizeConstraintState(item);
return c;
}
function commitMechanicalBody(item, reason = "constraint-moved-body") {
if (!item || !global.TarinaiMechanicalSystem.isMechanicalType(item.type)) return false;
// Constraint pulls sometimes mutate item.x/y as a scratch pose. Preserve
// that pose in physicsBody before normalizing, otherwise normalizeBody()
// can restore the old body pose and silently cancel the correction.
bodyApi()?.syncPoseFromItem?.(item);
bodyApi()?.normalizeBodyState?.(item, item.physicsBody);
bodyApi()?.markBodyChanged?.(item, reason);
return true;
}
function rectNearPointAabb(rect, x, y, padding = 0) {
if (!rect) return false;
const pad = Math.max(0, Number(padding || 0) || 0);
return x >= (Number(rect.left || 0) - pad)
&& x <= (Number(rect.right || 0) + pad)
&& y >= (Number(rect.top || 0) - pad)
&& y <= (Number(rect.bottom || 0) + pad);
}
function rectNearSegmentAabb(rect, x1, y1, x2, y2, padding = 0) {
if (!rect) return false;
const pad = Math.max(0, Number(padding || 0) || 0);
const left = Math.min(x1, x2) - pad;
const right = Math.max(x1, x2) + pad;
const top = Math.min(y1, y2) - pad;
const bottom = Math.max(y1, y2) + pad;
return !(right < Number(rect.left || 0) || left > Number(rect.right || 0) || bottom < Number(rect.top || 0) || top > Number(rect.bottom || 0));
}
function markConstraintSpatialDirty(worldRef, item, reason = "constraint-projection", threshold = 0.35, minInterval = 0.065) {
const world = worldRef || item?.world || null;
if (!world || !item) return false;
const now = Number(world.time || 0) || 0;
const x = Number(item.x || 0) || 0;
const y = Number(item.y || 0) || 0;
const r = Number(item.r || item.radius || 0) || 0;
const lastX = Number(item._lastConstraintSpatialDirtyX);
const lastY = Number(item._lastConstraintSpatialDirtyY);
const lastR = Number(item._lastConstraintSpatialDirtyR);
const missing = !Number.isFinite(lastX) || !Number.isFinite(lastY) || !Number.isFinite(lastR);
const moved = missing || Math.hypot(x - lastX, y - lastY) > threshold || Math.abs(r - lastR) > threshold;
const due = now >= Number(item._nextConstraintSpatialDirtyAt || 0);
if (moved || due) {
item._lastConstraintSpatialDirtyX = x;
item._lastConstraintSpatialDirtyY = y;
item._lastConstraintSpatialDirtyR = r;
item._nextConstraintSpatialDirtyAt = now + Math.max(0.016, Number(minInterval || 0.065) || 0.065);
const marked = global.TarinaiPhysicsProjectionSystem.markSpatialDirty(world, item, reason, threshold, minInterval) ?? false;
if (!marked) world.markSpatialDirty?.(reason);
if (world.constraintDirtyStatsThisFrame) world.constraintDirtyStatsThisFrame.marked = (world.constraintDirtyStatsThisFrame.marked || 0) + 1;
return true;
}
if (world.constraintDirtyStatsThisFrame) world.constraintDirtyStatsThisFrame.coalesced = (world.constraintDirtyStatsThisFrame.coalesced || 0) + 1;
return false;
}
function mechanicalPowered(item) {
return Boolean(global.TarinaiMechanicalSystem.isPowered(item));
}
function commitConstraintMovedMechanical(item, reason = "constraint-projection") {
if (!item || !global.TarinaiMechanicalSystem.isMechanicalType(item.type)) return false;
bodyApi()?.syncPoseFromItem?.(item);
bodyApi()?.normalizeBodyState?.(item, item.physicsBody);
bodyApi()?.markBodyChanged?.(item, reason);
markConstraintSpatialDirty(item.world, item, reason, 0.22, 0.045);
item.world && (item.world.drawListDirty = true);
return true;
}
function reciprocatorAxis(item) {
if (global.TarinaiMechanicalSystem.axis) return global.TarinaiMechanicalSystem.axis(item);
const fallback = itemAngleFor(item);
const a = normalizedItemAngle(ps(item, "railAxis", fallback), fallback);
return { x: Math.cos(a), y: Math.sin(a), angle: a };
}
function reciprocatorHalfTravel(item) {
return Math.max(24, ps(item, "railTravel", 150)) * 0.5;
}
function resetReciprocatorAnchorIfMissing(item) {
if (!Number.isFinite(Number(ps(item, "railAnchorX", NaN)))) pset(item, "railAnchorX", item.x || 0, "constraint-anchor-init");
if (!Number.isFinite(Number(ps(item, "railAnchorY", NaN)))) pset(item, "railAnchorY", item.y || 0, "constraint-anchor-init");
}
function resolveEndpointObject(endpoint, worldRef) {
if (!endpoint || !worldRef) return null;
const allowFuzzy = endpoint.fuzzyResolve === true || endpoint.fuzzyFallback === true;
const cached = endpoint._ref;
if (cached && !cached.dead && (cached.id === endpoint.id || endpoint.kind === "tarinai")) return cached;
if (endpoint.kind === "tarinai") {
let found = null;
if (worldRef.liveTarinaiById) found = worldRef.liveTarinaiById(endpoint.id, endpoint.liveToken ?? null) || null;
if (!found && worldRef.tarinaiById?.get) found = worldRef.tarinaiById.get(endpoint.id) || null;
if (!found && worldRef.liveTarinai?.get) {
const entry = worldRef.liveTarinai.get(endpoint.id) || null;
found = entry?.target || entry || null;
}
if (!found && endpoint.id) found = (worldRef.tarinai || []).find(t => t && !t.dead && t.id === endpoint.id);
if (!found && cached && !cached.dead && cached.radius && cached.name) found = cached;
if (!found && allowFuzzy && Number.isFinite(endpoint.x) && Number.isFinite(endpoint.y)) {
const source = worldRef.nearbyTarinai?.(endpoint.x, endpoint.y, 80, true) || worldRef.tarinai || [];
found = source.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.fuzzyFallback = false;
}
}
endpoint._ref = found || null;
return found || null;
}
let found = worldRef.itemById?.(endpoint.id) || null;
if (found && (found.dead || (Number.isFinite(Number(found.amount)) && Number(found.amount) <= 0))) found = null;
if (!found && endpoint.id != null) found = (worldRef.items || []).find(it => it && !it.dead && it.id === endpoint.id && !(Number.isFinite(Number(it.amount)) && Number(it.amount) <= 0));
if (!found && allowFuzzy && Number.isFinite(endpoint.x) && Number.isFinite(endpoint.y)) {
const source = worldRef.nearbyItems?.(endpoint.x, endpoint.y, 120, true) || worldRef.items || [];
found = source.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.fuzzyFallback = false;
}
}
endpoint._ref = found || null;
return found || null;
}
function itemAngle(item) {
return itemAngleFor(item);
}
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 mechanicalSupportTolerance(item) {
const thick = Math.max(4, Math.min(34, ps(item, "thickness", 12)));
return Math.max(14, thick * 0.9 + 7);
}
function mechanicalLocalPointSupported(item, lx, ly, endpoint = null) {
if (!isMechanicalLinkTarget(item)) return true;
const version = Number(item?._mechanicalShapeVersion || 0) || 0;
if (endpoint && endpoint._supportItemId === item.id && endpoint._supportCheckedVersion === version) return endpoint._supportAlive !== false;
const nearest = nearestMechanicalLocalPoint(item, lx, ly);
const alive = Boolean(nearest && nearest.d <= mechanicalSupportTolerance(item));
if (endpoint) {
endpoint._supportItemId = item.id;
endpoint._supportCheckedVersion = version;
endpoint._supportAlive = alive;
}
return alive;
}
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 || snapped.d > mechanicalSupportTolerance(obj)) {
const hub = Math.max(18, Math.min(40, ps(obj, "thickness", 12) * 2.3));
if (Math.hypot(local.x, local.y) <= hub) {
endpoint.localX = 0;
endpoint.localY = 0;
endpoint.x = obj.x || 0;
endpoint.y = obj.y || 0;
endpoint.center = true;
endpoint.supportLost = false;
return endpoint;
}
endpoint.supportLost = true;
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;
endpoint.supportLost = 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(ep(item, 0));
const b = remap(ep(item, 1));
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(ep(obj, 0), worldRef, depth + 1, guard);
const b = endpointWorld(ep(obj, 1), 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 };
}
}
if (endpoint.supportLost) return null;
const lx = Number(endpoint.localX || 0) || 0;
const ly = Number(endpoint.localY || 0) || 0;
if (isMechanicalLinkTarget(obj) && !endpoint.center && (Number.isFinite(Number(endpoint.localX)) || Number.isFinite(Number(endpoint.localY))) && !mechanicalLocalPointSupported(obj, lx, ly, endpoint)) return null;
if (endpoint.center || (!lx && !ly)) return { x: Number.isFinite(Number(obj.x)) ? Number(obj.x) : 0, y: Number.isFinite(Number(obj.y)) ? Number(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 endpointCanRemainAnchored(endpoint, point) {
if (!endpoint || !point || !point.obj || point.obj.dead) return false;
if (Number.isFinite(Number(point.obj.amount)) && Number(point.obj.amount) <= 0) return false;
return true;
}
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)) {
if (obj.type === "rotator") return Infinity; // anchored pivot: links may torque it, not translate it
if (obj.type === "reciprocator") return mechanicalPowered(obj) ? 18.0 : 5.8;
if (obj.type === "poison_block") return Math.max(0.35, ps(obj, "mass", 0.45));
return 8.0;
}
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, mode = "rope") {
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 (ep(obj, 0)) changed = moveEndpointObject(resolveEndpointObject(ep(obj, 0), obj.world || null), moveX * wA, moveY * wA, ep(obj, 0), dt, mode === "rod" ? 0.92 : 0.72, depth + 1, mode) || changed;
if (ep(obj, 1)) changed = moveEndpointObject(resolveEndpointObject(ep(obj, 1), obj.world || null), moveX * wB, moveY * wB, ep(obj, 1), dt, mode === "rod" ? 0.92 : 0.72, depth + 1, mode) || changed;
return changed;
}
if (obj.radius && obj.name) {
obj.prevX = Number.isFinite(Number(obj.x)) ? Number(obj.x) : obj.prevX;
obj.prevY = Number.isFinite(Number(obj.y)) ? Number(obj.y) : obj.prevY;
obj.x += moveX;
obj.y += moveY;
if (obj.state === "sleep" || obj.sleeping) {
global.TarinaiMovementUpdateStep.markSleepExternalMotion(obj, dt, `constraint-${mode}`);
obj._sleepExternalMotionUntil = Math.max(Number(obj._sleepExternalMotionUntil || 0) || 0, (Number(obj.world?.time || 0) || 0) + (mode === "rod" ? 1.1 : 0.6));
}
obj.target = null;
return true;
}
if (obj.type === "reciprocator") {
const mech = global.TarinaiMechanicalSystem;
mech?.resetAnchor?.(obj) || resetReciprocatorAnchorIfMissing(obj);
const axis = mech?.axis?.(obj) || reciprocatorAxis(obj);
const alongRaw = moveX * axis.x + moveY * axis.y;
if (!Number.isFinite(alongRaw) || Math.abs(alongRaw) < 0.001) return false;
const powered = mechanicalPowered(obj);
// Project the rail body along its single legal axis. Powered motors are
// intentionally stiff, so a rope/rod can slow them but not yank them far
// off their drive path in one frame.
const railScale = mode === "rod" ? (powered ? 0.46 : 0.76) : (powered ? 0.22 : 0.78);
const railCap = mode === "rod" ? (powered ? 22 : 30) : 18;
const along = clamp(alongRaw * railScale, -railCap, railCap);
if (Math.abs(along) < 0.001) return false;
const beforePhase = ps(obj, "railPhase", 0);
pset(obj, "railPhase", clamp(beforePhase + along / Math.max(10, reciprocatorHalfTravel(obj)), -1, 1), "constraint-rail-project");
const next = mech?.positionFromPhase?.(obj) || null;
if (next) {
obj.prevX = Number(obj.x || 0) || 0;
obj.prevY = Number(obj.y || 0) || 0;
obj.x = next.x;
obj.y = next.y;
}
const changed = Math.abs(ps(obj, "railPhase", 0) - beforePhase) > 0.0001;
if (changed) commitConstraintMovedMechanical(obj, "constraint-reciprocator-project");
return changed;
}
if (obj.type === "poison_block") {
const beforeX = Number(obj.x || 0) || 0;
const beforeY = Number(obj.y || 0) || 0;
const blockScale = mode === "rod" ? 0.74 : 0.72;
const blockCap = mode === "rod" ? 24 : 18;
const px = clamp(moveX * blockScale, -blockCap, blockCap);
const py = clamp(moveY * blockScale, -blockCap, blockCap);
if (Math.hypot(px, py) < 0.001) return false;
obj.prevX = beforeX;
obj.prevY = beforeY;
obj.x = beforeX + px;
obj.y = beforeY + py;
commitConstraintMovedMechanical(obj, "constraint-poison-project");
global.TarinaiMechanicalSystem.wakeItem(obj, "constraint-poison-project");
return true;
}
if (obj.type === "rotator") {
const powered = mechanicalPowered(obj);
if (powered) return false;
const p = endpointWorld(endpoint, obj.world || null) || { x: obj.x, y: obj.y };
const rx = (p.x || obj.x) - obj.x;
const ry = (p.y || obj.y) - obj.y;
const torque = rx * moveY - ry * moveX;
const delta = clamp(torque * 0.00018, -0.11, 0.11);
if (!Number.isFinite(delta) || Math.abs(delta) < 0.0015) return false;
pset(obj, "spin", clamp(ps(obj, "spin", 0) * 0.82 + delta, -1.45, 1.45), "constraint-passive-rotator-project");
commitMechanicalBody(obj, "constraint-passive-rotator-project");
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;
return true;
}
function applyDistanceConstraint(item, a, b, dt, length, mode) {
const solveOnce = (pa, pb, strength, maxFraction) => {
if (!pa || !pb || !pa.obj || !pb.obj) return false;
const dx = pb.x - pa.x;
const dy = pb.y - pa.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.85) return false;
const nx = dx / d;
const ny = dy / d;
const ma = endpointMass(pa.obj);
const mb = endpointMass(pb.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;
const step = Math.max(0.012, Math.min(0.05, Number(dt || 0.016) || 0.016));
const compliance = mode === "rod" ? 0.085 / (step * 60) : 0.46 / (step * 60);
const target = delta / (1 + compliance);
const cap = mode === "rod"
? Math.max(6.0, Math.min(46, length * maxFraction))
: Math.max(1.5, length * maxFraction);
const corr = clamp(target, -cap, cap);
let changed = false;
changed = moveEndpointObject(pa.obj, nx * corr * (ia / sum), ny * corr * (ia / sum), ep(item, 0), dt, strength, 0, mode) || changed;
changed = moveEndpointObject(pb.obj, -nx * corr * (ib / sum), -ny * corr * (ib / sum), ep(item, 1), dt, strength, 0, mode) || changed;
return changed;
};
let changed = false;
if (mode === "rope") {
changed = solveOnce(a, b, 0.58, 0.044) || changed;
} else {
const iterations = 5;
for (let i = 0; i < iterations; i += 1) {
const aa = endpointWorld(ep(item, 0), item.world || null) || a;
const bb = endpointWorld(ep(item, 1), item.world || null) || b;
changed = solveOnce(aa, bb, 0.82, 0.10) || 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(ls(item, "len", 80) || 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);
lset(item, "len", length);
ep(item, 0).x = a.x; ep(item, 0).y = a.y;
ep(item, 1).x = b.x; ep(item, 1).y = b.y;
return length;
}
function ropeLodSettings(worldRef, length) {
const tier = global.TarinaiPerf.simulationQualityTier?.() || "high";
const ropeCount = Number(worldRef?.itemCounts?.rope || 0) || 0;
let spacing = 34;
let maxParticles = 24;
let iterations = 6;
let particleStride = 1;
if (tier === "low") { spacing = 56; maxParticles = 15; iterations = 4; particleStride = 2; }
if (ropeCount > 12) { spacing *= 1.18; maxParticles = Math.max(10, maxParticles - 4); iterations = Math.max(3, iterations - 1); }
if (ropeCount > 24) { spacing *= 1.28; maxParticles = Math.max(8, maxParticles - 4); iterations = Math.max(3, iterations - 1); particleStride = 2; }
const targetCount = Math.max(4, Math.min(maxParticles, Math.ceil(Math.max(24, length) / spacing) + 1));
return { tier, ropeCount, spacing, maxParticles, iterations, particleStride, targetCount };
}
function ensureRopeParticles(item, a, b, length, worldRef = null) {
const lod = ropeLodSettings(worldRef, length);
const targetCount = lod.targetCount;
const oldParticles = Array.isArray(parts(item)) ? parts(item) : null;
if (!oldParticles || oldParticles.length !== targetCount || item._ropeLodTargetCount !== targetCount) {
const previous = oldParticles && oldParticles.length >= 2 ? oldParticles.slice() : null;
setParts(item, []);
for (let i = 0; i < targetCount; i += 1) {
const u = targetCount <= 1 ? 0 : i / (targetCount - 1);
let x, y, px, py;
if (previous) {
const pos = u * (previous.length - 1);
const j = Math.max(0, Math.min(previous.length - 2, Math.floor(pos)));
const f = pos - j;
const p1 = previous[j], p2 = previous[j + 1];
x = (Number(p1.x) || 0) + ((Number(p2.x) || 0) - (Number(p1.x) || 0)) * f;
y = (Number(p1.y) || 0) + ((Number(p2.y) || 0) - (Number(p1.y) || 0)) * f;
px = (Number(p1.px) || x) + ((Number(p2.px) || (Number(p2.x) || x)) - (Number(p1.px) || (Number(p1.x) || x))) * f;
py = (Number(p1.py) || y) + ((Number(p2.py) || (Number(p2.y) || y)) - (Number(p1.py) || (Number(p1.y) || y))) * f;
} else {
const sag = Math.sin(u * Math.PI) * Math.max(0, Math.min(48, (length - distXY(a.x, a.y, b.x, b.y)) * 0.35 + length * 0.035));
x = a.x + (b.x - a.x) * u;
y = a.y + (b.y - a.y) * u + sag;
px = x; py = y;
}
parts(item).push({ x, y, px, py });
}
item._ropeLodTargetCount = targetCount;
}
const ps = parts(item);
if (ps.length) {
ps[0].x = a.x; ps[0].y = a.y; ps[0].px = a.x; ps[0].py = a.y;
const last = ps[ps.length - 1];
last.x = b.x; last.y = b.y; last.px = b.x; last.py = b.y;
}
return ps;
}
function rectLocalPointForConstraint(r, x, y) {
return global.TarinaiGeometry.rectLocalPoint(r, x, y);
}
function rectWorldNormalForConstraint(r, nx, ny) {
return global.TarinaiGeometry.rectWorldNormal(r, nx, ny);
}
function pushRopeParticleOutOfRect(p, rect, radius) {
if (!p || !rect) return false;
let nx = 0, ny = 0, overlap = 0;
if (rect.oriented) {
const local = rectLocalPointForConstraint(rect, p.x, p.y);
const clx = clamp(local.x, -(rect.halfW || 0), rect.halfW || 0);
const cly = clamp(local.y, -(rect.halfH || 0), rect.halfH || 0);
let dx = local.x - clx;
let dy = local.y - cly;
let d = Math.hypot(dx, dy);
if (d >= radius) return false;
if (d < 0.001) {
const left = Math.abs(local.x + (rect.halfW || 0));
const right = Math.abs((rect.halfW || 0) - local.x);
const top = Math.abs(local.y + (rect.halfH || 0));
const bottom = Math.abs((rect.halfH || 0) - local.y);
const m = Math.min(left, right, top, bottom);
if (m === left) { dx = -1; dy = 0; d = 1; }
else if (m === right) { dx = 1; dy = 0; d = 1; }
else if (m === top) { dx = 0; dy = -1; d = 1; }
else { dx = 0; dy = 1; d = 1; }
}
const n = rectWorldNormalForConstraint(rect, dx / d, dy / d);
nx = n.x; ny = n.y; overlap = radius - d;
} else {
const cx = clamp(p.x, rect.left, rect.right);
const cy = clamp(p.y, rect.top, rect.bottom);
let dx = p.x - cx;
let dy = p.y - cy;
let d = Math.hypot(dx, dy);
if (d >= radius) return false;
if (d < 0.001) { dx = 0; dy = -1; d = 1; }
nx = dx / d; ny = dy / d; overlap = radius - d;
}
const push = Math.min(Math.max(0, overlap), Math.max(2, radius * 0.9));
p.x += nx * push;
p.y += ny * push;
// Do not let collision projection become a full Verlet velocity on the next frame.
if (Number.isFinite(Number(p.px))) p.px += nx * push * 0.72;
if (Number.isFinite(Number(p.py))) p.py += ny * push * 0.72;
return push > 0.001;
}
function pushRopeSegmentOutOfRect(p1, p2, rect, radius) {
if (!p1 || !p2 || !rect) return false;
const samples = [
{ t: 0.25, p: { x: p1.x + (p2.x - p1.x) * 0.25, y: p1.y + (p2.y - p1.y) * 0.25 } },
{ t: 0.50, p: { x: p1.x + (p2.x - p1.x) * 0.50, y: p1.y + (p2.y - p1.y) * 0.50 } },
{ t: 0.75, p: { x: p1.x + (p2.x - p1.x) * 0.75, y: p1.y + (p2.y - p1.y) * 0.75 } },
];
let changed = false;
for (const sample of samples) {
const beforeX = sample.p.x;
const beforeY = sample.p.y;
if (!pushRopeParticleOutOfRect(sample.p, rect, radius)) continue;
const dx = sample.p.x - beforeX;
const dy = sample.p.y - beforeY;
const w1 = 1 - sample.t;
const w2 = sample.t;
p1.x += dx * w1;
p1.y += dy * w1;
p2.x += dx * w2;
p2.y += dy * w2;
if (Number.isFinite(Number(p1.px))) p1.px += dx * w1 * 0.70;
if (Number.isFinite(Number(p1.py))) p1.py += dy * w1 * 0.70;
if (Number.isFinite(Number(p2.px))) p2.px += dx * w2 * 0.70;
if (Number.isFinite(Number(p2.py))) p2.py += dy * w2 * 0.70;
changed = true;
}
return changed;
}
function resolveRigidLinkObstacleContacts(item, a, b, dt, worldRef) {
if (!item || item.type !== "rod" || !a || !b || !worldRef) return false;
const cx = (a.x + b.x) * 0.5;
const cy = (a.y + b.y) * 0.5;
const queryRadius = Math.max(44, distXY(a.x, a.y, b.x, b.y) * 0.5 + 48);
const endpointIds = ropeEndpointObjectIds(item);
const rects = worldRef.nearbySolidObstacleRects?.(cx, cy, queryRadius, { maxChecks: 18 }) || [];
if (!rects.length) return false;
let changed = false;
const samples = [0.25, 0.50, 0.75];
for (const rect of rects) {
if (rect?.item?.id && endpointIds.has(rect.item.id)) continue;
for (const t of samples) {
const p = { x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t };
const beforeX = p.x, beforeY = p.y;
if (!pushRopeParticleOutOfRect(p, rect, 7.0)) continue;
const dx = p.x - beforeX;
const dy = p.y - beforeY;
changed = moveEndpointObject(a.obj, dx * (1 - t), dy * (1 - t), ep(item, 0), dt, 0.78, 0, "rod") || changed;
changed = moveEndpointObject(b.obj, dx * t, dy * t, ep(item, 1), dt, 0.78, 0, "rod") || changed;
}
}
return changed;
}
function ropeEndpointObjectIds(item) {
return {
a: ep(item, 0)?.kind === "item" ? ep(item, 0).id : null,
b: ep(item, 1)?.kind === "item" ? ep(item, 1).id : null,
has(id) { return id != null && (id === this.a || id === this.b); },
};
}
function solveRopeParticleChain(item, a, b, dt, length, worldRef) {
const lod = ropeLodSettings(worldRef, length);
const ps = ensureRopeParticles(item, a, b, length, worldRef);
if (!ps || ps.length < 2) return false;
const step = Math.max(0.008, Math.min(0.05, Number(dt || 0.016) || 0.016));
const rest = Math.max(4, length / (ps.length - 1));
const maxParticleStep = Math.max(10, Math.min(28, rest * 0.62));
let changed = false;
for (let i = 1; i < ps.length - 1; i += 1) {
const p = ps[i];
const ox = p.x, oy = p.y;
let vx = (p.x - (Number.isFinite(Number(p.px)) ? p.px : p.x)) * Math.pow(0.975, step * 60);
let vy = (p.y - (Number.isFinite(Number(p.py)) ? p.py : p.y)) * Math.pow(0.975, step * 60) + 120 * step * step;
const vLen = Math.hypot(vx, vy);
if (vLen > maxParticleStep) {
const k = maxParticleStep / Math.max(0.001, vLen);
vx *= k;
vy *= k;
}
p.px = p.x; p.py = p.y;
p.x += vx; p.y += vy;
changed = changed || Math.hypot(p.x - ox, p.y - oy) > 0.01;
}
const endpointIds = ropeEndpointObjectIds(item);
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (const p of ps) { minX = Math.min(minX, p.x); minY = Math.min(minY, p.y); maxX = Math.max(maxX, p.x); maxY = Math.max(maxY, p.y); }
const ropeCx = (minX + maxX) * 0.5;
const ropeCy = (minY + maxY) * 0.5;
const ropeQueryRadius = Math.max(48, Math.hypot(maxX - minX, maxY - minY) * 0.5 + 72);
const tarinaiCandidates = worldRef?.nearbyTarinai?.(ropeCx, ropeCy, ropeQueryRadius, true) || worldRef?.tarinai || [];
let ropeObstacleRects = null;
const getRopeObstacleRects = () => {
if (ropeObstacleRects) return ropeObstacleRects;
if (!worldRef?.nearbySolidObstacleRects) return (ropeObstacleRects = []);
const maxChecks = lod.tier === "low" ? 18 : 38;
ropeObstacleRects = worldRef.nearbySolidObstacleRects(ropeCx, ropeCy, ropeQueryRadius + 64, { maxChecks }) || [];
return ropeObstacleRects;
};
const collideParticles = () => {
let c = false;
for (let i = 1; i < ps.length - 1; i += lod.particleStride) {
const p = ps[i];
for (const t of tarinaiCandidates) {
if (!t || t.dead || worldRef?.isTarinaiHiddenInNestBox?.(t)) continue;
const minD = Math.max(8, (t.radius || 20) * 0.72 + 4.8);
const dx = p.x - t.x, dy = p.y - t.y;
let d = Math.hypot(dx, dy);
if (d >= minD) continue;
if (d < 0.001) { d = 1; }
const nx = dx / d || 0, ny = dy / d || -1;
const push = Math.min(Math.max(0, minD - d), Math.max(3, minD * 0.48));
p.x += nx * push;
p.y += ny * push;
c = push > 0.001 || c;
}
for (const rect of getRopeObstacleRects()) {
if (rect?.item?.id && endpointIds.has(rect.item.id)) continue;
const radius = rect?.mechanical ? 8.2 : 6.2;
if (!rectNearPointAabb(rect, p.x, p.y, radius + 5.5)) continue;
c = pushRopeParticleOutOfRect(p, rect, radius) || c;
}
}
return c;
};
for (let iter = 0; iter < lod.iterations; iter += 1) {
ps[0].x = a.x; ps[0].y = a.y;
ps[ps.length - 1].x = b.x; ps[ps.length - 1].y = b.y;
const shouldCollide = lod.tier === "low" ? iter === 1 : (iter === 2 || iter === lod.iterations - 1);
if (shouldCollide) changed = collideParticles() || changed;
for (let i = 0; i < ps.length - 1; i += 1) {
const p1 = ps[i], p2 = ps[i + 1];
const dx = p2.x - p1.x, dy = p2.y - p1.y;
const d = Math.max(0.001, Math.hypot(dx, dy));
const diff = (d - rest) / d;
const corrX = dx * diff;
const corrY = dy * diff;
if (i === 0) { p2.x -= corrX; p2.y -= corrY; }
else if (i + 1 === ps.length - 1) { p1.x += corrX; p1.y += corrY; }
else { p1.x += corrX * 0.5; p1.y += corrY * 0.5; p2.x -= corrX * 0.5; p2.y -= corrY * 0.5; }
}
if (shouldCollide && worldRef?.nearbySolidObstacleRects) {
for (let i = 0; i < ps.length - 1; i += Math.max(1, lod.particleStride)) {
const p1 = ps[i], p2 = ps[i + 1];
if (!p1 || !p2) continue;
for (const rect of getRopeObstacleRects()) {
if (rect?.item?.id && endpointIds.has(rect.item.id)) continue;
const pad = rect?.mechanical ? 8.8 : 7.0;
if (!rectNearSegmentAabb(rect, p1.x, p1.y, p2.x, p2.y, pad + 4.0)) continue;
changed = pushRopeSegmentOutOfRect(p1, p2, rect, rect?.mechanical ? 7.6 : 6.0) || changed;
}
}
}
}
ps[0].x = a.x; ps[0].y = a.y;
ps[ps.length - 1].x = b.x; ps[ps.length - 1].y = b.y;
const mid = ps[Math.floor(ps.length * 0.5)] || ps[0];
lset(item, "midX", mid.x);
lset(item, "midY", mid.y);
lset(item, "midVx", clamp((mid.x - (Number(item._ropePrevMidX) || mid.x)) / step, -260, 260));
lset(item, "midVy", clamp((mid.y - (Number(item._ropePrevMidY) || mid.y)) / step, -260, 260));
item._ropePrevMidX = mid.x;
item._ropePrevMidY = mid.y;
minX = Infinity; minY = Infinity; maxX = -Infinity; maxY = -Infinity;
for (const p of ps) { minX = Math.min(minX, p.x); minY = Math.min(minY, p.y); maxX = Math.max(maxX, p.x); maxY = Math.max(maxY, p.y); }
item.x = (minX + maxX) * 0.5;
item.y = (minY + maxY) * 0.5;
item.r = Math.max(18, Math.hypot(maxX - minX, maxY - minY) * 0.5 + 8);
return changed;
}
function beginLinkUpdate(item, worldRef, expectedType) {
if (!item || item.dead || item.type !== expectedType) return null;
item.amount = 999;
item.world = worldRef || item.world || null;
const a = endpointWorld(ep(item, 0), worldRef);
const b = endpointWorld(ep(item, 1), worldRef);
if (!endpointCanRemainAnchored(ep(item, 0), a) || !endpointCanRemainAnchored(ep(item, 1), b)) {
item.amount = 0;
if (worldRef) worldRef.drawListDirty = true;
return { detached: true };
}
return { a, b, x: Number(item.x || 0) || 0, y: Number(item.y || 0) || 0, r: Number(item.r || item.radius || 0) || 0 };
}
function finishLinkSpatialUpdate(item, worldRef, before, reason, threshold, minInterval) {
if (!worldRef || !before) return false;
worldRef.drawListDirty = true;
const movedForSpatial = Math.hypot((Number(item.x || 0) || 0) - before.x, (Number(item.y || 0) || 0) - before.y) > threshold
|| Math.abs((Number(item.r || item.radius || 0) || 0) - before.r) > threshold;
if (movedForSpatial) markConstraintSpatialDirty(worldRef, item, reason, threshold, minInterval);
else if (worldRef.constraintDirtyStatsThisFrame) worldRef.constraintDirtyStatsThisFrame.coalesced = (worldRef.constraintDirtyStatsThisFrame.coalesced || 0) + 1;
return movedForSpatial;
}
function updateFlexibleLink(item, dt, worldRef) {
const before = beginLinkUpdate(item, worldRef, "rope");
if (!before) return false;
if (before.detached) return true;
const { a, b } = before;
let length = primeLinkState(item, a, b);
let changed = applyDistanceConstraint(item, a, b, dt, length, "rope");
const aa = changed ? endpointWorld(ep(item, 0), worldRef) : a;
const bb = changed ? endpointWorld(ep(item, 1), worldRef) : b;
if (!endpointCanRemainAnchored(ep(item, 0), aa) || !endpointCanRemainAnchored(ep(item, 1), bb)) {
item.amount = 0;
if (worldRef) worldRef.drawListDirty = true;
return true;
}
length = primeLinkState(item, aa, bb);
changed = solveRopeParticleChain(item, aa, bb, dt, length, worldRef) || changed;
if (changed) finishLinkSpatialUpdate(item, worldRef, before, "rope-tension", 0.38, 0.085);
return changed;
}
function updateRigidLink(item, dt, worldRef) {
const before = beginLinkUpdate(item, worldRef, "rod");
if (!before) return false;
if (before.detached) return true;
const { a, b } = before;
const length = primeLinkState(item, a, b);
let changed = applyDistanceConstraint(item, a, b, dt, length, "rod");
const aa = endpointWorld(ep(item, 0), worldRef) || a;
const bb = endpointWorld(ep(item, 1), worldRef) || b;
changed = resolveRigidLinkObstacleContacts(item, aa, bb, dt, worldRef) || changed;
const ca = endpointWorld(ep(item, 0), worldRef) || aa;
const cb = endpointWorld(ep(item, 1), worldRef) || bb;
const stretch = ca && cb ? Math.abs(distXY(ca.x, ca.y, cb.x, cb.y) - length) : 0;
if (stretch > Math.max(1.6, length * 0.012)) {
item._rodStretchCorrectionCount = (item._rodStretchCorrectionCount || 0) + 1;
item._rodLastStretch = stretch;
changed = applyDistanceConstraint(item, ca, cb, Math.min(0.033, dt || 0.016), length, "rod") || changed;
} else {
item._rodLastStretch = stretch;
}
const fa = endpointWorld(ep(item, 0), worldRef) || ca;
const fb = endpointWorld(ep(item, 1), worldRef) || cb;
if (fa && fb) {
item.x = (fa.x + fb.x) * 0.5;
item.y = (fa.y + fb.y) * 0.5;
item.r = Math.max(18, length * 0.5);
ep(item, 0).x = fa.x; ep(item, 0).y = fa.y;
ep(item, 1).x = fb.x; ep(item, 1).y = fb.y;
}
if (changed) finishLinkSpatialUpdate(item, worldRef, before, "rod-constraint", 0.30, 0.070);
return changed;
}
function updateLink(item, dt, worldRef) {
if (!item || item.dead || (item.type !== "rope" && item.type !== "rod")) return false;
item.world = worldRef || item.world || null;
applyConstraintState(item);
let changed = false;
if (item.type === "rope") changed = updateFlexibleLink(item, dt, worldRef);
else if (item.type === "rod") changed = updateRigidLink(item, dt, worldRef);
commitConstraint(item);
return changed;
}
function endpointRuntimeStamp(endpoint, worldRef) {
const obj = resolveEndpointObject(endpoint, worldRef);
const q = (v, scale = 10) => Math.round((Number(v) || 0) * scale);
if (!obj || obj.dead || (Number.isFinite(Number(obj.amount)) && Number(obj.amount) <= 0)) {
return { stamp: "missing", missing: true, obj: null };
}
let stamp = `${endpoint?.kind || "item"}:${obj.id || "?"}:${obj.type || (obj.name ? "tarinai" : "entity")}:${q(obj.x)}:${q(obj.y)}`;
if (obj.radius && obj.name) stamp += `:${q(obj.vx, 4)}:${q(obj.vy, 4)}:${obj.state || ""}`;
else if (global.TarinaiMechanicalSystem.isMechanicalType(obj.type)) {
const a = itemAngleFor(obj);
const shape = Number(obj._mechanicalShapeVersion || 0) || 0;
stamp += `:${q(a, 1000)}:${shape}:${ps(obj, "motorOn", true) !== false ? 1 : 0}:${ps(obj, "railOn", true) !== false ? 1 : 0}:${q(obj.vx, 4)}:${q(obj.vy, 4)}:${q(ps(obj, "spin", 0), 1000)}:${q(ps(obj, "slideSpeed", 0), 4)}`;
} else stamp += `:${q(obj.vx, 4)}:${q(obj.vy, 4)}:${Number(obj.amount || 0) > 0 ? 1 : 0}`;
if (Number.isFinite(Number(endpoint?.localX)) || Number.isFinite(Number(endpoint?.localY))) stamp += `:l${q(endpoint.localX)}:${q(endpoint.localY)}`;
if (Number.isFinite(Number(endpoint?.attachT))) stamp += `:t${q(endpoint.attachT, 1000)}`;
return { stamp, missing: false, obj };
}
function linkRuntimeStamp(item, worldRef) {
const a = endpointRuntimeStamp(ep(item, 0), worldRef);
const b = endpointRuntimeStamp(ep(item, 1), worldRef);
const q = (v, scale = 10) => Math.round((Number(v) || 0) * scale);
return {
stamp: `${item?.type || "link"}:${q(ls(item, "len", 80))}|${a.stamp}|${b.stamp}`,
missing: a.missing || b.missing,
a: a.obj,
b: b.obj,
};
}
function linkNeedsUpdate(item, worldRef) {
if (!item || item.dead) return false;
const now = Number(worldRef?.time || 0) || 0;
const state = linkRuntimeStamp(item, worldRef);
item._linkPendingStamp = state.stamp;
if (state.missing) return true;
if (!item._linkLastStamp || item._linkLastStamp !== state.stamp) {
lset(item, "awakeUntil", Math.max(ls(item, "awakeUntil", 0), now + 0.08));
return true;
}
if (ls(item, "awakeUntil", 0) > now) return true;
if (item.type === "rope") {
if (Math.hypot(ls(item, "midVx", 0), ls(item, "midVy", 0)) > 4.0) return true;
if ((Number(item._nextRopeNearbyProbeAt || 0) || 0) <= now) {
item._nextRopeNearbyProbeAt = now + 0.12;
const radius = Math.max(42, Number(item.r || 0) || Math.max(24, Number(ls(item, "len", 80) || 60) * 0.5)) + 50;
const near = worldRef?.nearbyTarinai?.(item.x || 0, item.y || 0, radius, true) || [];
for (const t of near) {
if (!t || t.dead || worldRef?.isTarinaiHiddenInNestBox?.(t)) continue;
lset(item, "awakeUntil", now + 0.18);
return true;
}
}
}
const interval = item.type === "rope" ? 0.32 : 0.55;
if ((Number(item._nextPassiveLinkUpdateAt || 0) || 0) <= now) {
item._nextPassiveLinkUpdateAt = now + interval;
return true;
}
return false;
}
function noteLinkUpdated(item) {
if (!item) return;
if (item._linkPendingStamp) item._linkLastStamp = item._linkPendingStamp;
item._linkPendingStamp = "";
}
function linkScheduleScore(item, worldRef) {
const now = Number(worldRef?.time || 0) || 0;
let score = item?.type === "rod" ? 2.0 : 1.0;
if (ls(item, "awakeUntil", 0) > now) score += 4.0;
if (item?._linkLastStamp && item._linkPendingStamp && item._linkLastStamp !== item._linkPendingStamp) score += 2.5;
score += Math.min(3, Math.hypot(ls(item, "midVx", 0), ls(item, "midVy", 0)) / 40);
return score;
}
function updateWorld(worldRef, dt = 0.016, opts = {}) {
if (!worldRef?.itemsOfType) return 0;
const profiler = global.TarinaiPerf;
const end = profiler.begin("update.constraints") || null;
try {
worldRef.ensureItemBuckets?.("constraint-world");
const maxLinks = Math.max(8, Number(opts.maxLinks || 160) || 160);
const links = [];
for (const item of worldRef.itemsOfType("rope") || []) if (item && !item.dead) links.push(item);
for (const item of worldRef.itemsOfType("rod") || []) if (item && !item.dead) links.push(item);
if (!links.length) {
worldRef._constraintWorldStats = { visited: 0, ran: 0, skipped: 0, changed: 0, maxLinks, totalLinks: 0 };
return 0;
}
// Rotate the starting point each frame so a large rope/rod set does not
// starve low-score links that are always beyond the per-frame budget.
const cursor = Math.max(0, Math.min(links.length - 1, Number(worldRef._constraintBudgetCursor || 0) || 0));
const ordered = links.slice(cursor).concat(links.slice(0, cursor));
let ran = 0;
let visited = 0;
let skipped = 0;
let changed = 0;
let rodStretchMax = 0;
let rodCorrections = 0;
const pending = [];
for (const item of ordered) {
ensureConstraint(item, worldRef, { syncFromLegacy: false });
applyConstraintState(item);
visited += 1;
if (!linkNeedsUpdate(item, worldRef)) {
skipped += 1;
continue;
}
pending.push({ item, score: linkScheduleScore(item, worldRef) });
}
// Active/strained links run first, but cursor rotation above preserves
// fairness when maxLinks prevents solving every pending link.
pending.sort((a, b) => (b.score - a.score) || ((a.item.id || 0) - (b.item.id || 0)));
for (const entry of pending) {
if (ran >= maxLinks) break;
const beforeRodCorrections = Number(entry.item?._rodStretchCorrectionCount || 0) || 0;
const didChange = updateLink(entry.item, dt, worldRef);
if (entry.item?.type === "rod") {
rodStretchMax = Math.max(rodStretchMax, Number(entry.item._rodLastStretch || 0) || 0);
rodCorrections += Math.max(0, (Number(entry.item._rodStretchCorrectionCount || 0) || 0) - beforeRodCorrections);
}
noteLinkUpdated(entry.item);
ran += 1;
if (didChange) changed += 1;
}
for (let i = ran; i < pending.length; i += 1) pending[i].item._linkPendingStamp = "";
worldRef._constraintBudgetCursor = links.length ? (cursor + Math.max(1, ran || Math.min(maxLinks, links.length))) % links.length : 0;
worldRef._constraintWorldStats = { visited, ran, skipped, changed, maxLinks, totalLinks: links.length, pending: pending.length, cursor: worldRef._constraintBudgetCursor, rodStretchMax, rodCorrections };
return ran;
} finally {
if (end) end();
}
}
function pointSegmentDistance(px, py, ax, ay, bx, by) {
return global.TarinaiGeometry.pointSegmentDistance(px, py, ax, ay, bx, by);
}
const api = Object.freeze({
updateFlexibleLink,
updateRigidLink,
updateWorld,
});
global.TarinaiConstraintSystem = api;
global.TarinaiLinkRuntime = Object.freeze({ endpointWorld, pointSegmentDistance, snapEndpointToTarget, remapLinksForEditedMechanicalItem });
})(typeof window !== "undefined" ? window : globalThis);