1018 lines
48 KiB
JavaScript
1018 lines
48 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 && endpoint.id != null) found = (worldRef.items || []).find(it => it && !it.dead && it.id === endpoint.id);
|
|
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) return false;
|
|
if (point.obj && !point.obj.dead) return true;
|
|
// When a linked Tarinai dies, its live id is released before link cleanup.
|
|
// Keep the affected endpoint as a frozen world-space anchor instead of
|
|
// deleting the rope/rod item; otherwise unrelated links can be swept up by
|
|
// the lifecycle pass that removes zero-amount link items.
|
|
return endpoint.kind === "tarinai" && Number.isFinite(Number(point.x)) && Number.isFinite(Number(point.y));
|
|
}
|
|
|
|
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.renderQualityTier() || "high";
|
|
const ropeCount = Number(worldRef?.itemCounts?.rope || 0) || 0;
|
|
let spacing = 34;
|
|
let maxParticles = 24;
|
|
let iterations = 6;
|
|
let particleStride = 1;
|
|
if (tier === "mid") { spacing = 42; maxParticles = 20; iterations = 5; }
|
|
else 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.TarinaiCollisionFootprints.rectLocalPoint(r, x, y);
|
|
}
|
|
|
|
function rectWorldNormalForConstraint(r, nx, ny) {
|
|
const c = Number.isFinite(r.cos) ? r.cos : Math.cos(r.angle || 0);
|
|
const s = Number.isFinite(r.sin) ? r.sin : Math.sin(r.angle || 0);
|
|
return { x: nx * c - ny * s, y: nx * s + ny * c };
|
|
}
|
|
|
|
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 : (lod.tier === "mid" ? 28 : 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 : (lod.tier === "mid" ? iter === 2 : (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;
|
|
const mx = (p1.x + p2.x) * 0.5;
|
|
const my = (p1.y + p2.y) * 0.5;
|
|
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 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(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 true;
|
|
}
|
|
const beforeSpatialX = Number(item.x || 0) || 0;
|
|
const beforeSpatialY = Number(item.y || 0) || 0;
|
|
const beforeSpatialR = Number(item.r || item.radius || 0) || 0;
|
|
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 && worldRef) {
|
|
worldRef.drawListDirty = true;
|
|
const movedForSpatial = Math.hypot((Number(item.x || 0) || 0) - beforeSpatialX, (Number(item.y || 0) || 0) - beforeSpatialY) > 0.38
|
|
|| Math.abs((Number(item.r || item.radius || 0) || 0) - beforeSpatialR) > 0.38;
|
|
if (movedForSpatial) markConstraintSpatialDirty(worldRef, item, "rope-tension", 0.38, 0.085);
|
|
else if (worldRef.constraintDirtyStatsThisFrame) worldRef.constraintDirtyStatsThisFrame.coalesced = (worldRef.constraintDirtyStatsThisFrame.coalesced || 0) + 1;
|
|
}
|
|
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(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 true;
|
|
}
|
|
const beforeSpatialX = Number(item.x || 0) || 0;
|
|
const beforeSpatialY = Number(item.y || 0) || 0;
|
|
const beforeSpatialR = Number(item.r || item.radius || 0) || 0;
|
|
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 && worldRef) {
|
|
worldRef.drawListDirty = true;
|
|
const movedForSpatial = Math.hypot((Number(item.x || 0) || 0) - beforeSpatialX, (Number(item.y || 0) || 0) - beforeSpatialY) > 0.30
|
|
|| Math.abs((Number(item.r || item.radius || 0) || 0) - beforeSpatialR) > 0.30;
|
|
if (movedForSpatial) markConstraintSpatialDirty(worldRef, item, "rod-constraint", 0.30, 0.070);
|
|
else if (worldRef.constraintDirtyStatsThisFrame) worldRef.constraintDirtyStatsThisFrame.coalesced = (worldRef.constraintDirtyStatsThisFrame.coalesced || 0) + 1;
|
|
}
|
|
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) {
|
|
if (endpoint?.kind === "tarinai" && Number.isFinite(Number(endpoint.x)) && Number.isFinite(Number(endpoint.y))) {
|
|
return { stamp: `tarinai-anchor:${endpoint.id || "?"}:${q(endpoint.x)}:${q(endpoint.y)}`, missing: false, obj: null };
|
|
}
|
|
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) {
|
|
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({
|
|
updateFlexibleLink,
|
|
updateRigidLink,
|
|
updateWorld,
|
|
});
|
|
|
|
global.TarinaiConstraintSystem = api;
|
|
global.TarinaiLinkRuntime = Object.freeze({ endpointWorld, pointSegmentDistance, snapEndpointToTarget, remapLinksForEditedMechanicalItem });
|
|
})(typeof window !== "undefined" ? window : globalThis);
|