"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 commitConstraint(item) { if (!item || !["rope", "rod", "spring", "wire", "insulated_wire"].includes(item.type)) return null; const c = ensureConstraint(item, item.world || null, { syncFromLegacy: false }); if (!c) return null; return c; } function commitMechanicalBody(item) { if (!item || !global.TarinaiMechanicalSystem.isMechanicalType(item.type)) return false; // Constraint pulls mutate item.x/y as a scratch pose; preserve that pose in // the authoritative physics body before the next frame begins. bodyApi()?.syncPoseFromItem?.(item); 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 marked = global.TarinaiPhysicsProjectionSystem.markSpatialDirty(world, item, reason, threshold, minInterval) ?? false; if (world.constraintDirtyStatsThisFrame) { const key = marked ? "marked" : "coalesced"; world.constraintDirtyStatsThisFrame[key] = (world.constraintDirtyStatsThisFrame[key] || 0) + 1; } return marked; } 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); 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" || endpoint.kind === "ant")) return cached; if (endpoint.kind === "ant") { let found = (worldRef.ants || []).find(ant => ant && !ant.dead && ant.id === endpoint.id) || null; if (!found && allowFuzzy && Number.isFinite(endpoint.x) && Number.isFinite(endpoint.y)) { const source = worldRef.nearbyAnts?.(endpoint.x, endpoint.y, 48, true) || worldRef.ants || []; found = source.find(ant => ant && !ant.dead && distXY(ant.x, ant.y, endpoint.x, endpoint.y) <= Math.max(16, (ant.r || 6) * 2.4)) || null; if (found) { endpoint.id = found.id; endpoint.fuzzyResolve = false; endpoint.fuzzyFallback = false; } } endpoint._ref = found || null; return found || null; } 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 || !["rope", "rod", "spring", "wire", "insulated_wire"].includes(item.type)) 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 && ["rope", "rod", "spring", "wire", "insulated_wire"].includes(obj.type) && 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 (["rope", "rod", "spring", "wire", "insulated_wire"].includes(obj.type) && 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 === "balloon") return 0.22; if (obj.type === "ball") return 0.7; if (obj.type === "zunchi") return 0.55; if (["rope", "rod", "spring", "wire", "insulated_wire"].includes(obj.type)) 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" || obj.type === "pipe")) 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 (["rope", "rod", "spring", "wire", "insulated_wire"].includes(obj.type) && 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); 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; const target = delta / (1 + compliance); const cap = mode === "rod" ? Math.max(6.0, Math.min(46, length * maxFraction)) : Math.max(12, 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") { // A rope may go slack, but must not become longer than its recorded length. // Re-read anchors between projections because the endpoint objects move. const iterations = 8; 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; if (distXY(aa.x, aa.y, bb.x, bb.y) <= length + 0.25) break; changed = solveOnce(aa, bb, 1.0, 0.35) || 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 ropeCount = Number(worldRef?.itemCounts?.rope || 0) || 0; let spacing = 34; let maxParticles = 24; let iterations = 6; let particleStride = 1; 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 { 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 = []); ropeObstacleRects = worldRef.nearbySolidObstacleRects(ropeCx, ropeCy, ropeQueryRadius + 64, { maxChecks: 38 }) || []; 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 = 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; } } } } for (let iter = 0; iter < 4; 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; 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)); if (d <= rest + 0.02) continue; const corrX = dx * ((d - rest) / d); const corrY = dy * ((d - rest) / d); 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; } } } 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, reason, threshold, minInterval) { if (!worldRef || !item) return false; worldRef.drawListDirty = true; return markConstraintSpatialDirty(worldRef, item, reason, threshold, minInterval); } 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, "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, "rod-constraint", 0.30, 0.070); return changed; } function endpointVelocity(obj, dt = 0.016) { if (!obj) return { x: 0, y: 0 }; if (global.TarinaiMechanicalSystem.isMechanicalType(obj.type)) { return { x: ps(obj, "xv", Number(obj.vx || 0) || 0), y: ps(obj, "yv", Number(obj.vy || 0) || 0) }; } const step = Math.max(0.008, Math.min(0.05, Number(dt || 0.016) || 0.016)); const vx = Number(obj.vx); const vy = Number(obj.vy); return { x: Number.isFinite(vx) ? vx : ((Number(obj.x || 0) - Number(obj.prevX ?? obj.x ?? 0)) / step), y: Number.isFinite(vy) ? vy : ((Number(obj.y || 0) - Number(obj.prevY ?? obj.y ?? 0)) / step), }; } function addSpringVelocity(obj, dvx, dvy, worldRef) { if (!obj || obj.dead || obj._heldByPlayer || obj.playerHeld) return false; if (!Number.isFinite(dvx) || !Number.isFinite(dvy) || Math.hypot(dvx, dvy) < 0.001) return false; if (obj.radius && obj.name) { // Tarinai steering and state updates damp ordinary velocity quickly. Put // most spring recoil in the external impulse channel so the body keeps // oscillating instead of immediately cancelling the bounce. obj.vx = (Number(obj.vx) || 0) + dvx * 0.14; obj.vy = (Number(obj.vy) || 0) + dvy * 0.14; obj.impulseVx = (Number(obj.impulseVx) || 0) + dvx * 1.26; obj.impulseVy = (Number(obj.impulseVy) || 0) + dvy * 1.26; global.TarinaiMovementUpdateStep?.markSleepExternalMotion?.(obj, 0.016, "spring-recoil"); return true; } if (obj.type === "poison_block") { pset(obj, "xv", ps(obj, "xv", 0) + dvx, "spring-impulse"); pset(obj, "yv", ps(obj, "yv", 0) + dvy, "spring-impulse"); global.TarinaiMechanicalSystem.wakeItem?.(obj, "spring-impulse"); return true; } if (obj.type === "reciprocator") { const axis = global.TarinaiMechanicalSystem.axis?.(obj) || reciprocatorAxis(obj); const along = dvx * axis.x + dvy * axis.y; if (Math.abs(along) < 0.001) return false; pset(obj, "slideSpeed", ps(obj, "slideSpeed", 0) + along, "spring-rail-impulse"); global.TarinaiMechanicalSystem.wakeItem?.(obj, "spring-rail-impulse"); return true; } if (obj.type === "rotator" || (obj.type && obj.type.includes("fence"))) return false; obj.vx = (Number(obj.vx) || 0) + dvx; obj.vy = (Number(obj.vy) || 0) + dvy; global.TarinaiItemUpdateScheduler?.wakeItem?.(worldRef || obj.world || null, obj, 0.35); return true; } function applyPassiveSpring(item, a, b, dt, rest, worldRef) { if (!a?.obj || !b?.obj) return false; const dx = b.x - a.x; const dy = b.y - a.y; const distance = Math.max(0.001, Math.hypot(dx, dy)); const delta = distance - rest; const nx = dx / distance; const ny = dy / distance; const va = endpointVelocity(a.obj, dt); const vb = endpointVelocity(b.obj, dt); const relativeSpeed = (vb.x - va.x) * nx + (vb.y - va.y) * ny; 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 inverseSum = ia + ib; if (inverseSum <= 0) return false; const step = Math.max(0.008, Math.min(0.04, Number(dt || 0.016) || 0.016)); // Hooke force plus light damping. There is no time-driven target: the // spring only reacts when external motion stretches or compresses it. // Deliberately under-damped and stiff: it remains passive, but external // displacement produces a large, visibly oscillating recoil. const stiffness = 92.0; const damping = 0.06; const impulse = clamp((delta * stiffness + relativeSpeed * damping) * step, -250, 250); let changed = false; if (Math.abs(impulse) > 0.015) { changed = addSpringVelocity(a.obj, nx * impulse * (ia / inverseSum), ny * impulse * (ia / inverseSum), worldRef) || changed; changed = addSpringVelocity(b.obj, -nx * impulse * (ib / inverseSum), -ny * impulse * (ib / inverseSum), worldRef) || changed; } // A small positional correction prevents unbounded stretching while // retaining enough inertia for visible elastic recoil. if (Math.abs(delta) > 1.0) { const correction = clamp(delta * 0.004, -0.95, 0.95); changed = moveEndpointObject(a.obj, nx * correction * (ia / inverseSum), ny * correction * (ia / inverseSum), ep(item, 0), step, 0.92, 0, "spring") || changed; changed = moveEndpointObject(b.obj, -nx * correction * (ib / inverseSum), -ny * correction * (ib / inverseSum), ep(item, 1), step, 0.92, 0, "spring") || changed; } return changed; } function updateSpringLink(item, dt, worldRef) { const before = beginLinkUpdate(item, worldRef, "spring"); if (!before) return false; if (before.detached) return true; const { a, b } = before; const rest = primeLinkState(item, a, b); const changed = applyPassiveSpring(item, a, b, dt, rest, worldRef); const fa = endpointWorld(ep(item, 0), worldRef) || a; const fb = endpointWorld(ep(item, 1), worldRef) || b; if (fa && fb) { item.x = (fa.x + fb.x) * 0.5; item.y = (fa.y + fb.y) * 0.5; item.r = Math.max(18, distXY(fa.x, fa.y, fb.x, fb.y) * 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; const residual = Math.abs(distXY(fa.x, fa.y, fb.x, fb.y) - rest); if (changed || residual > 0.8) lset(item, "awakeUntil", Number(worldRef?.time || 0) + 0.36); } if (changed) finishLinkSpatialUpdate(item, worldRef, "spring-constraint", 0.22, 0.035); return changed; } function updateLink(item, dt, worldRef) { if (!item || item.dead || !["rope", "rod", "spring"].includes(item.type)) return false; item.world = worldRef || item.world || null; let changed = false; if (item.type === "rope") changed = updateFlexibleLink(item, dt, worldRef); else if (item.type === "rod") changed = updateRigidLink(item, dt, worldRef); else if (item.type === "spring") changed = updateSpringLink(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; } } } if (item.type === "spring") 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 === "spring" ? 6.0 : (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 = worldRef._constraintLinksScratch || (worldRef._constraintLinksScratch = []); links.length = 0; 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); for (const item of worldRef.itemsOfType("spring") || []) 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)); let ran = 0; let visited = 0; let skipped = 0; let changed = 0; let rodStretchMax = 0; let rodCorrections = 0; const pending = worldRef._constraintPendingScratch || (worldRef._constraintPendingScratch = []); pending.length = 0; for (let n = 0; n < links.length; n += 1) { const item = links[(cursor + n) % links.length]; if (opts.statePrepared === true) ensureConstraint(item, worldRef, { syncFromLegacy: false }); else 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(); } } const api = Object.freeze({ updateFlexibleLink, updateRigidLink, updateSpringLink, updateWorld, }); global.TarinaiConstraintSystem = api; global.TarinaiLinkRuntime = Object.freeze({ endpointWorld, snapEndpointToTarget, remapLinksForEditedMechanicalItem }); })(typeof window !== "undefined" ? window : globalThis);