1873 lines
90 KiB
JavaScript
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.nearbyNonGrassItems?.(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.nearbyNonGrassItems?.(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.nearbyNonGrassItems?.(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.nearbyNonGrassItems?.(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.nearbyNonGrassItems?.(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);
|