"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);