tarinai/js/mechanical_system.js
2026-07-16 22:12:03 +09:00

1873 lines
90 KiB
JavaScript

"use strict";
// Layer: physics/mechanical-body
// Common body, footprint, motion, and contact runtime for \u56de\u8ee2\u4f53, \u6bd2\u30d6\u30ed\u30c3\u30af, and \u5f80\u5fa9\u4f53.
(function (global) {
const footprints = global.TarinaiCollisionFootprints;
const num = global.TarinaiCoreHelpers?.finiteOr || ((v, fallback = 0) => {
const n = Number(v);
return Number.isFinite(n) ? n : fallback;
});
function rectCorners(rect, padding = 0) {
return footprints?.rectCorners?.(rect, padding) || [];
}
function rectAxes(rect) {
return footprints?.rectAxes?.(rect) || [{ x: 1, y: 0 }, { x: 0, y: 1 }];
}
function projectPoints(points, axis) {
return footprints?.projectPoints?.(points, axis) || { min: Infinity, max: -Infinity };
}
function rectOverlapInfo(a, b, padding = 0) {
return footprints?.rectOverlapInfo?.(a, b, padding) || null;
}
function typeOf(itemOrType) { return typeof itemOrType === "string" ? itemOrType : String(itemOrType?.type || ""); }
function isMechanicalType(itemOrType) { const t = typeOf(itemOrType); return t === "rotator" || t === "poison_block" || t === "reciprocator"; }
function motionType(itemOrType) { const t = typeOf(itemOrType); return t === "rotator" ? "rotate" : (t === "reciprocator" ? "reciprocate" : (t === "poison_block" ? "passive" : "none")); }
function normalizeAngle(angle = 0) { return typeof global.normalizedItemAngle === "function" ? global.normalizedItemAngle(angle, 0) : angle; }
function bodySystem() { return global.TarinaiPhysicsBodySystem || null; }
function ps(item, key, fallback = 0) { return bodySystem()?.scalar?.(item, key, fallback) ?? fallback; }
function pset(item, key, value, reason = "mechanical-write") { return bodySystem()?.setScalar?.(item, key, value, reason) || false; }
function ensureBody(item, opts = {}) {
if (!item || item.dead || !isMechanicalType(item)) return null;
const api = bodySystem();
return api?.ensureBody?.(item, item.world || null, { syncFromLegacy: opts.syncFromLegacy === true }) || item.physicsBody || null;
}
function bodyOf(item) { return ensureBody(item, { syncFromLegacy: false }); }
function usingStepScratch(item) { return item?._physicsStepScratch === true; }
function runMechanicalStep(item, fn) {
const prevScratch = item._physicsStepScratch === true;
item._physicsStepScratch = true;
try {
return fn();
} finally {
item._physicsStepScratch = prevScratch;
commitBody(item);
}
}
function bodyPose(item) { return usingStepScratch(item) ? null : (bodyOf(item)?.pose || null); }
function bodyVelocity(item) { return bodyOf(item)?.velocity || null; }
function bodyMotor(item) { return bodyOf(item)?.motor || null; }
function bodyRail(item) { return bodyOf(item)?.rail || null; }
function bodyShape(item) { return bodyOf(item)?.shape || null; }
function applyBodyState(item, opts = {}) { return bodySystem()?.applyBodyState?.(item, item?.physicsBody, opts) || false; }
function commitBody(item) {
if (!item || !isMechanicalType(item)) return null;
// Capture the scratch pose before writing it back to the authoritative body.
const sx = num(item.x);
const sy = num(item.y);
const sa = num(item.angle);
let body = item.physicsBody && item.physicsBody.type === item.type ? item.physicsBody : null;
if (!body) body = ensureBody(item, { syncFromLegacy: false });
if (!body) return null;
body.pose = body.pose || { x: sx, y: sy, angle: sa };
body.pose.x = sx;
body.pose.y = sy;
body.pose.angle = sa;
item.x = sx;
item.y = sy;
item.angle = sa;
return body;
}
function isPowered(item) {
const signalOverride = global.TarinaiSignalSystem?.powerOverride?.(item);
if (signalOverride != null) return Boolean(signalOverride);
const motor = bodyMotor(item);
if (motor) return item?.type === "poison_block" ? false : motor.powered !== false;
return item?.type === "rotator" ? ps(item, "motorOn", true) !== false : (item?.type === "reciprocator" ? ps(item, "railOn", true) !== false : false);
}
function itemAngle(item) {
const pose = bodyPose(item);
if (pose && Number.isFinite(Number(pose.angle))) return num(pose.angle);
return typeof global.itemAngleFor === "function" ? global.itemAngleFor(item) : num(item?.angle, 0);
}
function geomCache(item) {
if (!item) return null;
return item._mechanicalGeomCache || (item._mechanicalGeomCache = Object.create(null));
}
function shapeVersion(item) {
const shape = bodyShape(item);
return Number(shape?.version ?? item?._mechanicalShapeVersion ?? 0) || 0;
}
function invalidateGeometry(item) {
if (!item) return false;
const nextVersion = (Number(shapeVersion(item) || 0) || 0) + 1;
const body = ensureBody(item, { syncFromLegacy: true });
if (body) {
body.shape = body.shape || {};
body.shape.version = nextVersion;
}
item._mechanicalShapeVersion = nextVersion;
item._mechanicalGeomCache = null;
item.world?.markSpatialDirty?.("mechanical-geometry-edited");
item.world?.markItemBucketsDirty?.("mechanical-geometry-edited");
return true;
}
function wakeItem(item, reason = "mechanical-wake") {
if (!item || item.dead) return false;
const now = Number(item.world?.time || 0) || 0;
const awakeUntil = Math.max(ps(item, "awakeUntil", 0), now + 0.45);
pset(item, "awakeUntil", awakeUntil, reason);
const body = ensureBody(item, { syncFromLegacy: false });
if (body) {
body.sleep = body.sleep || {};
body.sleep.awakeUntil = Math.max(num(body.sleep.awakeUntil), awakeUntil);
}
if (item.world) {
item.world._itemUpdateScheduler = null;
item.world.markSpatialDirty?.(reason);
}
return true;
}
function passiveItemAwake(item) {
if (!item || item.dead) return false;
if (item.playerHeld || item._heldByPlayer) return true;
const body = bodyOf(item);
const sleep = body?.sleep || null;
const vel = body?.velocity || null;
const awakeUntil = Math.max(num(sleep?.awakeUntil), ps(item, "awakeUntil", 0));
if (awakeUntil > (Number(item.world?.time || 0) || 0)) return true;
if (Math.hypot(num(vel?.x, ps(item, "xv", 0)), num(vel?.y, ps(item, "yv", 0))) > 0.035) return true;
if (Math.abs(num(vel?.angular, ps(item, "spin", 0))) > 0.0015) return true;
return false;
}
function railAxisAngle(item) {
const fallback = itemAngle(item);
return normalizeAngle(num(bodyRail(item)?.axisAngle, ps(item, "railAxis", fallback)));
}
function rawSegmentSignature(raw) {
if (!Array.isArray(raw) || !raw.length) return "0";
// Cheap edit detection for older save/editor paths that do not bump the
// mechanical shape version. This runs only until the local cache is valid.
let h = raw.length * 2166136261;
const step = Math.max(1, Math.floor(raw.length / 24));
for (let i = 0; i < raw.length; i += step) {
const seg = raw[i];
if (!Array.isArray(seg)) continue;
for (let j = 0; j < 4; j += 1) {
h ^= Math.round(num(seg[j]) * 10) & 0xffff;
h = Math.imul(h, 16777619);
}
}
return String(h >>> 0);
}
function normalizeRawSegments(item) {
const shape = bodyShape(item);
const raw = Array.isArray(shape?.segments) ? shape.segments : [];
const out = [];
const clampCoord = (v) => Math.max(-420, Math.min(420, num(v)));
for (const seg of raw) {
if (!Array.isArray(seg) || seg.length < 4) continue;
const x1 = clampCoord(seg[0]), y1 = clampCoord(seg[1]), x2 = clampCoord(seg[2]), y2 = clampCoord(seg[3]);
if (Math.hypot(x2 - x1, y2 - y1) < 4) continue;
out.push([x1, y1, x2, y2]);
if (out.length >= 128) break;
}
if (!out.length) {
if (item?.type === "poison_block") out.push([-52, -20, 52, -20], [52, -20, 52, 20], [52, 20, -52, 20], [-52, 20, -52, -20]);
else out.push(item?.type === "rotator" ? [-78, 0, 78, 0] : [-78, 0, 78, 0], ...(item?.type === "rotator" ? [[0, -52, 0, 52]] : []));
}
return out;
}
function physicsSegmentLimit(item, normalizedCount = 0) {
const base = item?.type === "reciprocator" ? 34 : (item?.type === "poison_block" ? 48 : 58);
// Keep small hand-made shapes exact. Simplification is only for dense free-draw shapes.
if (normalizedCount <= base) return normalizedCount;
return base;
}
function simplifySegmentsForPhysics(item, segments) {
const limit = physicsSegmentLimit(item, segments.length);
if (segments.length <= limit) return segments;
const minLen = item?.type === "poison_block" ? 5.2 : 5.8;
const merged = [];
const angleEps = 0.15;
const joinEps = 7.5;
for (const seg of segments) {
const x1 = seg[0], y1 = seg[1], x2 = seg[2], y2 = seg[3];
const len = Math.hypot(x2 - x1, y2 - y1);
if (len < minLen) continue;
const last = merged[merged.length - 1];
if (last) {
const ldx = last[2] - last[0], ldy = last[3] - last[1];
const dx = x2 - x1, dy = y2 - y1;
const llen = Math.max(0.001, Math.hypot(ldx, ldy));
const dlen = Math.max(0.001, Math.hypot(dx, dy));
const dot = (ldx / llen) * (dx / dlen) + (ldy / llen) * (dy / dlen);
if (Math.hypot(last[2] - x1, last[3] - y1) <= joinEps && dot > 1 - angleEps) {
last[2] = x2;
last[3] = y2;
continue;
}
}
merged.push([x1, y1, x2, y2]);
}
const source = merged.length ? merged : segments;
if (source.length <= limit) return source;
// Preserve coverage rather than perfect ordering: choose the longest segments,
// then restore original order so free-drawn outlines remain visually coherent to physics.
const ranked = source.map((seg, index) => ({
index,
seg,
score: Math.hypot(seg[2] - seg[0], seg[3] - seg[1]) + Math.hypot(seg[0], seg[1]) * 0.015 + Math.hypot(seg[2], seg[3]) * 0.015,
})).sort((a, b) => b.score - a.score).slice(0, limit).sort((a, b) => a.index - b.index);
return ranked.map(e => e.seg);
}
function sanitizeSegments(item) {
const cache = geomCache(item);
const shape = bodyShape(item);
const raw = Array.isArray(shape?.segments) ? shape.segments : [];
const thickKey = Number.isFinite(Number(shape?.thickness)) ? shape.thickness : ps(item, "thickness", item?.type === "poison_block" ? 11 : 12);
const key = `${item?.type || ""}|${shapeVersion(item)}|${raw.length}|${rawSegmentSignature(raw)}|${thickKey || ""}`;
if (cache?.localKey === key && cache.localSegments) return cache.localSegments;
const normalized = normalizeRawSegments(item);
const out = simplifySegmentsForPhysics(item, normalized);
if (cache) {
cache.localKey = key;
cache.localSegments = out;
}
return out;
}
function thickness(item) { return Math.max(4, Math.min(34, num(bodyShape(item)?.thickness, ps(item, "thickness", item?.type === "poison_block" ? 11 : 12)))); }
function extent(item) {
const cache = geomCache(item);
const local = sanitizeSegments(item);
const key = `${cache?.localKey || ""}|${shapeVersion(item)}|${item?.type || ""}|${thickness(item)}`;
if (cache?.extentKey === key && Number.isFinite(cache.extent)) return cache.extent;
let maxD = 48;
for (const seg of local) maxD = Math.max(maxD, Math.hypot(seg[0], seg[1]), Math.hypot(seg[2], seg[3]));
const value = Math.min(460, maxD + Math.max(8, thickness(item)) + 8);
if (cache) { cache.extentKey = key; cache.extent = value; }
return value;
}
function worldSegments(item) {
if (!item || item.dead || !isMechanicalType(item)) return [];
const cache = geomCache(item);
const local = sanitizeSegments(item);
const a = itemAngle(item);
const key = `${cache?.localKey || ""}|${shapeVersion(item)}|${num(item.x).toFixed(3)}|${num(item.y).toFixed(3)}|${a.toFixed(5)}`;
if (cache?.worldKey === key && cache.worldSegments) return cache.worldSegments;
const c = Math.cos(a), s = Math.sin(a);
const pose = bodyPose(item);
const cx = num(pose?.x, item.x), cy = num(pose?.y, item.y);
const segments = local.map(([x1, y1, x2, y2]) => [
cx + x1 * c - y1 * s,
cy + x1 * s + y1 * c,
cx + x2 * c - y2 * s,
cy + x2 * s + y2 * c,
]);
if (cache) { cache.worldKey = key; cache.worldSegments = segments; }
return segments;
}
function axis(item) {
const a = item?.type === "reciprocator" ? railAxisAngle(item) : itemAngle(item);
return { x: Math.cos(a), y: Math.sin(a), angle: a };
}
function halfTravel(item) { return Math.max(24, num(bodyRail(item)?.travel, ps(item, "railTravel", 150))) * 0.5; }
function resetAnchor(item) {
if (!item) return;
const body = ensureBody(item, { syncFromLegacy: false });
const rail = body?.rail || null;
const pose = body?.pose || null;
if (rail) {
if (!Number.isFinite(Number(rail.anchorX))) rail.anchorX = num(pose?.x, item.x);
if (!Number.isFinite(Number(rail.anchorY))) rail.anchorY = num(pose?.y, item.y);
pset(item, "railAnchorX", rail.anchorX, "rail-anchor-init");
pset(item, "railAnchorY", rail.anchorY, "rail-anchor-init");
return;
}
if (!Number.isFinite(Number(rail?.anchorX))) pset(item, "railAnchorX", num(item.x), "rail-anchor-init");
if (!Number.isFinite(Number(rail?.anchorY))) pset(item, "railAnchorY", num(item.y), "rail-anchor-init");
}
function positionFromPhase(item) {
resetAnchor(item);
const a = axis(item);
const travel = halfTravel(item);
const rail = bodyRail(item);
const phase = Math.max(-1, Math.min(1, num(rail?.phase, ps(item, "railPhase", 0))));
return { x: num(rail?.anchorX, ps(item, "railAnchorX", item.x)) + a.x * travel * phase, y: num(rail?.anchorY, ps(item, "railAnchorY", item.y)) + a.y * travel * phase };
}
function signedDrive(item) {
if (!item) return 0;
const vel = bodyVelocity(item);
const motor = bodyMotor(item);
if (item.type === "rotator") return (isPowered(item) ? num(motor?.speed, ps(item, "motorSpeed", 0)) : 0) + num(vel?.angular, ps(item, "spin", 0));
if (item.type === "poison_block") return num(vel?.angular, ps(item, "spin", 0));
if (item.type === "reciprocator") {
const dir = Math.sign(num(motor?.direction, ps(item, "railDir", 1))) || 1;
return (isPowered(item) ? dir * Math.max(0, num(motor?.speed, ps(item, "railMotorSpeed", 0))) : 0) + num(vel?.linear, ps(item, "slideSpeed", 0));
}
return 0;
}
function motionLevel(item, dt = 0.016) {
if (!item || item.dead || !isMechanicalType(item)) return 0;
const speed = Math.abs(signedDrive(item));
if (item.type === "rotator" || item.type === "poison_block") return speed * Math.max(48, extent(item));
return speed;
}
function pointVelocity(item, x, y) {
if (!item) return { x: 0, y: 0 };
if (item.type === "rotator" || item.type === "poison_block") {
const omega = signedDrive(item);
const pose = bodyPose(item);
const vel = bodyVelocity(item);
const rx = num(x) - num(pose?.x, item.x);
const ry = num(y) - num(pose?.y, item.y);
const baseX = item.type === "poison_block" ? num(vel?.x, ps(item, "xv", 0)) : 0;
const baseY = item.type === "poison_block" ? num(vel?.y, ps(item, "yv", 0)) : 0;
return { x: baseX - ry * omega, y: baseY + rx * omega };
}
if (item.type === "reciprocator") {
const a = axis(item);
const v = signedDrive(item);
return { x: a.x * v, y: a.y * v };
}
return { x: 0, y: 0 };
}
function segmentRect(item, x1, y1, x2, y2) {
const len = Math.max(4, Math.hypot(x2 - x1, y2 - y1));
const angle = Math.atan2(y2 - y1, x2 - x1);
const halfW = len / 2;
// Keep mechanical collision close to the drawn stroke. Earlier large
// skins made rotators/reciprocators feel visually offset; tunneling is
// handled by the central substep pair pass instead of over-thick shapes.
const visualThickness = thickness(item);
const collisionSkin = item.type === "poison_block" ? 1.5 : 1.0;
const halfH = visualThickness / 2 + collisionSkin;
const cx = (x1 + x2) / 2;
const cy = (y1 + y2) / 2;
const aabb = global.TarinaiGeometry.orientedRectAabb(cx, cy, halfW, halfH, angle);
const base = {
left: aabb.left, right: aabb.right, top: aabb.top, bottom: aabb.bottom,
cx, cy, halfW, halfH, angle, cos: aabb.cos, sin: aabb.sin, oriented: true,
type: item.type, item, mechanical: true, motionType: motionType(item), restitution: item.type === "rotator" ? 0.72 : 0.70,
};
if (item.type === "rotator") {
return {
...base,
rotator: true,
angularVelocity: signedDrive(item),
centerX: num(bodyPose(item)?.x, item.x), centerY: num(bodyPose(item)?.y, item.y),
};
}
if (item.type === "poison_block") {
return {
...base,
poisonBlock: true,
passiveBlock: true,
restitution: 0.62,
motionVelocityX: num(bodyVelocity(item)?.x, item.vx),
motionVelocityY: num(bodyVelocity(item)?.y, item.vy),
angularVelocity: signedDrive(item),
centerX: num(bodyPose(item)?.x, item.x), centerY: num(bodyPose(item)?.y, item.y),
};
}
const a = axis(item);
const v = signedDrive(item);
return { ...base, reciprocator: true, motionVelocityX: a.x * v, motionVelocityY: a.y * v, axisX: a.x, axisY: a.y };
}
function rectCacheKey(item, kind = "obstacle") {
const cache = geomCache(item);
const body = bodyOf(item);
const pose = body?.pose || {};
const vel = body?.velocity || {};
const motor = body?.motor || {};
const rail = body?.rail || {};
const collision = body?.collision || {};
sanitizeSegments(item);
return [
kind,
cache?.localKey || "",
item?.type || "",
shapeVersion(item),
num(pose.x, item?.x).toFixed(3),
num(pose.y, item?.y).toFixed(3),
itemAngle(item).toFixed(5),
thickness(item).toFixed(2),
collision.solid === false ? 0 : 1,
motor.powered === false ? 0 : 1,
num(motor.speed, ps(item, "motorSpeed", 0)).toFixed(4),
num(vel.angular, ps(item, "spin", 0)).toFixed(5),
motor.powered === false ? 0 : 1,
num(rail.axisAngle, itemAngle(item)).toFixed(5),
num(motor.speed, ps(item, "railMotorSpeed", 92)).toFixed(3),
num(motor.direction, 1),
num(vel.linear, ps(item, "slideSpeed", 0)).toFixed(3),
num(vel.x, item?.vx).toFixed(3),
num(vel.y, item?.vy).toFixed(3),
].join("|");
}
function rectsFor(item, kind) {
const cache = geomCache(item);
const key = rectCacheKey(item, kind);
const prop = kind === "hazard" ? "hazardRects" : "obstacleRects";
const keyProp = `${prop}Key`;
if (cache?.[keyProp] === key && cache[prop]) return cache[prop];
const rects = worldSegments(item).map(seg => segmentRect(item, seg[0], seg[1], seg[2], seg[3]));
if (cache) { cache[keyProp] = key; cache[prop] = rects; }
return rects;
}
function obstacleRects(item) {
if (!item || item.dead || !isMechanicalType(item)) return [];
if (bodyOf(item)?.collision?.solid === false) return [];
return rectsFor(item, "obstacle");
}
function placementRects(item) {
if (!item || item.dead || !isMechanicalType(item)) return [];
return rectsFor(item, "obstacle");
}
function poisonHazardRects(item) {
if (!item || item.dead || item.type !== "poison_block") return [];
return rectsFor(item, "hazard");
}
function aabbFromRects(item, rects, kind = "obstacle") {
if (!rects || !rects.length) return null;
const cache = geomCache(item);
const key = `${kind}|${rectCacheKey(item, kind)}|aabb`;
const prop = kind === "hazard" ? "hazardAabb" : "obstacleAabb";
const keyProp = `${prop}Key`;
if (cache?.[keyProp] === key && cache[prop]) return cache[prop];
const out = {
left: Infinity, right: -Infinity, top: Infinity, bottom: -Infinity,
type: item.type, item,
};
for (const r of rects) {
out.left = Math.min(out.left, r.left);
out.right = Math.max(out.right, r.right);
out.top = Math.min(out.top, r.top);
out.bottom = Math.max(out.bottom, r.bottom);
}
if (!Number.isFinite(out.left) || !Number.isFinite(out.right) || !Number.isFinite(out.top) || !Number.isFinite(out.bottom)) return null;
if (cache) { cache[keyProp] = key; cache[prop] = out; }
return out;
}
function boundsAabb(item) {
return aabbFromRects(item, obstacleRects(item), "obstacle");
}
function reach(item) {
if (!item) return 64;
const base = Math.max(num(item.r, 64), extent(item));
if (item.type === "reciprocator") return Math.max(90, base + num(bodyRail(item)?.travel, ps(item, "railTravel", 150)) * 0.55);
return base;
}
function applyImpulse(item, contactX, contactY, fx, fy, scale = 1) {
if (!item || item.dead || !isMechanicalType(item)) return false;
const powered = isPowered(item);
if (item.type === "poison_block") {
const body = ensureBody(item, { syncFromLegacy: false });
if (!body) return false;
body.velocity = body.velocity || { x: 0, y: 0, angular: 0, linear: 0 };
const vel = body.velocity;
const pose = body.pose || {};
const mass = Math.max(0.20, ps(item, "mass", 0.45));
const prevVx = num(vel.x, item.vx);
const prevVy = num(vel.y, item.vy);
const nextVx = clamp(prevVx + num(fx) * 0.42 * scale / mass, -260, 260);
const nextVy = clamp(prevVy + num(fy) * 0.42 * scale / mass, -260, 260);
vel.x = nextVx;
vel.y = nextVy;
// Keep the legacy Canvas scratch velocity in sync for non-physics helpers
// that still render effects from item.vx/vy, but physics reads body.velocity.
item.vx = nextVx;
item.vy = nextVy;
const rx = num(contactX) - num(pose.x, item.x);
const ry = num(contactY) - num(pose.y, item.y);
const torque = rx * num(fy) - ry * num(fx);
const inertia = Math.max(1200, ps(item, "inertia", 3200));
vel.angular = clamp(num(vel.angular, ps(item, "spin", 0)) + torque / inertia * scale, -2.2, 2.2);
const changed = Math.hypot(nextVx - prevVx, nextVy - prevVy) > 0.002 || Math.abs(torque) > 0.001;
if (changed) { commitBody(item); wakeItem(item, "poison-block-impulse"); }
return changed;
}
if (item.type === "rotator") {
const rx = num(contactX) - num(item.x);
const ry = num(contactY) - num(item.y);
const torque = rx * num(fy) - ry * num(fx);
const denom = powered ? 38000 : 15000;
const delta = clamp(torque / denom * scale, -0.46, 0.46);
if (!Number.isFinite(delta) || Math.abs(delta) < 0.0008) return false;
const limit = powered ? 3.2 : 2.25;
pset(item, "spin", clamp(num(bodyVelocity(item)?.angular, ps(item, "spin", 0)) + delta, -limit, limit), "rotator-torque");
commitBody(item);
return true;
}
const a = axis(item);
const along = num(fx) * a.x + num(fy) * a.y;
let delta = along * (powered ? 0.28 : 0.52) * scale;
if (powered) {
// A powered reciprocator should not lose its motor drive the instant it
// touches something. Contact impulses are kept as a small secondary
// slide component; hard physical blocking is handled by immediate contact reversal.
const driveDir = Math.sign(ps(item, "railDir", 1)) || 1;
const motorSpeed = Math.max(0, ps(item, "railMotorSpeed", 92));
const current = num(bodyVelocity(item)?.linear, ps(item, "slideSpeed", 0));
const opposing = Math.sign(delta || 0) === -driveDir && Math.abs(delta) > motorSpeed * 0.18;
delta = clamp(delta * (opposing ? 0.10 : 0.18), -14, 14);
const next = clamp(current * 0.62 + delta, -Math.max(18, motorSpeed * 0.32), Math.max(18, motorSpeed * 0.32));
if (!Number.isFinite(next) || Math.abs(next - current) < 0.03) return false;
pset(item, "slideSpeed", next, "slide-contact-trim");
} else {
delta = clamp(delta, -52, 52);
if (!Number.isFinite(delta) || Math.abs(delta) < 0.05) return false;
pset(item, "slideSpeed", clamp(num(bodyVelocity(item)?.linear, ps(item, "slideSpeed", 0)) + delta, -150, 150), "slide-impulse");
}
commitBody(item);
return true;
}
function applyPassiveReciprocatorImpulse(item, axisX, axisY, nx, ny, vx, vy, scale = 1) {
if (!item || isPowered(item)) return false;
const ax = num(axisX, 1), ay = num(axisY, 0), nX = num(nx), nY = num(ny);
const hitVx = num(vx), hitVy = num(vy);
const incomingNormal = Math.max(0, -(hitVx * nX + hitVy * nY));
const axisVel = hitVx * ax + hitVy * ay;
const normalDrive = -(nX * ax + nY * ay) * incomingNormal;
const axisDrive = Math.abs(axisVel) >= 3 ? axisVel : 0;
const delta = clamp((axisDrive * 0.030 + normalDrive * 0.080) * scale, -24, 24);
if (!Number.isFinite(delta) || Math.abs(delta) < 0.08) return false;
pset(item, "slideSpeed", clamp(num(bodyVelocity(item)?.linear, ps(item, "slideSpeed", 0)) * 0.84 + delta, -125, 125), "passive-slide-impulse");
commitBody(item);
return true;
}
function applyPassiveRotatorImpulse(item, rx, ry, nx, ny, vx, vy, scale = 1) {
if (!item || isPowered(item)) return false;
const hitVx = num(vx), hitVy = num(vy), nX = num(nx), nY = num(ny);
const arm2 = Math.max(4200, rx * rx + ry * ry);
const tangential = (hitVx * -ry + hitVy * rx) / arm2;
const incomingNormal = Math.max(0, -(hitVx * nX + hitVy * nY));
const normalTorque = ((-nX) * -ry + (-nY) * rx) / Math.max(28, Math.hypot(rx, ry));
const delta = clamp((tangential * 0.14 + normalTorque * incomingNormal * 0.0016) * scale, -0.18, 0.18);
if (!Number.isFinite(delta) || Math.abs(delta) < 0.002) return false;
pset(item, "spin", clamp(num(bodyVelocity(item)?.angular, ps(item, "spin", 0)) * 0.86 + delta, -1.75, 1.75), "passive-spin-impulse");
commitBody(item);
return true;
}
function applySurfaceVelocityToCircle(obj, rect, nx, ny, preVx = 0, preVy = 0, opts = {}) {
if (!obj || !rect || !rect.mechanical) return false;
const nX = num(nx);
const nY = num(ny);
const vx = num(preVx, num(obj.vx));
const vy = num(preVy, num(obj.vy));
const maxSpeed = Math.max(80, num(opts.maxSpeed, 900));
const normalBoost = num(opts.normalBoost, rect.rotator ? 20 : 14);
const surfaceScale = num(opts.surfaceScale, rect.rotator ? 0.28 : 0.30);
const damping = clamp(num(opts.damping, 1), 0, 1.2);
let applied = false;
if (rect.rotator) {
const omega = clamp(num(rect.angularVelocity), -8, 8);
const rx = num(obj.x) - num(rect.centerX, num(rect.item?.x, num(rect.cx)));
const ry = num(obj.y) - num(rect.centerY, num(rect.item?.y, num(rect.cy)));
const tvx = -ry * omega;
const tvy = rx * omega;
obj.vx = clamp(num(obj.vx) * damping + tvx * surfaceScale + nX * normalBoost, -maxSpeed, maxSpeed);
obj.vy = clamp(num(obj.vy) * damping + tvy * surfaceScale + nY * normalBoost, -maxSpeed, maxSpeed);
if (opts.impulseVScale) {
obj.impulseVx = clamp(num(obj.impulseVx) + tvx * num(opts.impulseVScale), -num(opts.impulseMax, 360), num(opts.impulseMax, 360));
obj.impulseVy = clamp(num(obj.impulseVy) + tvy * num(opts.impulseVScale), -num(opts.impulseMax, 360), num(opts.impulseMax, 360));
}
applyPassiveRotatorImpulse(rect.item, rx, ry, nX, nY, vx, vy, num(opts.passiveImpulseScale, 1));
applied = true;
} else if (rect.reciprocator) {
const mvx = clamp(num(rect.motionVelocityX), -360, 360);
const mvy = clamp(num(rect.motionVelocityY), -360, 360);
obj.vx = clamp(num(obj.vx) * damping + mvx * surfaceScale + nX * normalBoost, -maxSpeed, maxSpeed);
obj.vy = clamp(num(obj.vy) * damping + mvy * surfaceScale + nY * normalBoost, -maxSpeed, maxSpeed);
if (opts.impulseVScale) {
obj.impulseVx = clamp(num(obj.impulseVx) + mvx * num(opts.impulseVScale), -num(opts.impulseMax, 320), num(opts.impulseMax, 320));
obj.impulseVy = clamp(num(obj.impulseVy) + mvy * num(opts.impulseVScale), -num(opts.impulseMax, 320), num(opts.impulseMax, 320));
}
applyPassiveReciprocatorImpulse(rect.item, rect.axisX || 1, rect.axisY || 0, nX, nY, vx, vy, num(opts.passiveImpulseScale, 1));
applied = true;
} else if (rect.poisonBlock) {
const omega = clamp(num(rect.angularVelocity), -5.5, 5.5);
const rx = num(obj.x) - num(rect.centerX, num(rect.item?.x, num(rect.cx)));
const ry = num(obj.y) - num(rect.centerY, num(rect.item?.y, num(rect.cy)));
const tvx = clamp(num(rect.motionVelocityX) - ry * omega, -420, 420);
const tvy = clamp(num(rect.motionVelocityY) + rx * omega, -420, 420);
obj.vx = clamp(num(obj.vx) * damping + tvx * surfaceScale + nX * normalBoost, -maxSpeed, maxSpeed);
obj.vy = clamp(num(obj.vy) * damping + tvy * surfaceScale + nY * normalBoost, -maxSpeed, maxSpeed);
if (opts.impulseVScale) {
obj.impulseVx = clamp(num(obj.impulseVx) + tvx * num(opts.impulseVScale), -num(opts.impulseMax, 320), num(opts.impulseMax, 320));
obj.impulseVy = clamp(num(obj.impulseVy) + tvy * num(opts.impulseVScale), -num(opts.impulseMax, 320), num(opts.impulseMax, 320));
}
applyImpulse(rect.item, obj.x, obj.y, -nX * (28 + Math.max(0, -(vx * nX + vy * nY)) * 0.42), -nY * (28 + Math.max(0, -(vx * nX + vy * nY)) * 0.42), num(opts.passiveImpulseScale, 1));
applied = true;
}
if (applied && Number.isFinite(obj.spinVelocity)) obj.spinVelocity = clamp(num(obj.spinVelocity) + (nX >= 0 ? -1 : 1) * num(opts.spinKick, 7.0), -46, 46);
return applied;
}
function applyRailCorrection(item, nx, ny, overlap, scale = 1) {
if (!item || item.dead || item.type !== "reciprocator" || item.playerHeld || item._heldByPlayer) return false;
resetAnchor(item);
const a = axis(item);
const push = Math.max(0, num(overlap)) * Math.max(0, num(scale));
const along = (-num(nx) * a.x + -num(ny) * a.y) * push;
if (!Number.isFinite(along) || Math.abs(along) < 0.01) return false;
const before = ps(item, "railPhase", 0);
pset(item, "railPhase", clamp(before + along / Math.max(10, halfTravel(item)), -1, 1), "rail-correction");
const p = positionFromPhase(item);
item.x = p.x;
item.y = p.y;
const changed = Math.abs(ps(item, "railPhase", 0) - before) > 0.0001;
if (changed) commitBody(item);
return changed;
}
function mechanicalSeparationWeight(item, nx, ny) {
if (!item || item.dead || item.playerHeld || item._heldByPlayer) return 0;
if (item.type === "poison_block") return 1 / Math.max(0.20, ps(item, "mass", 0.45));
if (item.type === "reciprocator") {
const a = axis(item);
const projection = Math.abs(num(nx) * a.x + num(ny) * a.y);
// Reciprocators can only be separated along their rail. Side contacts are
// handled by impulse/brake, not by teleporting the rail body sideways.
return projection < 0.10 ? 0 : projection * projection * 0.72;
}
// Rotators are anchored motors. Translating them would make edited drawings
// drift away from their intended pivot, so they are solved via impulse/brake.
return 0;
}
function moveMechanicalBodyForSeparation(item, dx, dy, dt = 0.016, reason = "mechanical-separation") {
if (!item || item.dead || item.playerHeld || item._heldByPlayer) return false;
if (!Number.isFinite(dx) || !Number.isFinite(dy) || Math.hypot(dx, dy) < 0.001) return false;
if (item.type === "poison_block") {
const beforeX = num(item.x), beforeY = num(item.y);
item.prevX = beforeX;
item.prevY = beforeY;
item.x = beforeX + dx;
item.y = beforeY + dy;
item._physicsExternalPoseDirty = true;
const len = Math.max(0.001, Math.hypot(dx, dy));
const nx = dx / len, ny = dy / len;
const vx = ps(item, "xv", num(item.vx));
const vy = ps(item, "yv", num(item.vy));
const inward = vx * nx + vy * ny;
if (inward < 0) {
pset(item, "xv", vx - nx * inward * 0.58, `${reason}-normal-damp`);
pset(item, "yv", vy - ny * inward * 0.58, `${reason}-normal-damp`);
}
commitBody(item);
wakeItem(item, reason);
return true;
}
if (item.type === "reciprocator") {
resetAnchor(item);
const a = axis(item);
const along = dx * a.x + dy * a.y;
if (!Number.isFinite(along) || Math.abs(along) < 0.001) return false;
const before = ps(item, "railPhase", 0);
pset(item, "railPhase", clamp(before + along / Math.max(10, halfTravel(item)), -1, 1), reason);
const p = positionFromPhase(item);
item.prevX = num(item.x);
item.prevY = num(item.y);
item.x = p.x;
item.y = p.y;
item._physicsExternalPoseDirty = true;
const current = ps(item, "slideSpeed", 0);
if (current * along < 0) pset(item, "slideSpeed", current * 0.38, `${reason}-slide-damp`);
const changed = Math.abs(ps(item, "railPhase", 0) - before) > 0.0001;
if (changed) commitBody(item);
return changed;
}
return false;
}
function applyMechanicalPairSeparation(a, b, info, dt, worldRef) {
if (!a || !b || !info) return false;
const overlap = Math.max(0, num(info.overlap));
if (overlap <= 0.001) return false;
const nx = num(info.nx), ny = num(info.ny);
const wa = mechanicalSeparationWeight(a, -nx, -ny);
const wb = mechanicalSeparationWeight(b, nx, ny);
const sum = wa + wb;
if (sum <= 0.0001) return false;
// Resolve the actual penetration only. The former extra bias made bodies
// visibly pop when many mechanical items touched at once.
const correction = Math.min(Math.max(0, overlap - 0.05) * 0.92, 18);
const ax = -nx * correction * (wa / sum);
const ay = -ny * correction * (wa / sum);
const bx = nx * correction * (wb / sum);
const by = ny * correction * (wb / sum);
const movedA = moveMechanicalBodyForSeparation(a, ax, ay, dt, "pair-separation");
const movedB = moveMechanicalBodyForSeparation(b, bx, by, dt, "pair-separation");
if (movedA || movedB) {
worldRef?.markSpatialDirty?.("mechanical-pair-separation");
worldRef && (worldRef.drawListDirty = true);
}
return movedA || movedB;
}
function applyMechanicalFenceSeparation(item, info, dt, worldRef) {
if (!item || !info) return false;
const overlap = Math.max(0, num(info.overlap));
if (overlap <= 0.001) return false;
const correction = Math.min(Math.max(0, overlap - 0.05) * 0.94, 20);
const moved = moveMechanicalBodyForSeparation(item, -num(info.nx) * correction, -num(info.ny) * correction, dt, "fence-separation");
if (moved) {
worldRef?.markSpatialDirty?.("mechanical-fence-separation");
worldRef && (worldRef.drawListDirty = true);
}
return moved;
}
function noteReciprocatorBlocked(item, dt = 0.016, worldRef = null, reason = "obstacle") {
if (!item || item.dead || item.type !== "reciprocator" || !isPowered(item)) return false;
const now = Number(worldRef?.time || item.world?.time || 0) || 0;
const step = Math.max(0.012, Math.min(0.08, num(dt, 0.016)));
if ((item._reciprocatorBlockedAt || -999) + 0.18 < now) item._reciprocatorBlockedTimer = 0;
item._reciprocatorBlockedAt = now;
item._reciprocatorBlockedTimer = Math.min(1.2, num(item._reciprocatorBlockedTimer) + step);
const cooldownOk = (item._reciprocatorAutoReverseAt || -999) + 0.42 <= now;
if (!cooldownOk || item._reciprocatorBlockedTimer < 0.34) return false;
const before = Math.sign(ps(item, "railDir", 1)) || 1;
pset(item, "railDir", -before, "slide-reverse");
const body = ensureBody(item, { syncFromLegacy: false });
if (body) {
body.motor = body.motor || {};
body.motor.direction = ps(item, "railDir", 1);
body.velocity = body.velocity || {};
body.velocity.linear = -Math.abs(num(body.velocity.linear, ps(item, "slideSpeed", 0))) * before * 0.20;
}
pset(item, "slideSpeed", -Math.abs(ps(item, "slideSpeed", 0)) * before * 0.20, "slide-reverse-passive");
item._reciprocatorBlockedTimer = 0;
item._reciprocatorAutoReverseAt = now;
commitBody(item);
worldRef?.markSpatialDirty?.("reciprocator-auto-reverse");
worldRef && (worldRef.drawListDirty = true);
return true;
}
function reverseReciprocatorOnContact(item, nx, ny, dt = 0.016, worldRef = null, reason = "mechanical-contact") {
if (!item || item.dead || item.type !== "reciprocator" || !isPowered(item)) return false;
const now = Number(worldRef?.time || item.world?.time || 0) || 0;
// Only physical body contacts should interrupt the rail. Static fences and
// ordinary obstacles never call this function.
if ((item._reciprocatorContactReverseAt || -999) + 0.12 > now) return false;
const a = axis(item);
const dir = Math.sign(ps(item, "railDir", 1)) || 1;
const driveX = a.x * dir;
const driveY = a.y * dir;
const ahead = driveX * num(nx) + driveY * num(ny);
// Side brushes and contacts behind the motor are allowed to slide. A
// direct or moderately diagonal contact in the travel direction reverses.
if (ahead < 0.18) return false;
pset(item, "railDir", -dir, "slide-contact-reverse");
pset(item, "slideSpeed", 0, "slide-contact-clear-slide");
const body = ensureBody(item, { syncFromLegacy: false });
if (body) {
body.motor = body.motor || {};
body.motor.direction = -dir;
body.velocity = body.velocity || {};
body.velocity.linear = 0;
}
item._reciprocatorBlockedTimer = 0;
item._reciprocatorContactReverseAt = now;
item._reciprocatorAutoReverseAt = now;
commitBody(item);
if (worldRef) {
worldRef.drawListDirty = true;
worldRef.markSpatialDirty?.("reciprocator-contact-reverse");
}
return true;
}
function brakeRotatorOnContact(item, dt = 0.016, worldRef = null, reason = "mechanical-contact") {
if (!item || item.dead || item.type !== "rotator" || !isPowered(item)) return false;
const now = Number(worldRef?.time || item.world?.time || 0) || 0;
if ((item._rotatorContactBrakeAt || -999) + 0.045 > now) return false;
const motorSpeed = Math.abs(ps(item, "motorSpeed", 0));
if (motorSpeed <= 0) return false;
const drive = signedDrive(item);
const sign = Math.sign(drive || motorSpeed) || 1;
const current = num(bodyVelocity(item)?.angular, ps(item, "spin", 0));
// A powered rotator has a motor, so a contact must produce a temporary
// counter-spin; otherwise two powered rotators visually pass through each
// other while the motor keeps driving at full speed.
const brake = Math.max(0.32, motorSpeed * 0.82);
pset(item, "spin", clamp(current - sign * brake, -3.4, 3.4), "rotator-contact-brake");
item._rotatorContactBrakeAt = now;
commitBody(item);
if (worldRef) {
worldRef.drawListDirty = true;
worldRef.markSpatialDirty?.("rotator-contact-brake");
}
return true;
}
function isPhysicalCircleContactObject(obj) {
if (!obj || obj.dead) return false;
const t = String(obj.type || "");
// Tarinai also collide as circles, but a walking creature should not flip a
// powered rail. Keep contact reversal to item-like physical circles.
if (t === "ball" || t === "stone" || t === "genkotsu" || t === "firecracker" || t === "pushpin" || t === "oshibyo" || t === "zunchi") return true;
return Boolean(obj.physicsBody && !isMechanicalType(t));
}
function itemCircleRadius(obj) {
if (!obj) return 12;
const fromHelper = typeof global.itemRadiusFor === "function" ? global.itemRadiusFor(obj.type, obj.r || obj.radius || 12) : null;
return Math.max(4, num(obj.radius, num(obj.r, Number.isFinite(Number(fromHelper)) ? Number(fromHelper) : 12)));
}
function orientedRectCircleContactInfo(rect, obj, radius, margin = 0.5) {
if (!rect || !obj) return null;
const r = Math.max(0, num(radius));
const m = Math.max(0, num(margin));
const cx = num(obj.x);
const cy = num(obj.y);
if (!rect.oriented) {
const px = Math.max(num(rect.left) - m, Math.min(num(rect.right) + m, cx));
const py = Math.max(num(rect.top) - m, Math.min(num(rect.bottom) + m, cy));
let dx = cx - px;
let dy = cy - py;
let d = Math.hypot(dx, dy);
if (d >= r + m) return null;
if (d < 0.001) { dx = cx - num(rect.cx, (num(rect.left) + num(rect.right)) * 0.5); dy = cy - num(rect.cy, (num(rect.top) + num(rect.bottom)) * 0.5); d = Math.hypot(dx, dy) || 1; }
return { nx: dx / d, ny: dy / d, x: px, y: py, overlap: Math.max(0, r + m - d) };
}
const c = Number.isFinite(rect.cos) ? rect.cos : Math.cos(num(rect.angle));
const ss = Number.isFinite(rect.sin) ? rect.sin : Math.sin(num(rect.angle));
const dxw = cx - num(rect.cx);
const dyw = cy - num(rect.cy);
const lx = dxw * c + dyw * ss;
const ly = -dxw * ss + dyw * c;
const qx = Math.max(-num(rect.halfW) - m, Math.min(num(rect.halfW) + m, lx));
const qy = Math.max(-num(rect.halfH) - m, Math.min(num(rect.halfH) + m, ly));
let dx = lx - qx;
let dy = ly - qy;
let d = Math.hypot(dx, dy);
if (d >= r + m) return null;
if (d < 0.001) {
const left = Math.abs(lx + num(rect.halfW));
const right = Math.abs(num(rect.halfW) - lx);
const top = Math.abs(ly + num(rect.halfH));
const bottom = Math.abs(num(rect.halfH) - ly);
const minSide = Math.min(left, right, top, bottom);
if (minSide === left) { dx = -1; dy = 0; }
else if (minSide === right) { dx = 1; dy = 0; }
else if (minSide === top) { dx = 0; dy = -1; }
else { dx = 0; dy = 1; }
d = 1;
}
const nx = dx / d * c - dy / d * ss;
const ny = dx / d * ss + dy / d * c;
const wx = num(rect.cx) + qx * c - qy * ss;
const wy = num(rect.cy) + qx * ss + qy * c;
return { nx, ny, x: wx, y: wy, overlap: Math.max(0, r + m - d) };
}
function resolveReciprocatorPhysicalContacts(item, dt = 0.016, worldRef = null) {
// Circle contacts are solved as push-away contacts in resolveMechanicalCircleContacts().
// A powered reciprocator must reverse at rail limits or mechanical blockers,
// not merely because it touched Tarinai, a ball, or a pin-like item.
if (!item || item.dead || item.type !== "reciprocator" || !isPowered(item) || !worldRef?.items) return false;
return resolveMechanicalCircleContacts(item, dt, worldRef, reach(item) + 96);
}
function resolveMechanicalCircleContacts(item, dt = 0.016, worldRef = null, radius = null) {
if (!item || item.dead || !isMechanicalType(item) || !worldRef?.items) return false;
const rects = obstacleRects(item);
if (!rects.length) return false;
const queryRadius = radius || reach(item) + 96;
const source = worldRef.nearbyItems?.(item.x, item.y, queryRadius, true) || worldRef.nearbyObstacles?.(item.x, item.y, queryRadius, false) || worldRef.items || [];
let changed = false;
let contacts = 0;
for (const other of source) {
if (!other || other === item || other.dead || !isPhysicalCircleContactObject(other)) continue;
if (typeof isPinType === "function" && isPinType(other.type) && other.pinState === "lodged") continue;
const rr = itemCircleRadius(other);
if (Math.hypot(num(other.x) - num(item.x), num(other.y) - num(item.y)) > queryRadius + rr + 12) continue;
let best = null;
for (const rect of rects) {
const info = orientedRectCircleContactInfo(rect, other, rr, 2.75);
if (!info) continue;
if (!best || info.overlap > best.overlap) best = info;
}
if (!best) continue;
const nx = Number.isFinite(best.nx) ? best.nx : 1;
const ny = Number.isFinite(best.ny) ? best.ny : 0;
const sep = Math.max(0.5, Math.min(28, best.overlap + 1.4));
if (Number.isFinite(other.x)) other.x += nx * sep;
if (Number.isFinite(other.y)) other.y += ny * sep;
const v = pointVelocity(item, best.x, best.y);
const closing = Math.max(0, v.x * nx + v.y * ny);
const baseImpulse = clamp(best.overlap * 7.2 + closing * 0.24 + 8.0, 5.5, item.type === "reciprocator" ? 150 : 210);
if (Number.isFinite(other.vx)) other.vx = clamp((other.vx || 0) + nx * baseImpulse, -1180, 1180);
if (Number.isFinite(other.vy)) other.vy = clamp((other.vy || 0) + ny * baseImpulse, -1180, 1180);
if (other.type === "ball") {
other.prevX = Number.isFinite(other.prevX) ? other.prevX : other.x - nx * sep;
other.prevY = Number.isFinite(other.prevY) ? other.prevY : other.y - ny * sep;
other.spinVelocity = clamp((other.spinVelocity || 0) + (nx >= 0 ? -1 : 1) * (5.8 + Math.min(10, closing * 0.02)), -48, 48);
other.lastKickedAt = worldRef.time || 0;
other.lastKickerId = item.id || "mechanical";
}
if (item.type === "reciprocator") {
// Balls, stones, pins and other movable circle items are cargo, not rail
// blockers. The reciprocator may push them, but its direction, phase
// and motor velocity are left untouched.
} else {
applyImpulse(item, best.x, best.y, -nx * baseImpulse * (item.type === "poison_block" ? 0.38 : 0.16), -ny * baseImpulse * (item.type === "poison_block" ? 0.38 : 0.16), item.type === "poison_block" ? 0.58 : 0.34);
if (item.type === "rotator") brakeRotatorOnContact(item, dt, worldRef, "physical-circle");
}
contacts += 1;
changed = true;
if (contacts >= 18) break;
}
if (changed) {
commitBody(item);
worldRef.markSpatialDirty?.("mechanical-circle-contact");
worldRef.drawListDirty = true;
}
return changed;
}
function strongestRectContact(rectsA, rectsB, padding = 0) {
let best = null;
for (const ra of rectsA || []) {
for (const rb of rectsB || []) {
const info = rectOverlapInfo(ra, rb, padding);
if (!info) continue;
if (!best || info.overlap > best.info.overlap) best = { ra, rb, info };
}
}
return best;
}
function pairFrameGuard(a, b, worldRef, budget = 6) {
if (!worldRef || !a || !b) return false;
const frame = Number(worldRef.frameCount || worldRef.tickCount || worldRef._frameId || 0) || Math.floor((Number(worldRef.time || 0) || 0) * 60);
if (worldRef._mechanicalPairGuardFrame !== frame) {
worldRef._mechanicalPairGuardFrame = frame;
worldRef._mechanicalPairFrameAt = Object.create(null);
}
const key = a.id < b.id ? `${a.id}:${b.id}` : `${b.id}:${a.id}`;
const packed = worldRef._mechanicalPairFrameAt[key];
if (packed && packed.count >= budget) return true;
worldRef._mechanicalPairFrameAt[key] = { count: packed ? packed.count + 1 : 1 };
return false;
}
function resolvePair(a, b, dt, worldRef) {
if (!a || !b || a.dead || b.dead || !isMechanicalType(a) || !isMechanicalType(b)) return false;
if (pairFrameGuard(a, b, worldRef, 6)) return false;
let contact = strongestRectContact(obstacleRects(a), obstacleRects(b), 0.75);
if (!contact) return false;
let info = contact.info;
// Reverse from the initial impact. Separation can fully clear the overlap,
// but that still represents a real collision and must not skip reversal.
if (a.type === "reciprocator") reverseReciprocatorOnContact(a, info.nx, info.ny, dt, worldRef, "mechanical");
if (b.type === "reciprocator") reverseReciprocatorOnContact(b, -info.nx, -info.ny, dt, worldRef, "mechanical");
const separated = applyMechanicalPairSeparation(a, b, info, dt, worldRef);
if (separated) {
// Position projection may fully clear the overlap. Re-query before adding
// impulses; otherwise the solver turns a successful separation into a new kick.
contact = strongestRectContact(obstacleRects(a), obstacleRects(b), 0.25);
if (!contact) {
if (worldRef) { worldRef.drawListDirty = true; worldRef.markSpatialDirty?.("mechanical-contact-separated"); }
return true;
}
info = contact.info;
}
const va = pointVelocity(a, info.x, info.y);
const vb = pointVelocity(b, info.x, info.y);
const relClosing = (va.x - vb.x) * info.nx + (va.y - vb.y) * info.ny;
const closing = Math.max(0, relClosing);
const powerMul = isPowered(a) && isPowered(b) ? 1.18 : (isPowered(a) || isPowered(b) ? 1.02 : 0.72);
const impulse = clamp((closing * 0.14 + info.overlap * 4.7 + 3.2) * powerMul, 2.5, separated ? 42 : 68);
applyImpulse(a, info.x, info.y, -info.nx * impulse, -info.ny * impulse, separated ? 0.78 : 1.0);
applyImpulse(b, info.x, info.y, info.nx * impulse, info.ny * impulse, separated ? 0.78 : 1.0);
applyRailCorrection(a, info.nx, info.ny, info.overlap, b.type === "reciprocator" ? 0.42 : 0.72);
applyRailCorrection(b, -info.nx, -info.ny, info.overlap, a.type === "reciprocator" ? 0.42 : 0.72);
if (a.type === "rotator") brakeRotatorOnContact(a, dt, worldRef, "mechanical");
if (b.type === "rotator") brakeRotatorOnContact(b, dt, worldRef, "mechanical");
if (worldRef) { worldRef.drawListDirty = true; worldRef.markSpatialDirty?.("mechanical-contact"); }
return true;
}
function fenceRectsNear(item, worldRef, radius) {
const out = [];
if (!item || !worldRef?.items) return out;
const source = worldRef.nearbyItems?.(item.x, item.y, radius, true) || worldRef.nearbyObstacles?.(item.x, item.y, radius, false) || worldRef.items;
for (const other of source) {
if (!other || other === item || other.dead || isMechanicalType(other)) continue;
if (other.type === "gate_fence" && other.gateOpen) continue;
for (const rect of worldRef.solidObstacleRects?.(other) || []) out.push(rect);
}
return out;
}
function hasFenceNearby(item, worldRef, radius) {
if (!item || !worldRef?.items) return false;
const source = worldRef.nearbyItems?.(item.x, item.y, radius, true) || worldRef.items;
for (const other of source) {
if (!other || other === item || other.dead || !worldRef.isFenceType?.(other.type)) continue;
if (other.type === "gate_fence" && other.gateOpen) continue;
return true;
}
return false;
}
function resolveFenceContacts(item, dt, worldRef, radius = null) {
if (!item || !worldRef || !isMechanicalType(item)) return false;
const rects = obstacleRects(item);
const fences = fenceRectsNear(item, worldRef, radius || reach(item) + 80);
let changed = false;
for (const mechRect of rects) {
for (const fenceRect of fences) {
const velocity = bodyVelocity(item);
const vx = Number(velocity?.x || item.vx || 0);
const vy = Number(velocity?.y || item.vy || 0);
if (fenceRect?.oneWay && global.TarinaiGeometry.oneWayFenceAllows(item, fenceRect, vx, vy)) continue;
let info = rectOverlapInfo(mechRect, fenceRect, item.type === "rotator" ? 1.35 : 0.5);
if (!info) continue;
if (item.type === "reciprocator") reverseReciprocatorOnContact(item, info.nx, info.ny, dt, worldRef, "solid-obstacle");
const separated = applyMechanicalFenceSeparation(item, info, dt, worldRef);
if (separated) {
const post = strongestRectContact(obstacleRects(item), [fenceRect], 0.2);
if (!post) { changed = true; continue; }
info = post.info;
}
const v = pointVelocity(item, info.x, info.y);
const closing = Math.max(0, v.x * info.nx + v.y * info.ny);
const impulse = clamp(closing * 0.18 + info.overlap * 6.6 + 4.5, 3.5, separated ? 58 : 92);
applyImpulse(item, info.x, info.y, -info.nx * impulse, -info.ny * impulse, separated ? 0.92 : 1.10);
applyRailCorrection(item, info.nx, info.ny, info.overlap, 0.82);
if (item.type === "rotator") brakeRotatorOnContact(item, dt, worldRef, "fence");
changed = true;
}
}
if (changed) { worldRef.drawListDirty = true; worldRef.markSpatialDirty?.("mechanism-fence-contact"); }
return changed;
}
function hasInteractionCandidates(item, worldRef, radius = null) {
if (!item || !worldRef) return false;
const r = radius || reach(item) + 80;
const source = worldRef.nearbyItems?.(item.x, item.y, r, true) || worldRef.nearbyObstacles?.(item.x, item.y, r, false) || worldRef.items || [];
for (const other of source) {
if (!other || other === item || other.dead) continue;
if (isMechanicalType(other) || worldRef.isFenceType?.(other.type) || isPhysicalCircleContactObject(other)) return true;
if (worldRef.solidObstacleRects?.(other)?.length) return true;
}
return false;
}
function resolveInteractions(item, dt, worldRef) {
if (!item || !worldRef?.items || !isMechanicalType(item)) return false;
let changed = false;
const radius = reach(item) + 80;
const source = worldRef.nearbyItems?.(item.x, item.y, radius, true) || worldRef.nearbyObstacles?.(item.x, item.y, radius, false) || worldRef.items;
for (const other of source) {
if (!other || other === item || other.dead || !isMechanicalType(other)) continue;
changed = resolvePair(item, other, dt, worldRef) || changed;
}
changed = resolveMechanicalCircleContacts(item, dt, worldRef, radius) || changed;
changed = resolveFenceContacts(item, dt, worldRef, radius) || changed;
return changed;
}
function updateRotator(item, dt, worldRef, opts = {}) {
if (!item || item.dead || item.type !== "rotator") return false;
if (opts.statePrepared !== true) applyBodyState(item);
return runMechanicalStep(item, () => {
item.amount = 999;
if (item.playerHeld || item._heldByPlayer) return false;
const powered = isPowered(item);
const passiveSpeed = ps(item, "spin", 0);
const speed = (powered ? ps(item, "motorSpeed", 0) : 0) + passiveSpeed;
pset(item, "spin", passiveSpeed * Math.pow(powered ? 0.18 : 0.88, Math.max(0.016, dt || 0.016)), "spin-friction");
if (Math.abs(ps(item, "spin", 0)) < 0.004) pset(item, "spin", 0, "spin-stop");
const ext = extent(item);
item.r = Math.max(item.r || 64, Math.min(460, ext));
const skipInteractions = opts.skipInteractions === true || opts.centralStep === true;
if (!speed || !Number.isFinite(speed)) return (!skipInteractions && hasInteractionCandidates(item, worldRef, ext + 90)) ? resolveInteractions(item, dt, worldRef) : false;
const totalDelta = speed * dt;
if (skipInteractions || !hasInteractionCandidates(item, worldRef, ext + 100)) {
item.prevAngle = num(item.angle);
item.angle = normalizeAngle(num(item.angle) + totalDelta);
item.rotatorSpinPhase = num(item.rotatorSpinPhase) + Math.abs(speed) * dt;
if (worldRef && opts.deferDirty !== true) { worldRef.drawListDirty = true; worldRef.markSpatialDirty?.("rotator-spin-fast"); }
return true;
}
const maxAngleStep = clamp(thickness(item) / Math.max(90, ext) * 0.75, 0.018, 0.055);
const steps = Math.max(1, Math.min(36, Math.ceil(Math.abs(totalDelta) / maxAngleStep)));
const stepDelta = totalDelta / steps;
const stepDt = Math.max(0.001, (dt || 0.016) / steps);
for (let i = 0; i < steps; i += 1) {
item.prevAngle = num(item.angle);
item.angle = normalizeAngle(num(item.angle) + stepDelta);
if (!skipInteractions) resolveInteractions(item, stepDt, worldRef);
}
item.rotatorSpinPhase = num(item.rotatorSpinPhase) + Math.abs(speed) * dt;
if (worldRef && opts.deferDirty !== true) { worldRef.drawListDirty = true; worldRef.markSpatialDirty?.("rotator-spin"); }
return true;
});
}
function integrateReciprocatorPhase(item, total, stepDt, powered, reason = "rail-integrate") {
if (!item || item.dead || item.type !== "reciprocator") return false;
const travel = Math.max(10, halfTravel(item));
const before = ps(item, "railPhase", 0);
const step = Math.max(0.001, num(stepDt, 0.016));
const drive = num(total);
const raw = before + drive * step / travel;
if (!Number.isFinite(raw)) return false;
const eps = 0.000001;
if (powered && ((before >= 1 - eps && drive > 0) || raw >= 1)) {
pset(item, "railPhase", 1, "rail-limit");
pset(item, "railDir", -1, "rail-limit-reverse");
pset(item, "slideSpeed", 0, "rail-limit-clear-slide");
return true;
}
if (powered && ((before <= -1 + eps && drive < 0) || raw <= -1)) {
pset(item, "railPhase", -1, "rail-limit");
pset(item, "railDir", 1, "rail-limit-reverse");
pset(item, "slideSpeed", 0, "rail-limit-clear-slide");
return true;
}
if (raw > 1) {
pset(item, "railPhase", 1, "rail-limit");
if (!powered) pset(item, "slideSpeed", -Math.abs(drive) * 0.20, "rail-limit-bounce");
return Math.abs(before - 1) > 0.0001;
}
if (raw < -1) {
pset(item, "railPhase", -1, "rail-limit");
if (!powered) pset(item, "slideSpeed", Math.abs(drive) * 0.20, "rail-limit-bounce");
return Math.abs(before + 1) > 0.0001;
}
pset(item, "railPhase", raw, reason);
return Math.abs(raw - before) > 0.0001;
}
function repairPoweredReciprocatorStall(item, dt = 0.016, moved = 0, worldRef = null) {
if (!item || item.dead || item.type !== "reciprocator" || !isPowered(item)) return false;
const motorSpeed = Math.max(0, ps(item, "railMotorSpeed", 92));
if (motorSpeed <= 0.001) return false;
const phase = ps(item, "railPhase", 0);
const dir = Math.sign(ps(item, "railDir", 1)) || 1;
let changed = false;
if (phase >= 1 - 0.000001 && dir > 0) { pset(item, "railDir", -1, "rail-stall-limit-repair"); changed = true; }
else if (phase <= -1 + 0.000001 && dir < 0) { pset(item, "railDir", 1, "rail-stall-limit-repair"); changed = true; }
if (Math.abs(num(moved)) <= 0.001) {
const now = Number(worldRef?.time || item.world?.time || 0) || 0;
if ((item._reciprocatorMovedAt || -999) + 0.18 < now) {
// Do not nudge the rail body forward. Treat a stalled powered rail as
// blocked and let the existing blocked/reverse path decide the next move.
pset(item, "slideSpeed", 0, "rail-stall-slide-clear");
if (!changed && Math.abs(phase) < 1 - 0.000001) {
changed = noteReciprocatorBlocked(item, dt, worldRef, "rail-stall-blocked") || changed;
}
}
} else {
item._reciprocatorMovedAt = Number(worldRef?.time || item.world?.time || 0) || 0;
}
if (!changed) return false;
const p = positionFromPhase(item);
item.x = p.x;
item.y = p.y;
commitBody(item);
worldRef?.markSpatialDirty?.("reciprocator-stall-repair");
if (worldRef) worldRef.drawListDirty = true;
return true;
}
function updateReciprocator(item, dt, worldRef, opts = {}) {
if (!item || item.dead || item.type !== "reciprocator") return false;
if (opts.statePrepared !== true) applyBodyState(item);
return runMechanicalStep(item, () => {
item.amount = 999;
resetAnchor(item);
if (item.playerHeld || item._heldByPlayer) { item.prevX = item.x; item.prevY = item.y; return false; }
const powered = isPowered(item);
const prevX = num(item.x, ps(item, "railAnchorX", 0)), prevY = num(item.y, ps(item, "railAnchorY", 0));
item.prevX = prevX; item.prevY = prevY;
const baseSpeed = Math.max(0, ps(item, "railMotorSpeed", 92));
const estimateVelocity = (powered ? Math.sign(ps(item, "railDir", 1)) * baseSpeed : 0) + ps(item, "slideSpeed", 0);
const skipInteractions = opts.skipInteractions === true || opts.centralStep === true;
const steps = Math.max(1, Math.min(32, Math.ceil(Math.abs(estimateVelocity * dt) / 8)));
const stepDt = Math.max(0.001, (dt || 0.016) / steps);
if (Math.abs(estimateVelocity) > 0.001 && (skipInteractions || !hasInteractionCandidates(item, worldRef, reach(item) + 110))) {
const dir = Math.sign(ps(item, "railDir", 1)) || 1;
const passive = ps(item, "slideSpeed", 0);
const total = (powered ? dir * baseSpeed : 0) + passive;
integrateReciprocatorPhase(item, total, dt || 0.016, powered, "rail-integrate");
const p = positionFromPhase(item);
item.x = p.x; item.y = p.y;
if (powered) resolveReciprocatorPhysicalContacts(item, dt || 0.016, worldRef);
} else for (let i = 0; i < steps; i += 1) {
const dir = Math.sign(ps(item, "railDir", 1)) || 1;
const passive = ps(item, "slideSpeed", 0);
const total = (powered ? dir * baseSpeed : 0) + passive;
if (Number.isFinite(total) && Math.abs(total) > 0.001) {
integrateReciprocatorPhase(item, total, stepDt, powered, "rail-integrate");
}
const p = positionFromPhase(item);
item.x = p.x; item.y = p.y;
if (powered) resolveReciprocatorPhysicalContacts(item, stepDt, worldRef);
if (!skipInteractions) resolveInteractions(item, stepDt, worldRef);
}
pset(item, "slideSpeed", ps(item, "slideSpeed", 0) * Math.pow(powered ? 0.22 : 0.90, Math.max(0.016, dt || 0.016)), "slide-friction");
if (Math.abs(ps(item, "slideSpeed", 0)) < 0.08) pset(item, "slideSpeed", 0, "slide-stop");
let moved = Math.hypot(num(item.x) - prevX, num(item.y) - prevY);
if (repairPoweredReciprocatorStall(item, dt || 0.016, moved, worldRef)) moved = Math.hypot(num(item.x) - prevX, num(item.y) - prevY);
if (moved > 0.001 || Math.abs(ps(item, "slideSpeed", 0)) > 0.001 || powered) {
if (worldRef && opts.deferDirty !== true) { worldRef.drawListDirty = true; worldRef.markSpatialDirty?.("reciprocator-motion"); }
return true;
}
return false;
});
}
function constrainPassiveBlockInsideWorld(item, worldRef) {
if (!item || !worldRef) return false;
const pad = Number(global.CONFIG?.worldPadding || 16) || 16;
const reachValue = Math.max(24, reach(item));
let changed = false;
if (num(item.x) < pad + reachValue * 0.25) { item.x = pad + reachValue * 0.25; pset(item, "xv", Math.abs(ps(item, "xv", 0)) * 0.38, "world-boundary"); changed = true; }
if (num(item.y) < pad + reachValue * 0.25) { item.y = pad + reachValue * 0.25; pset(item, "yv", Math.abs(ps(item, "yv", 0)) * 0.38, "world-boundary"); changed = true; }
if (num(item.x) > num(worldRef.w, 1000) - pad - reachValue * 0.25) { item.x = num(worldRef.w, 1000) - pad - reachValue * 0.25; pset(item, "xv", -Math.abs(ps(item, "xv", 0)) * 0.38, "world-boundary"); changed = true; }
if (num(item.y) > num(worldRef.h, 800) - pad - reachValue * 0.25) { item.y = num(worldRef.h, 800) - pad - reachValue * 0.25; pset(item, "yv", -Math.abs(ps(item, "yv", 0)) * 0.38, "world-boundary"); changed = true; }
return changed;
}
function resolvePoisonBlockTarinaiContacts(item, worldRef, dt = 0.016) {
if (!item || item.dead || item.type !== "poison_block" || !worldRef?.tarinai) return false;
if (ps(item, "solid", true) === false) return false;
const rects = obstacleRects(item);
if (!rects.length) return false;
const radius = reach(item) + 96;
const source = worldRef.nearbyTarinai?.(item.x, item.y, radius + 48) || worldRef.tarinai || [];
let changed = false;
let contacts = 0;
for (const t of source) {
if (!t || t.dead || worldRef.isTarinaiHiddenInNestBox?.(t)) continue;
const rr = Math.max(8, (Number(t.radius) || 22) * 0.74);
if (Math.hypot(num(t.x) - num(item.x), num(t.y) - num(item.y)) > radius + rr) continue;
for (const rect of rects) {
if (!global.TarinaiGeometry.circleOverlapsRect(t.x, t.y, rr, rect, 1.0)) continue;
const beforeX = num(t.x);
const beforeY = num(t.y);
const pushed = worldRef.pushTarinaiOutOfRect?.(t, rect, rr, {
maxPush: Math.max(14, rr * 1.65),
slop: 0.45,
});
if (!pushed) continue;
contacts += 1;
changed = true;
const dx = num(t.x) - beforeX;
const dy = num(t.y) - beforeY;
const d = Math.hypot(dx, dy);
if (d > 0.001) {
// Eating/sleeping bodies can be nearly stationary. Feed a small,
// opposite impulse back into the passive block so the contact is not
// visually perceived as the block tunneling through the Tarinai.
const nx = dx / d;
const ny = dy / d;
const speed = Math.hypot(ps(item, "xv", 0), ps(item, "yv", 0));
const impulse = Math.min(34, 5.5 + d * 0.85 + speed * 0.035);
applyImpulse(item, num(t.x) - nx * rr, num(t.y) - ny * rr, -nx * impulse, -ny * impulse, 0.42);
t.lastSolidObstacleCollisionSource = item;
t.lastSolidObstacleCollisionAt = worldRef.time || 0;
}
break;
}
if (contacts >= 10) break;
}
if (changed) {
commitBody(item);
worldRef.markSpatialDirty?.("poison-block-tarinai-contact");
worldRef.drawListDirty = true;
}
return changed;
}
function updatePoisonBlock(item, dt, worldRef, opts = {}) {
if (!item || item.dead || item.type !== "poison_block") return false;
if (opts.statePrepared !== true) applyBodyState(item);
return runMechanicalStep(item, () => {
item.amount = 999;
item.r = Math.max(item.r || 64, Math.min(460, extent(item)));
if (item.playerHeld || item._heldByPlayer) {
item.prevX = item.x;
item.prevY = item.y;
item.prevAngle = num(item.angle);
return false;
}
const stepTime = Math.max(0.001, Math.min(0.05, num(dt, 0.016)));
if (!passiveItemAwake(item)) return false;
const vx = ps(item, "xv", 0), vy = ps(item, "yv", 0), omega = ps(item, "spin", 0);
const moveEstimate = Math.hypot(vx, vy) * stepTime;
const rotEstimate = Math.abs(omega) * stepTime * Math.max(48, item.r || 64);
const skipInteractions = opts.skipInteractions === true || opts.centralStep === true;
const hasContacts = !skipInteractions && ps(item, "solid", true) !== false && hasInteractionCandidates(item, worldRef, reach(item) + 110);
const steps = hasContacts ? Math.max(1, Math.min(28, Math.ceil(Math.max(moveEstimate, rotEstimate) / 8))) : 1;
const subDt = stepTime / steps;
let changed = false;
for (let i = 0; i < steps; i += 1) {
const beforeX = num(item.x), beforeY = num(item.y), beforeA = num(item.angle);
item.prevX = beforeX;
item.prevY = beforeY;
item.prevAngle = beforeA;
item.x = beforeX + ps(item, "xv", 0) * subDt;
item.y = beforeY + ps(item, "yv", 0) * subDt;
item.angle = normalizeAngle(beforeA + ps(item, "spin", 0) * subDt);
changed = constrainPassiveBlockInsideWorld(item, worldRef) || changed;
if (hasContacts) changed = resolveInteractions(item, subDt, worldRef) || changed;
changed = resolvePoisonBlockTarinaiContacts(item, worldRef, subDt) || changed;
changed = changed || Math.hypot(num(item.x) - beforeX, num(item.y) - beforeY) > 0.001 || Math.abs(num(item.angle) - beforeA) > 0.0001;
}
const friction = Math.pow(0.42, stepTime);
const spinFriction = Math.pow(0.36, stepTime);
pset(item, "xv", ps(item, "xv", 0) * friction, "poison-friction");
pset(item, "yv", ps(item, "yv", 0) * friction, "poison-friction");
pset(item, "spin", ps(item, "spin", 0) * spinFriction, "poison-spin-friction");
if (Math.abs(ps(item, "xv", 0)) < 0.035) pset(item, "xv", 0, "poison-stop");
if (Math.abs(ps(item, "yv", 0)) < 0.035) pset(item, "yv", 0, "poison-stop");
if (Math.abs(ps(item, "spin", 0)) < 0.0015) pset(item, "spin", 0, "poison-spin-stop");
if (item.physicsBody?.velocity) {
item.vx = num(item.physicsBody.velocity.x);
item.vy = num(item.physicsBody.velocity.y);
}
if (changed && worldRef && opts.deferDirty !== true) { worldRef.drawListDirty = true; worldRef.markSpatialDirty?.("poison-block-motion"); }
return changed;
});
}
function shouldCollideMechanical(item) {
if (!item || item.dead || !isMechanicalType(item)) return false;
if (item.playerHeld || item._heldByPlayer) return false;
if (bodyOf(item)?.collision?.solid === false) return false;
return obstacleRects(item).length > 0;
}
function isMechanicallyActive(item) {
if (!item || item.dead || !isMechanicalType(item)) return false;
if (item.playerHeld || item._heldByPlayer) return false;
if (item.type === "rotator") return motionLevel(item) > 0.55;
if (item.type === "reciprocator") return motionLevel(item) > 0.08;
if (item.type === "poison_block") return passiveItemAwake(item);
return false;
}
function collectMechanicalBodies(worldRef, opts = {}) {
if (!worldRef?.itemsOfType) return [];
worldRef.ensureItemBuckets?.("mechanical-world");
const maxItems = Math.max(24, Number(opts.maxItems || 220) || 220);
const cacheKey = `${worldRef.itemBucketRebuildsTotal || 0}:${maxItems}`;
const cached = worldRef._mechanicalBodiesCache;
if (cached?.key === cacheKey && Array.isArray(cached.items)) {
let write = 0;
for (let read = 0; read < cached.items.length; read += 1) {
const item = cached.items[read];
if (item && !item.dead) cached.items[write++] = item;
}
cached.items.length = write;
return cached.items;
}
const out = [];
const seen = worldRef._mechanicalBodiesSeen || (worldRef._mechanicalBodiesSeen = new Set());
seen.clear();
const add = (item) => {
if (!item || item.dead || seen.has(item)) return false;
item.world = worldRef;
ensureBody(item, { syncFromLegacy: false });
item.amount = 999;
seen.add(item);
out.push(item);
return true;
};
// Powered reciprocators are autonomous motors. They must not be starved by
// the broad mechanical body cap, otherwise they appear to stop in empty
// space and only move again after another item touches them.
for (const item of worldRef.itemsOfType("reciprocator") || []) {
if (!item || item.dead) continue;
ensureBody(item, { syncFromLegacy: false });
if (isPowered(item) || Math.abs(num(bodyVelocity(item)?.linear, ps(item, "slideSpeed", 0))) > 0.08) add(item);
}
for (const type of ["rotator", "poison_block", "reciprocator"]) {
for (const item of worldRef.itemsOfType(type) || []) {
if (!item || item.dead || seen.has(item)) continue;
if (out.length >= maxItems) break;
add(item);
}
if (out.length >= maxItems) break;
}
worldRef._mechanicalBodiesCache = { key: cacheKey, items: out };
return out;
}
function bodyAabb(item, pad = 0) {
const b = boundsAabb(item);
if (!b) return null;
return {
left: b.left - pad,
right: b.right + pad,
top: b.top - pad,
bottom: b.bottom + pad,
item,
};
}
function bodyAabbOverlap(a, b, pad = 0) {
return footprints?.aabbOverlap?.(a, b, pad) ?? false;
}
function pairKey(a, b) {
const ai = a?.id ?? "a";
const bi = b?.id ?? "b";
return ai < bi ? `${ai}:${bi}` : `${bi}:${ai}`;
}
function physicsFrameId(worldRef) {
if (!worldRef) return 0;
const frame = Number(worldRef.frameCount || worldRef.tickCount || worldRef._frameId || 0);
if (Number.isFinite(frame) && frame > 0) return frame;
return Math.floor((Number(worldRef.time || 0) || 0) * 60);
}
function makeBodyRecord(item, activeSet = null, pad = 6) {
if (!item || item.dead || !isMechanicalType(item)) return null;
const collidable = shouldCollideMechanical(item);
const active = activeSet ? activeSet.has(item) : isMechanicallyActive(item);
return {
item,
active,
collidable,
aabb: collidable ? bodyAabb(item, pad) : null,
reach: reach(item),
};
}
function buildMechanicalFrame(worldRef, opts = {}) {
const bodies = Array.isArray(opts.bodies) ? opts.bodies : collectMechanicalBodies(worldRef, opts);
const activeSet = opts.activeSet || (worldRef?._mechanicalActiveSet || (worldRef._mechanicalActiveSet = new Set()));
if (!opts.activeSet) activeSet.clear();
if (!opts.activeSet) {
for (const item of bodies) if (isMechanicallyActive(item)) activeSet.add(item);
}
const focus = opts.focus !== false && worldRef?.nearbyObstacles && bodies.length > Math.max(36, Number(opts.focusThreshold || 48) || 48) && activeSet.size > 0 && activeSet.size < bodies.length * 0.65;
let source = bodies;
if (focus) {
const chosen = worldRef._mechanicalFocusedSet || (worldRef._mechanicalFocusedSet = new Set());
chosen.clear();
for (const item of activeSet) chosen.add(item);
for (const item of activeSet) {
const radius = Math.max(90, reach(item) + 128);
const candidates = worldRef.nearbyObstacles?.(item.x || 0, item.y || 0, radius, false) || [];
for (const other of candidates) {
if (!other || other === item || other.dead || !isMechanicalType(other.type)) continue;
chosen.add(other);
}
}
source = worldRef._mechanicalFocusedSource || (worldRef._mechanicalFocusedSource = []);
source.length = 0;
for (const item of chosen) source.push(item);
}
const pad = Number.isFinite(Number(opts.aabbPad)) ? Number(opts.aabbPad) : 6;
const records = worldRef?._mechanicalFrameRecords || (worldRef._mechanicalFrameRecords = []);
const byItem = worldRef?._mechanicalFrameByItem || (worldRef._mechanicalFrameByItem = new Map());
records.length = 0;
byItem.clear();
let active = activeSet.size;
let collidable = 0;
for (const item of source) {
const rec = makeBodyRecord(item, activeSet, pad);
if (!rec) continue;
records.push(rec);
byItem.set(item, rec);
if (rec.collidable && rec.aabb) collidable += 1;
}
return {
frame: physicsFrameId(worldRef),
time: Number(worldRef?.time || 0) || 0,
bodies,
activeSet,
records,
byItem,
active,
collidable,
focused: focus,
sourceCount: source.length,
};
}
function broadphaseCandidateRecords(worldRef, frame, opts = {}) {
if (!frame?.records?.length) return [];
const all = frame.records.filter(r => r && r.collidable && r.aabb);
const active = all.filter(r => r.active);
if (!active.length) return [];
const minUseFocused = Number(opts.focusThreshold || 48) || 48;
// When only a few bodies are awake, build the broadphase around those
// bodies plus nearby mechanical neighbors instead of scanning every static
// collider in the field.
if (!worldRef?.nearbyObstacles || all.length <= minUseFocused || active.length > Math.max(10, all.length * 0.45)) return all;
const chosen = worldRef._mechanicalBroadphaseChosen || (worldRef._mechanicalBroadphaseChosen = new Set());
chosen.clear();
for (const r of active) chosen.add(r.item);
const mech = global.TarinaiMechanicalSystem;
for (const r of active) {
const item = r.item;
const radius = Math.max(90, (r.reach || reach(item)) + 128);
const candidates = worldRef.nearbyObstacles?.(item.x || 0, item.y || 0, radius, false) || [];
for (const other of candidates) {
if (!other || other === item || other.dead || !mech?.isMechanicalType?.(other.type)) continue;
chosen.add(other);
}
}
const out = worldRef._mechanicalBroadphaseRecords || (worldRef._mechanicalBroadphaseRecords = []);
out.length = 0;
for (const item of chosen) {
const cached = frame.byItem.get(item);
const rec = cached || makeBodyRecord(item, frame.activeSet, Number.isFinite(Number(opts.aabbPad)) ? Number(opts.aabbPad) : 6);
if (rec?.collidable && rec.aabb) out.push(rec);
}
return out;
}
function buildColliderGrid(records, opts = {}, gridScratch = null) {
const cellSize = Math.max(96, Number(opts.cellSize || 156) || 156);
const grid = gridScratch || new Map();
grid.clear();
const maxCellsPerBody = Math.max(4, Number(opts.maxCellsPerBody || 64) || 64);
let inserted = 0;
for (const rec of records || []) {
if (!rec?.aabb) continue;
const a = rec.aabb;
const minX = Math.floor(a.left / cellSize);
const maxX = Math.floor(a.right / cellSize);
const minY = Math.floor(a.top / cellSize);
const maxY = Math.floor(a.bottom / cellSize);
let cells = (maxX - minX + 1) * (maxY - minY + 1);
if (!Number.isFinite(cells) || cells <= 0) cells = 1;
if (cells > maxCellsPerBody) {
// Oversized AABBs can explode grid insertion cost. Center-bucketing
// keeps the frame bounded; the later narrowphase still checks geometry.
const key = `${Math.floor(((a.left + a.right) * 0.5) / cellSize)}:${Math.floor(((a.top + a.bottom) * 0.5) / cellSize)}`;
let bucket = grid.get(key);
if (!bucket) { bucket = []; grid.set(key, bucket); }
bucket.push(rec);
inserted += 1;
continue;
}
for (let cy = minY; cy <= maxY; cy += 1) {
for (let cx = minX; cx <= maxX; cx += 1) {
const key = `${cx}:${cy}`;
let bucket = grid.get(key);
if (!bucket) { bucket = []; grid.set(key, bucket); }
bucket.push(rec);
inserted += 1;
}
}
}
return { grid, cellSize, inserted };
}
function contactCandidateScore(a, b) {
if (!a?.aabb || !b?.aabb) return -Infinity;
const overlapX = Math.min(a.aabb.right, b.aabb.right) - Math.max(a.aabb.left, b.aabb.left);
const overlapY = Math.min(a.aabb.bottom, b.aabb.bottom) - Math.max(a.aabb.top, b.aabb.top);
if (overlapX < -8 || overlapY < -8) return -Infinity;
let score = 0;
if (a.active) score += 4;
if (b.active) score += 4;
score += Math.max(0, Math.min(overlapX, overlapY) + 8) * 0.22;
score += Math.min(18, Math.max(0, overlapX + 8) * Math.max(0, overlapY + 8) / 900);
const ax = (a.aabb.left + a.aabb.right) * 0.5;
const ay = (a.aabb.top + a.aabb.bottom) * 0.5;
const bx = (b.aabb.left + b.aabb.right) * 0.5;
const by = (b.aabb.top + b.aabb.bottom) * 0.5;
score -= Math.min(5, Math.hypot(ax - bx, ay - by) / 180);
return score;
}
function resolveMechanicalPairsBroadphase(worldRef, dt = 0.016, opts = {}) {
const frame = opts.frame || buildMechanicalFrame(worldRef, opts);
if (!frame || frame.collidable < 2 || frame.active <= 0) return { solved: 0, checked: 0, pairs: 0, candidates: 0, gridCells: 0 };
const records = Array.isArray(opts.records) ? opts.records : broadphaseCandidateRecords(worldRef, frame, opts);
if (records.length < 2) return { solved: 0, checked: 0, pairs: 0, candidates: records.length, gridCells: 0 };
const gridInfo = buildColliderGrid(records, opts, worldRef._mechanicalColliderGrid || (worldRef._mechanicalColliderGrid = new Map()));
const grid = gridInfo.grid;
const seen = Object.create(null);
const maxPairs = Math.max(24, Number(opts.maxPairs || 240) || 240);
const maxCandidates = maxPairs >= 100000000 ? Number.POSITIVE_INFINITY : Math.max(maxPairs + 48, Math.min(900, maxPairs * 3));
const pairCandidates = worldRef._mechanicalPairCandidates || (worldRef._mechanicalPairCandidates = []);
pairCandidates.length = 0;
let pairs = 0;
for (const bucket of grid.values()) {
for (let i = 0; i < bucket.length; i += 1) {
const a = bucket[i];
for (let j = i + 1; j < bucket.length; j += 1) {
const b = bucket[j];
if (!a || !b || a.item === b.item) continue;
if (!a.active && !b.active) continue;
const key = pairKey(a.item, b.item);
if (seen[key]) continue;
seen[key] = true;
if (!bodyAabbOverlap(a.aabb, b.aabb, 8)) continue;
const score = contactCandidateScore(a, b);
if (!Number.isFinite(score)) continue;
pairs += 1;
if (pairCandidates.length < maxCandidates) pairCandidates.push({ a, b, key, score });
else {
// Keep only the highest-priority candidates so pathological overlap
// scenes stay bounded before narrowphase resolution.
let worst = 0;
for (let k = 1; k < pairCandidates.length; k += 1) if (pairCandidates[k].score < pairCandidates[worst].score) worst = k;
if (score > pairCandidates[worst].score) pairCandidates[worst] = { a, b, key, score };
}
}
}
}
pairCandidates.sort((p, q) => q.score - p.score);
let checked = 0;
let solved = 0;
for (const p of pairCandidates) {
if (checked >= maxPairs) break;
checked += 1;
if (resolvePair(p.a.item, p.b.item, dt, worldRef)) solved += 1;
}
return { solved, checked, pairs, candidates: records.length, gridCells: grid.size, queuedPairs: pairCandidates.length };
}
function resolveMechanicalFences(worldRef, dt, bodies, activeSet, opts = {}) {
let solved = 0;
const maxItems = Math.max(12, Number(opts.maxFenceItems || 96) || 96);
let count = 0;
for (const item of bodies || []) {
if (!item || item.dead) continue;
// Fences are static in this runtime. A sleeping poison block or passive body
// cannot create a new fence contact by itself, so skip it before expensive geometry work.
if (activeSet && !activeSet.has(item)) continue;
// Rail-driven reciprocators also participate here. A solid obstacle in
// their travel path is a collision and reverses the motor direction.
if (!shouldCollideMechanical(item)) continue;
if (resolveFenceContacts(item, dt, worldRef, reach(item) + 80)) solved += 1;
count += 1;
if (count >= maxItems) break;
}
return solved;
}
function estimatedBodyMotion(item, dt = 0.016) {
if (!item || item.dead) return 0;
const step = Math.max(0.001, Math.min(0.05, Number(dt || 0.016) || 0.016));
if (item.type === "rotator") {
const speed = signedDrive(item);
return Math.abs(speed) * step * Math.max(48, extent(item));
}
if (item.type === "reciprocator") {
const speed = signedDrive(item);
return Math.abs(speed) * step;
}
if (item.type === "poison_block") {
const vel = bodyVelocity(item);
return Math.hypot(num(vel?.x, ps(item, "xv", 0)), num(vel?.y, ps(item, "yv", 0))) * step + Math.abs(num(vel?.angular, ps(item, "spin", 0))) * step * Math.max(48, extent(item));
}
return 0;
}
function updateMechanicalWorld(worldRef, dt = 0.016, opts = {}) {
if (!worldRef?.itemsOfType) return { ran: 0, active: 0, solved: 0, fenceSolved: 0, links: 0 };
const profiler = global.TarinaiPerf;
const end = profiler.begin("update.mechanicalWorld") || null;
try {
const rawDt = Number(dt || 0.016) || 0.016;
const clampedDt = Math.max(0.001, Math.min(0.05, rawDt));
const bodies = collectMechanicalBodies(worldRef, opts);
if (!bodies.length) return { ran: 0, active: 0, solved: 0, fenceSolved: 0, links: 0, candidates: 0 };
if (opts.statePrepared !== true) {
for (const item of bodies) applyBodyState(item);
}
const active = [];
for (const item of bodies) if (isMechanicallyActive(item)) active.push(item);
const movingWork = active.length;
let maxMotion = 0;
for (const item of active) maxMotion = Math.max(maxMotion, estimatedBodyMotion(item, clampedDt));
let fenceSensitiveMotion = false;
for (const item of active) {
if ((item?.type === "rotator" || item?.type === "reciprocator") && estimatedBodyMotion(item, clampedDt) > 9 && hasFenceNearby(item, worldRef, reach(item) + 94)) { fenceSensitiveMotion = true; break; }
}
const requestedMaxSubsteps = Number(opts.maxSubsteps || 4) || 4;
const substepCap = fenceSensitiveMotion ? Math.min(6, Math.max(requestedMaxSubsteps, requestedMaxSubsteps + 2)) : requestedMaxSubsteps;
const motionSlice = fenceSensitiveMotion ? 8 : 14;
const substeps = Math.max(1, Math.min(substepCap, Math.max(Math.ceil(movingWork / 36), Math.ceil(maxMotion / motionSlice))));
const subDt = clampedDt / substeps;
let ran = 0;
let moved = false;
let stepSolved = 0;
let stepChecked = 0;
let stepPairs = 0;
let stepFenceSolved = 0;
// Resolve body pairs between substeps, not only once after all motion.
// This keeps thin, visually-aligned rotator/reciprocator strokes from
// tunneling through each other without inflating their collision width.
for (let step = 0; step < substeps; step += 1) {
let subMoved = false;
for (const item of active) {
if (!item || item.dead || item.playerHeld || item._heldByPlayer) continue;
let changed = false;
if (item.type === "rotator") changed = updateRotator(item, subDt, worldRef, { centralStep: true, skipInteractions: true, deferDirty: true, statePrepared: true });
else if (item.type === "reciprocator") changed = updateReciprocator(item, subDt, worldRef, { centralStep: true, skipInteractions: true, deferDirty: true, statePrepared: true });
else if (item.type === "poison_block") changed = updatePoisonBlock(item, subDt, worldRef, { centralStep: true, skipInteractions: true, deferDirty: true, statePrepared: true });
if (changed) { moved = true; subMoved = true; ran += 1; }
}
if (subMoved) {
const subFrame = active.length > 1 || active.some(it => it?.type === "rotator") ? buildMechanicalFrame(worldRef, { bodies }) : null;
if (active.length > 1 && subFrame) {
const subStats = resolveMechanicalPairsBroadphase(worldRef, subDt, { frame: subFrame, maxPairs: opts.maxSubstepPairs || 140, focusThreshold: opts.focusThreshold || 48 });
if (subStats.solved) moved = true;
stepSolved += subStats.solved || 0;
stepChecked += subStats.checked || 0;
stepPairs += subStats.pairs || 0;
}
// Fence contacts are checked during high-speed rotator substeps rather
// than only after the final pose. This closes the tunneling gap where
// a thin/hand-drawn rotator can pass entirely across a fence between
// two final-frame overlap checks.
const subFenceSolved = resolveMechanicalFences(worldRef, subDt, active, subFrame?.activeSet || new Set(active), { maxFenceItems: opts.maxSubstepFenceItems || 32 });
if (subFenceSolved) { moved = true; stepFenceSolved += subFenceSolved; }
}
}
if (moved) {
worldRef.drawListDirty = true;
worldRef.markSpatialDirty?.("mechanical-world-motion");
}
// Resolve links after all motors/passive bodies have moved, so constraints see
// one coherent world-state rather than each item solving in isolation.
const links = opts.skipLinks === true ? 0 : (global.TarinaiConstraintSystem.updateWorld(worldRef, clampedDt, { maxLinks: opts.maxLinks || 160 }) || 0);
// Build a single physics frame after motion and reuse it for pair and fence passes.
// This is intentionally save-incompatible: transient body records are runtime only.
const frame = buildMechanicalFrame(worldRef, { bodies });
const pairStats = resolveMechanicalPairsBroadphase(worldRef, clampedDt, { frame, maxPairs: opts.maxPairs || 260, focusThreshold: opts.focusThreshold || 48 });
pairStats.solved = (pairStats.solved || 0) + stepSolved;
pairStats.checked = (pairStats.checked || 0) + stepChecked;
pairStats.pairs = (pairStats.pairs || 0) + stepPairs;
const fenceSolved = stepFenceSolved + resolveMechanicalFences(worldRef, clampedDt, bodies, frame.activeSet, opts);
if (pairStats.solved || fenceSolved || links) {
worldRef.drawListDirty = true;
worldRef.markSpatialDirty?.("mechanical-world-solve");
}
worldRef._mechanicalWorldStats = { bodies: bodies.length, active: frame.active, collidable: frame.collidable, sourceCount: frame.sourceCount || frame.records.length, focused: Boolean(frame.focused), candidates: pairStats.candidates || 0, gridCells: pairStats.gridCells || 0, substeps, fenceSensitive: fenceSensitiveMotion };
return { ran, active: frame.active, solved: pairStats.solved, checked: pairStats.checked, pairs: pairStats.pairs, fenceSolved, links, candidates: pairStats.candidates || 0, gridCells: pairStats.gridCells || 0, substeps, sourceCount: frame.sourceCount || frame.records.length, focused: Boolean(frame.focused) };
} finally {
if (end) end();
}
}
function resolveMechanicalPairs(worldRef, dt = 0.016, opts = {}) {
const stats = resolveMechanicalPairsBroadphase(worldRef, dt, opts);
return stats.solved || 0;
}
function applyPokeImpulse(item, x, y, worldRef) {
if (!item || !isMechanicalType(item)) return false;
if (item.type === "poison_block") {
const dx = num(item.x) - num(x);
const dy = num(item.y) - num(y);
const d = Math.max(12, Math.hypot(dx, dy));
applyImpulse(item, x, y, dx / d * 72, dy / d * 72, 1.0);
wakeItem(item, "poke-poison-block");
worldRef?.markSpatialDirty?.("poke-poison-block");
return true;
}
if (item.type === "rotator" && !isPowered(item)) {
const a = itemAngle(item);
const pose = bodyPose(item);
const side = Math.sign((num(x) - num(pose?.x, item.x)) * Math.cos(a + Math.PI / 2) + (num(y) - num(pose?.y, item.y)) * Math.sin(a + Math.PI / 2)) || (stableUnit(item.id || item.seed || "rotator", "poke-side") < 0.5 ? -1 : 1);
pset(item, "spin", clamp(num(bodyVelocity(item)?.angular, ps(item, "spin", 0)) * 0.82 - side * 0.32, -1.6, 1.6), "pair-spin-response");
commitBody(item);
worldRef?.markSpatialDirty?.("poke-passive-rotator");
return true;
}
if (item.type === "reciprocator" && !isPowered(item)) {
const a = axis(item);
const pose = bodyPose(item);
const side = Math.sign((num(x) - num(pose?.x, item.x)) * a.x + (num(y) - num(pose?.y, item.y)) * a.y) || (stableUnit(item.id || item.seed || "reciprocator", "poke-side") < 0.5 ? -1 : 1);
pset(item, "slideSpeed", clamp(num(bodyVelocity(item)?.linear, ps(item, "slideSpeed", 0)) * 0.76 - side * 34, -115, 115), "pair-slide-response");
commitBody(item);
worldRef?.markSpatialDirty?.("poke-passive-reciprocator");
return true;
}
return false;
}
function hitTest(worldRef, item, x, y, opts = {}) {
return global.TarinaiCollisionFootprints.hitTestItem(worldRef, item, x, y, opts) || { hit: false, distance: Infinity };
}
global.TarinaiMechanicalSystem = Object.freeze({
isMechanicalType,
isPowered,
sanitizeSegments,
obstacleRects,
placementRects,
poisonHazardRects,
extent,
reach,
signedDrive,
motionLevel,
resetAnchor,
axis,
railAxisAngle,
applySurfaceVelocityToCircle,
resolveMechanicalPairs,
updateWorld: updateMechanicalWorld,
updateRotator,
updateReciprocator,
updatePoisonBlock,
applyPokeImpulse,
hitTest,
invalidateGeometry,
wakeItem,
passiveItemAwake,
});
})(typeof window !== "undefined" ? window : globalThis);