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

154 lines
7.9 KiB
JavaScript

"use strict";
// Layer: physics/footprints
// Shared hit-testing and placement-overlap geometry for oriented rect, circle,
// and item footprint checks. This keeps placement, grabbing, and physics using
// the same current-frame shape instead of separate radius approximations.
(function (global) {
const Geometry = global.TarinaiGeometry;
const num = Geometry.num;
const rectLocalPoint = Geometry.rectLocalPoint;
const rectWorldVector = Geometry.rectWorldVector;
const rectCorners = Geometry.rectCorners;
const rectAxes = Geometry.rectAxes;
const projectPoints = Geometry.projectPoints;
const aabbOverlap = Geometry.aabbOverlap;
const rectOverlapInfo = Geometry.rectOverlapInfo;
const rectsOverlap = Geometry.rectsOverlap;
function rectCircleOverlap(rect, cx, cy, radius, margin = 0) {
if (!rect) return false;
const r = Math.max(0, num(radius));
const m = Math.max(0, num(margin));
if (rect.oriented) {
const local = rectLocalPoint(rect, cx, cy);
const qx = Math.max(-num(rect.halfW) - m, Math.min(num(rect.halfW) + m, local.x));
const qy = Math.max(-num(rect.halfH) - m, Math.min(num(rect.halfH) + m, local.y));
return Math.hypot(local.x - qx, local.y - qy) < r + m;
}
const px = Math.max(num(rect.left) - m, Math.min(num(rect.right) + m, num(cx)));
const py = Math.max(num(rect.top) - m, Math.min(num(rect.bottom) + m, num(cy)));
return Math.hypot(num(cx) - px, num(cy) - py) < r + m;
}
function itemFootprintRadius(worldRef, item, rects = null) {
if (!item) return 14;
const ownRects = Array.isArray(rects) ? rects : (worldRef?.solidObstacleRects?.(item) || []);
if (ownRects.length) {
let reach = 14;
const ix = num(item.x);
const iy = num(item.y);
for (const r of ownRects) {
for (const p of rectCorners(r, 0)) reach = Math.max(reach, Math.hypot(p.x - ix, p.y - iy));
}
return Math.max(14, reach);
}
const radiusFor = global.itemRadiusFor;
if (item.type === "duplicator") {
// \u4FDD\u5B58\u6E08\u307F\u30C7\u30FC\u30BF\u306B\u53E4\u3044 r=34 \u304C\u6B8B\u3063\u3066\u3044\u3066\u3082\u3001\u914D\u7F6E\u5224\u5B9A\u306F\u5916\u89B3\u306B\u8FD1\u3044\u534A\u5F84\u3067\u6271\u3046\u3002
return Math.max(14, Math.min(24, num(item.r, radiusFor(item.type, 24)) || radiusFor(item.type, 24)));
}
return Math.max(14, num(item.r, radiusFor(item.type, 12)) || radiusFor(item.type, 12));
}
function isSoftPlacementIgnoredType(type = "") {
const key = String(type || "");
return key === "zunchi" || key === "grass_bed" || key === "grass" || key === "water";
}
function placementRectsFor(worldRef, item) {
const solid = worldRef?.solidObstacleRects?.(item) || [];
if (solid.length) return solid;
if (global.TarinaiMechanicalSystem.isMechanicalType(item?.type)) return global.TarinaiMechanicalSystem.placementRects(item) || [];
if (item?.type === "circuit_board") {
const circuit = global.TarinaiCircuitBoardSystem;
const halfW = Number(circuit?.boardHalfWidth?.(item) || 32) || 32;
const halfH = Number(circuit?.boardHalfHeight?.(item) || 32) || 32;
return [{ oriented: true, cx: num(item.x), cy: num(item.y), halfW, halfH, angle: num(item.angle), type: item.type, item }];
}
return solid;
}
function hitTestItem(worldRef, item, x, y, opts = {}) {
if (!item || item.dead) return { hit: false, distance: Infinity };
const padding = Math.max(0, num(opts.padding, 0));
let rects = opts.rects || worldRef?.solidObstacleRects?.(item) || [];
if ((!rects || !rects.length) && item?.type === "poison_block") rects = global.TarinaiMechanicalSystem.poisonHazardRects(item) || [];
let bestDistance = Math.hypot(num(x) - num(item.x), num(y) - num(item.y));
let hit = false;
if (rects.length) {
for (const rect of rects) {
if (Geometry.pointInRect(x, y, rect, padding)) hit = true;
bestDistance = Math.min(bestDistance, Math.hypot(num(x) - num(rect.cx, item.x), num(y) - num(rect.cy, item.y)));
}
}
const mechHit = global.TarinaiMechanicalSystem.isMechanicalType(item.type);
if (mechHit) {
const hub = Math.max(18, Math.min(40, (global.TarinaiPhysicsBodySystem.scalar(item, "thickness", 12) ?? 12) * 2.3));
if (Math.hypot(num(x) - num(item.x), num(y) - num(item.y)) <= hub + padding) hit = true;
} else if (!rects.length) {
const baseRadius = item.type === "duplicator" ? Math.min(24, num(item.r, 24) || 24) : num(item.r, 12);
const defaultMul = (item.type === "ball" || item.type === "balloon") ? 4.0 : (item.type === "duplicator" ? 1.45 : 2.2);
const mul = num(opts.radiusMultiplier, defaultMul);
const r = Math.max(item.type === "duplicator" ? 18 : 24, baseRadius * mul);
hit = bestDistance <= r + padding;
}
return { hit, distance: bestDistance };
}
function itemPlacementOverlapBlocked(worldRef, item, opts = {}) {
if (!worldRef || !item || item.dead) return true;
const important = opts.important || new Set(["zunchi", "grass", "grass_bed", "water", "fence_v", "fence_h", "glass_wall", "bounce_fence", "bounce_fence_v", "gate_fence", "one_way_fence", "rotator", "poison_block", "reciprocator", "nest_box", "pipe"]);
const isPhysicsItem = (global.TarinaiMechanicalSystem.isMechanicalType(item.type) || ["rope", "rod", "spring", "wire", "insulated_wire"].includes(item.type) || isFenceItemType(item.type));
if (!important.has(item.type) && !isPhysicsItem) return false;
const itemRects = placementRectsFor(worldRef, item);
const itemRadius = itemFootprintRadius(worldRef, item, itemRects);
const search = Math.max(132, itemRadius * 3.2);
for (const other of worldRef.nearbyItems?.(item.x, item.y, search) || []) {
if (!other || other === item || other.dead) continue;
if (isSoftPlacementIgnoredType(other.type)) continue;
const freeStacking = global.TarinaiItemToolMetadata?.ITEM_TRAITS?.freeStackingMovable;
if (freeStacking?.has(item.type) && freeStacking.has(other.type)) continue;
if (!important.has(other.type)) continue;
const otherRects = placementRectsFor(worldRef, other);
const otherRadius = itemFootprintRadius(worldRef, other, otherRects);
const hasMechanism = global.TarinaiMechanicalSystem.isMechanicalType(item.type) || global.TarinaiMechanicalSystem.isMechanicalType(other.type);
const itemIsFence = global.isFenceItemType(item.type);
const otherIsFence = global.isFenceItemType(other.type);
const margin = (itemIsFence || otherIsFence) ? (item.type === "gate_fence" || other.type === "gate_fence" ? 1.5 : 2.5) : (hasMechanism ? 1.25 : Math.max(4, Math.min(itemRadius, otherRadius) * 0.18));
if (itemRects.length && otherRects.length) {
if (itemRects.some(a => otherRects.some(b => rectsOverlap(a, b, margin)))) return true;
continue;
}
if (itemRects.length) {
if (itemRects.some(r => rectCircleOverlap(r, other.x, other.y, otherRadius, margin))) return true;
continue;
}
if (otherRects.length) {
if (otherRects.some(r => rectCircleOverlap(r, item.x, item.y, itemRadius, margin))) return true;
continue;
}
if (Math.hypot(num(item.x) - num(other.x), num(item.y) - num(other.y)) < itemRadius + otherRadius + margin) return true;
}
return false;
}
global.TarinaiCollisionFootprints = Object.freeze({
// Mechanical-body narrowphase consumes the same SAT primitives as
// placement/hit testing. Keep these delegates public so collision-enabled
// rotators, reciprocators, and poison blocks can physically interact.
rectLocalPoint,
rectWorldVector,
rectCorners,
rectAxes,
projectPoints,
aabbOverlap,
rectOverlapInfo,
rectsOverlap,
rectCircleOverlap,
isSoftPlacementIgnoredType,
itemFootprintRadius,
placementRectsFor,
hitTestItem,
itemPlacementOverlapBlocked,
});
})(typeof window !== "undefined" ? window : globalThis);