"use strict"; (function (global) { const World = global.World; if (!World) throw new Error("World is not available for mixin: world_environment.js"); const Geometry = global.TarinaiGeometry; const BOUNCE_FENCE_RESTITUTION = 4.20; const ORDINARY_FENCE_RESTITUTION = 0.38; const BOUNCE_FENCE_MIN_SPEED = 820; const BOUNCE_FENCE_MAX_SPEED = 1680; function angleForItemLocal(it) { return itemAngleFor(it); } function obstacleQueryDistanceLocal(x, y, it) { if (!it) return Infinity; const type = String(it.type || ""); let reach = Math.max(32, Number(it.r || it.radius || 32) || 32); if (type === "fence" || type === "fence_v" || type === "fence_h" || type === "glass_wall" || type === "bounce_fence" || type === "bounce_fence_v" || type === "gate_fence" || type === "one_way_fence") reach = Math.max(120, reach * 3.9); else if (type === "nest_box" || type === "pipe") reach = Math.max(92, reach * 1.9); else reach = global.TarinaiMechanicalShapeSystem.reach(it) || Math.max(60, reach * 1.6); return Math.max(0, distXY(x, y, Number(it.x) || 0, Number(it.y) || 0) - reach); } function rectDistanceToPointLocal(rect, x, y) { if (!rect) return Infinity; const px = Math.max(Number(rect.left || 0), Math.min(Number(rect.right || 0), Number(x) || 0)); const py = Math.max(Number(rect.top || 0), Math.min(Number(rect.bottom || 0), Number(y) || 0)); return Math.hypot((Number(x) || 0) - px, (Number(y) || 0) - py); } function nearestRectFaceDirection(left, right, top, bottom) { const depth = Math.min(left, right, top, bottom); if (depth === left) return { dx: -1, dy: 0, depth }; if (depth === right) return { dx: 1, dy: 0, depth }; if (depth === top) return { dx: 0, dy: -1, depth }; return { dx: 0, dy: 1, depth }; } function visitNearestObstaclesLocal(x, y, candidates, limit, visitor, opts = {}) { if (!candidates || typeof visitor !== "function") return 0; const source = candidates; const n = Number(source.length || 0) || 0; if (n <= 0) return 0; const keepMax = Math.max(1, Math.min(n, Math.max(limit + 6, limit * 3))); const kept = []; const dists = []; const include = opts.include || null; const exclude = opts.exclude || null; const stamp = opts.stamp; // Hot path: candidate counts are usually small. Keep a bounded sorted // prefix without allocating intermediate Array.from()/sort() output. For // large candidate sets, insertion is limited to the top keepMax entries. for (let i = 0; i < n; i++) { const it = source[i]; if (!it || it === exclude || it.dead || it._obstacleRectQueryStamp === stamp) continue; if (include && !include(it)) continue; const d = obstacleQueryDistanceLocal(x, y, it); if (kept.length >= keepMax && d >= dists[dists.length - 1]) continue; let pos = kept.length; while (pos > 0 && d < dists[pos - 1]) pos -= 1; if (pos >= keepMax) continue; kept.splice(pos, 0, it); dists.splice(pos, 0, d); if (kept.length > keepMax) { kept.length = keepMax; dists.length = keepMax; } } let visited = 0; for (let i = 0; i < kept.length; i++) { visited += 1; if (visitor(kept[i]) === true) break; } return visited; } function sanitizeRotatorSegmentsLocal(item) { const raw = global.TarinaiPhysicsBodySystem.segments(item) || []; const out = []; const clampCoord = (v) => Math.max(-420, Math.min(420, Number(v) || 0)); for (const seg of raw) { if (!Array.isArray(seg) || seg.length < 4) continue; const x1 = clampCoord(seg[0]); const y1 = clampCoord(seg[1]); const x2 = clampCoord(seg[2]); const y2 = clampCoord(seg[3]); if (Math.hypot(x2 - x1, y2 - y1) < 4) continue; out.push([x1, y1, x2, y2]); if (out.length >= 96) break; } if (!out.length) out.push([-78, 0, 78, 0], [0, -52, 0, 52]); return out; } function rotatorExtentLocal(item) { let maxD = 48; for (const seg of sanitizeRotatorSegmentsLocal(item)) { maxD = Math.max(maxD, Math.hypot(seg[0], seg[1]), Math.hypot(seg[2], seg[3])); } return Math.min(460, maxD + Math.max(8, global.TarinaiPhysicsBodySystem.scalar(item, "thickness", 12) || 12) + 8); } function segmentAabbHitLocal(x1, y1, x2, y2, left, top, right, bottom) { let t0 = 0; let t1 = 1; const dx = x2 - x1; const dy = y2 - y1; const clip = (p, q) => { if (Math.abs(p) < 1e-9) return q >= 0; const t = q / p; if (p < 0) { if (t > t1) return false; if (t > t0) t0 = t; } else { if (t < t0) return false; if (t < t1) t1 = t; } return true; }; if (!clip(-dx, x1 - left)) return null; if (!clip(dx, right - x1)) return null; if (!clip(-dy, y1 - top)) return null; if (!clip(dy, bottom - y1)) return null; if (t1 < 0 || t0 > 1) return null; const t = clamp(t0, 0, 1); const hx = x1 + dx * t; const hy = y1 + dy * t; const dl = Math.abs(hx - left); const dr = Math.abs(right - hx); const dt = Math.abs(hy - top); const db = Math.abs(bottom - hy); const m = Math.min(dl, dr, dt, db); let nx = 0, ny = 0; if (m === dl) nx = -1; else if (m === dr) nx = 1; else if (m === dt) ny = -1; else ny = 1; return { t, nx, ny }; } function sweptCircleRectHitLocal(x1, y1, x2, y2, radius, r, padding = 0) { if (!r) return null; const rr = Math.max(0, Number(radius || 0) || 0) + Math.max(0, Number(padding || 0) || 0); if (r.oriented) { const a = Geometry.rectLocalPoint(r, x1, y1); const b = Geometry.rectLocalPoint(r, x2, y2); const hit = segmentAabbHitLocal(a.x, a.y, b.x, b.y, -(r.halfW || 0) - rr, -(r.halfH || 0) - rr, (r.halfW || 0) + rr, (r.halfH || 0) + rr); if (!hit) return null; const n = Geometry.rectWorldNormal(r, hit.nx, hit.ny); return { t: hit.t, nx: n.x, ny: n.y, rect: r }; } const hit = segmentAabbHitLocal(x1, y1, x2, y2, (r.left || 0) - rr, (r.top || 0) - rr, (r.right || 0) + rr, (r.bottom || 0) + rr); return hit ? { t: hit.t, nx: hit.nx, ny: hit.ny, rect: r } : null; } function nearbyMechanicalObstacleRectsLocal(worldRef, x, y, radius, opts = {}) { if (!worldRef?.itemsOfType || !worldRef?.solidObstacleRects) return []; const maxChecks = Math.max(1, Math.min(32, Number(opts.maxChecks || 0) || 10)); const maxRects = Math.max(12, Math.min(128, Number(opts.maxRects || 0) || 72)); const rects = []; const candidates = []; for (const type of ["rotator", "poison_block", "reciprocator"]) { for (const it of worldRef.itemsOfType(type) || []) { if (!it || it.dead || it === opts.exclude) continue; const reach = global.TarinaiMechanicalShapeSystem.reach(it) || Math.max(60, Number(it.r || it.radius || 64) || 64); const d = Math.max(0, distXY(x, y, Number(it.x) || 0, Number(it.y) || 0) - reach); if (d > radius + 72) continue; candidates.push({ it, d }); } } candidates.sort((a, b) => a.d - b.d); let checked = 0; for (const entry of candidates) { if (checked >= maxChecks || rects.length >= maxRects) break; checked += 1; for (const rect of worldRef.solidObstacleRects(entry.it) || []) { if (rectDistanceToPointLocal(rect, x, y) > radius + 48) continue; rects.push(rect); if (rects.length >= maxRects) break; } } return rects; } function firstSweptCircleObstacleHitLocal(worldRef, t, x1, y1, x2, y2, radius, opts = {}) { if (!worldRef?.nearbySolidObstacleRects) return null; const midX = (x1 + x2) * 0.5; const midY = (y1 + y2) * 0.5; const travel = Math.hypot(x2 - x1, y2 - y1); const searchRadius = Math.max(Number(opts.searchRadius || 0) || 0, radius + travel * 0.5 + 180); const maxChecks = Math.max(4, Number(opts.maxChecks || 0) || 48); const maxRects = Math.max(18, Math.min(128, Number(opts.maxRects || 0) || Math.ceil(maxChecks * 2.5))); const mechanicalPadding = Math.max(1.75, Math.min(6.5, radius * 0.24)); let best = null; const testRect = (rect) => { if (rect?.type === "nest_box" && worldRef.shouldIgnoreNestBoxCollisionFor?.(t, rect.item)) return; if (rect?.oneWay && worldRef.oneWayFenceAllowsPath?.(x1, y1, x2, y2, rect)) return; const hit = sweptCircleRectHitLocal(x1, y1, x2, y2, radius, rect, rect?.mechanical ? mechanicalPadding : 0.5); if (!hit) return; if (!best || hit.t < best.t) best = hit; }; for (const rect of worldRef.nearbySolidObstacleRects(midX, midY, searchRadius, { maxChecks, maxRects })) { testRect(rect); } if (opts.mechanicalFallback !== false) { const mechanicalSearchRadius = Math.max(searchRadius, radius + travel + 140); for (const rect of nearbyMechanicalObstacleRectsLocal(worldRef, midX, midY, mechanicalSearchRadius, { maxChecks: Math.max(6, Math.min(18, Math.ceil(maxChecks * 0.55))), maxRects: Math.max(32, maxRects), })) { testRect(rect); } } return best; } function segmentHitsLocalRect(x1, y1, x2, y2, r, padding = 0) { return global.TarinaiGeometry.segmentIntersectsOrientedRect(x1, y1, x2, y2, r, padding); } function nearbyPoisonBlockHazardRectsLocal(worldRef, x, y, radius, { maxChecks = 24 } = {}) { if ((worldRef?.itemCounts?.poison_block || 0) <= 0) return []; const rects = []; const stamp = (worldRef._poisonHazardQueryStamp = (worldRef._poisonHazardQueryStamp || 0) + 1); let checked = 0; const addRectsFor = (it) => { if (!it || it.dead || it.type !== "poison_block" || it._poisonHazardQueryStamp === stamp) return false; const reach = global.TarinaiMechanicalShapeSystem.reach(it) || Math.max(60, it.r || 64); if (distXY(x, y, it.x, it.y) > radius + reach + 36) return false; const partRects = global.TarinaiMechanicalSystem.poisonHazardRects(it) || []; if (!partRects.length) return false; it._poisonHazardQueryStamp = stamp; checked += 1; for (const rect of partRects) rects.push(rect); return true; }; const candidates = worldRef.nearbyHazards?.(x, y, radius + 48, false) || []; for (const it of candidates) { addRectsFor(it); if (checked >= maxChecks) break; } if (checked < maxChecks && !worldRef.nearbyHazards) { for (const it of worldRef.itemsOfType?.("poison_block") || []) { if (addRectsFor(it) && checked >= maxChecks) break; } } return rects; } function applyPoisonBlockContactDamageLocal(worldRef, t, rect, rr) { if (!worldRef || !t || t.dead || !rect?.poisonBlock || !rect.item || rect.item.dead) return false; if (!Geometry.circleOverlapsRect(t.x, t.y, rr, rect, 1.0)) return false; const now = worldRef.time || 0; const key = rect.item.id || "poison"; t._poisonBlockHitAt = t._poisonBlockHitAt || Object.create(null); const body = global.TarinaiPhysicsBodySystem.ensureBody(rect.item, worldRef, { syncFromLegacy: false }); const solid = body?.collision ? body.collision.solid !== false : true; const cooldown = solid ? 0.30 : 0.42; if ((t._poisonBlockHitAt[key] || -999) + cooldown > now) return false; t._poisonBlockHitAt[key] = now; const velocity = body?.velocity || {}; const vx = Number((velocity.x ?? rect.item.vx) || 0) || 0; const vy = Number((velocity.y ?? rect.item.vy) || 0) || 0; const omega = Number((velocity.angular ?? global.TarinaiPhysicsBodySystem.scalar(rect.item, "spin", 0)) || 0) || 0; const speed = Math.hypot(vx, vy) + Math.abs(omega) * Math.max(28, rect.item.r || 64) * 0.35; const base = Math.max(1, Number((body?.hazard?.damage ?? global.TarinaiPhysicsBodySystem.scalar(rect.item, "damage", 7)) || 7) || 7); const damage = clamp(base * (solid ? 1.0 : 0.72) + speed * 0.018, 2.0, 18.0); if (typeof worldRef.applyImpactDamage === "function") worldRef.applyImpactDamage(t, damage, "\u6bd2\u30d6\u30ed\u30c3\u30af", { source: rect.item, x: t.x, y: t.y }); else if (typeof t.damage === "function") t.damage(damage, "\u6bd2\u30d6\u30ed\u30c3\u30af", { source: rect.item }); t.showHpBar?.(4.8); t.hurtTimer = Math.max(t.hurtTimer || 0, 0.82); t.pokeFlashTimer = Math.max(t.pokeFlashTimer || 0, 0.28); t.surpriseTimer = Math.max(t.surpriseTimer || 0, 0.24); if (typeof t.bubble === "function" && now >= (t.lastPoisonBlockBubbleAt || -999) + 0.85) { t.lastPoisonBlockBubbleAt = now; t.bubble("!", 0.48, "rgba(94,166,76,0.78)"); } return true; } function mechanicalPressureForTarinaiLocal(worldRef, t) { const pressure = worldRef?._mechanicalCrowdPressure; if (!pressure?.active || !pressure.ids || !t) return null; return pressure.ids.has(t.id || t.familyKey || t) ? pressure : null; } function consumeMechanicalPressureContactLocal(worldRef, t) { const pressure = mechanicalPressureForTarinaiLocal(worldRef, t); if (!pressure) return true; const budget = Math.max(1, Number(pressure.contactBudget || 8) || 8); if ((pressure.detailedContacts || 0) < budget) { pressure.detailedContacts = (pressure.detailedContacts || 0) + 1; return true; } pressure.skippedContacts = (pressure.skippedContacts || 0) + 1; return false; } function cheapMechanicalPressureContactLocal(worldRef, t, rr, opts = {}) { const pressure = mechanicalPressureForTarinaiLocal(worldRef, t); if (!pressure || !Array.isArray(pressure.sources) || !pressure.sources.length) return false; const maxSources = Math.max(1, Math.min(4, Number(opts.maxSources || 2) || 2)); const maxRects = Math.max(1, Math.min(12, Number(opts.maxRects || 5) || 5)); const x = Number(t.x || 0) || 0; const y = Number(t.y || 0) || 0; let sourceChecks = 0; let rectChecks = 0; let pushed = false; for (const item of pressure.sources) { if (!item || item.dead) continue; const reach = global.TarinaiMechanicalShapeSystem?.reach?.(item) || Math.max(64, Number(item.r || item.radius || 64) || 64); if (distXY(x, y, Number(item.x || 0) || 0, Number(item.y || 0) || 0) > reach + rr + 56) continue; sourceChecks += 1; for (const rect of worldRef.solidObstacleRects?.(item) || []) { rectChecks += 1; if (Geometry.circleOverlapsRect(x, y, rr, rect, 2.0) && worldRef.pushTarinaiOutOfRect(t, rect, rr, { slop: 0.55, pressureCheap: true })) { pushed = true; break; } if (rectChecks >= maxRects) break; } if (pushed || sourceChecks >= maxSources || rectChecks >= maxRects) break; } pressure.degradedContacts = (pressure.degradedContacts || 0) + 1; if (pushed) { t.lastSolidObstacleCollisionAt = worldRef.time || 0; t.vx = clamp(Number(t.vx || 0) || 0, -420, 420) * 0.82; t.vy = clamp(Number(t.vy || 0) || 0, -420, 420) * 0.82; t.impulseVx = clamp(Number(t.impulseVx || 0) || 0, -260, 260) * 0.72; t.impulseVy = clamp(Number(t.impulseVy || 0) || 0, -260, 260) * 0.72; } else { t.vx = clamp(Number(t.vx || 0) || 0, -520, 520) * 0.94; t.vy = clamp(Number(t.vy || 0) || 0, -520, 520) * 0.94; } return pushed; } Object.defineProperties(World.prototype, Object.getOwnPropertyDescriptors({ nearest(entity, types, maxDist = Infinity) { let best = null, bestD = maxDist; const candidates = Number.isFinite(maxDist) ? this.nearbyItems(entity.x, entity.y, maxDist) : this.items; for (const it of candidates) { if (it.dead || !types.includes(it.type)) continue; if (entity?.shouldAvoidTarget && entity.shouldAvoidTarget(it)) continue; const d = dist(entity, it); if (d < bestD) { best = it; bestD = d; } } return best; }, nearestOther(entity, maxDist = Infinity, predicate = null) { let best = null, bestD = maxDist; const candidates = Number.isFinite(maxDist) ? this.nearbyTarinai(entity.x, entity.y, maxDist) : this.tarinai; for (const o of candidates) { if (o === entity || o.dead || this.isTarinaiHiddenInNestBox(o)) continue; if (predicate && !predicate(o)) continue; if (entity?.shouldAvoidTarget && entity.shouldAvoidTarget(o)) continue; const d = dist(entity, o); if (d < bestD) { best = o; bestD = d; } } return best; }, fenceRect(it) { const type = it?.type || ""; if (!this.isFenceType(type)) return null; const bounce = type === "bounce_fence" || type === "bounce_fence_v"; const gate = type === "gate_fence"; const glassWall = type === "glass_wall"; const oneWay = type === "one_way_fence"; const len = Math.max(112, (it.r || 42) * 3.55); const thick = Math.max(10, (it.r || 42) * 0.31); const angle = angleForItemLocal(it); const halfW = len / 2; const halfH = thick / 2; const aabb = Geometry.orientedRectAabb(it.x || 0, it.y || 0, halfW, halfH, angle); return { left: aabb.left, right: aabb.right, top: aabb.top, bottom: aabb.bottom, cx: it.x || 0, cy: it.y || 0, halfW, halfH, angle, cos: aabb.cos, sin: aabb.sin, oriented: true, vertical: Math.abs(Math.sin(angle)) > Math.abs(Math.cos(angle)), horizontal: Math.abs(Math.cos(angle)) >= Math.abs(Math.sin(angle)), bounce, gate, glassWall, oneWay, oneWayNx: Math.sin(angle), oneWayNy: -Math.cos(angle), gateOpen: Boolean(it.gateOpen), restitution: bounce ? BOUNCE_FENCE_RESTITUTION : ORDINARY_FENCE_RESTITUTION, minBounceSpeed: bounce ? BOUNCE_FENCE_MIN_SPEED : undefined }; }, nestBoxCapacity(box = null) { if (box?.type === "pipe") { const count = (this.items || []).reduce((n, it) => n + (it && !it.dead && it.type === "pipe" ? 1 : 0), 0); return Math.max(3, count * 3); } return 5; }, isNestContainerItem(it) { return Boolean(it && !it.dead && globalThis.TarinaiToolRuntime?.isNestContainerItem?.(it)); }, nestBoxById(id) { if (!id) return null; return (this.items || []).find(it => it && !it.dead && it.id === id && this.isNestContainerItem?.(it)) || null; }, nestBoxBaseRect(it) { if (!this.isNestContainerItem?.(it)) return null; const r = it.r || (it.type === "pipe" ? 36 : 42); if (it.type === "pipe") { return { left: it.x - r * 1.12, right: it.x + r * 1.12, top: it.y - r * 0.88, bottom: it.y + r * 0.82, type: "pipe", item: it, }; } return { left: it.x - r * 1.28, right: it.x + r * 1.28, top: it.y - r * 0.80, bottom: it.y + r * 0.72, type: "nest_box", item: it, }; }, nestBoxSolidRects(it) { const base = this.nestBoxBaseRect(it); if (!base) return []; if (it?.type === "pipe") return []; const w = base.right - base.left; const h = base.bottom - base.top; const colW = w / 3; const rowH = h / 3; const mk = (left, right, top, bottom, cell) => ({ left, right, top, bottom, type: "nest_box", cell, item: it }); return [ mk(base.left, base.right, base.top, base.top + rowH, "top"), mk(base.left, base.left + colW, base.top + rowH, base.top + rowH * 2, "middle-left"), mk(base.right - colW, base.right, base.top + rowH, base.top + rowH * 2, "middle-right"), ]; }, nestBoxEntryPoint(box) { const base = this.nestBoxBaseRect(box); if (!base) return { x: box?.x || 0, y: box?.y || 0 }; const r = box.r || (box.type === "pipe" ? 36 : 42); if (box.type === "pipe") { return { x: clamp(box.x, CONFIG.worldPadding, this.w - CONFIG.worldPadding), y: clamp(box.y - r * 0.02, CONFIG.worldPadding, this.h - CONFIG.worldPadding), }; } // Move the approach point to the center of the open middle cell. The // collision resolver also ignores this nest box for active sleepers near // the door, so tarinai can cross the threshold instead of sliding off posts. return { x: clamp(box.x, CONFIG.worldPadding, this.w - CONFIG.worldPadding), y: clamp(box.y + r * 0.02, base.top + r * 0.46, base.bottom - r * 0.18), }; }, nestBoxExitPoint(box, occupant = null) { const base = this.nestBoxBaseRect(box); if (!base) return this.nestBoxEntryPoint(box); const r = box.r || (box.type === "pipe" ? 36 : 42); if (box.type === "pipe") { const side = occupant ? (stableUnit(occupant.id || "pipe", `pipe-exit-${box.id || "pipe"}`) - 0.5) * r * 0.46 : 0; return { x: clamp(box.x + side, CONFIG.worldPadding, this.w - CONFIG.worldPadding), y: clamp(box.y + r * 0.74, CONFIG.worldPadding, this.h - CONFIG.worldPadding), }; } const side = occupant ? (stableUnit(occupant.id || "nest", `nest-exit-${box.id || "box"}`) - 0.5) * r * 0.36 : 0; return { x: clamp(box.x + side, CONFIG.worldPadding, this.w - CONFIG.worldPadding), y: clamp(base.bottom + r * 0.22, CONFIG.worldPadding, this.h - CONFIG.worldPadding), }; }, nestBoxInnerPoint(box, occupant = null) { const base = this.nestBoxBaseRect(box); if (!base) return { x: box?.x || 0, y: box?.y || 0 }; const occupants = this.nestBoxOccupants ? this.nestBoxOccupants(box, Infinity) : []; let index = Number.isInteger(occupant?.nestBoxSlotIndex) && occupant.nestBoxSlotIndex >= 0 ? occupant.nestBoxSlotIndex : occupants.indexOf(occupant); if (index < 0) index = Math.min(occupants.length, this.nestBoxCapacity(box) - 1); index = clamp(index, 0, Math.max(0, this.nestBoxCapacity(box) - 1)); const r = box.r || (box.type === "pipe" ? 36 : 42); const pipeSlots = [[0.00, -0.02], [-0.15, 0.06], [0.15, 0.06]]; const nestSlots = [[0.00, -0.02], [-0.22, 0.04], [0.22, 0.04], [-0.11, 0.16], [0.11, 0.16]]; const slots = box.type === "pipe" ? pipeSlots : nestSlots; const slot = slots[index % slots.length]; if (box.type === "pipe") { return { x: clamp(box.x + slot[0] * r, base.left + r * 0.40, base.right - r * 0.40), y: clamp(box.y + slot[1] * r, base.top + r * 0.34, base.bottom - r * 0.28), }; } return { x: clamp(box.x + slot[0] * r, base.left + r * 0.46, base.right - r * 0.46), y: clamp(box.y + slot[1] * r, base.top + r * 0.34, base.bottom - r * 0.18), }; }, randomPipeExitFor(sourcePipe = null, occupant = null) { const allPipes = (this.items || []).filter(it => it && !it.dead && it.type === "pipe"); if (!allPipes.length) return sourcePipe; const pipes = allPipes.length > 1 && sourcePipe ? allPipes.filter(it => it.id !== sourcePipe.id) : allPipes; if (!pipes.length) return sourcePipe || allPipes[0] || null; const now = Number(this.time || 0) || 0; const salt = `${occupant?.id || "tarinai"}:${sourcePipe?.id || "pipe"}:${Math.floor(now * 1.7)}`; const idx = Math.floor(stableUnit(salt, "random-pipe-exit") * pipes.length) % pipes.length; return pipes[idx] || sourcePipe || null; }, rotatorExtent(it) { return global.TarinaiMechanicalShapeSystem.extent(it) || rotatorExtentLocal(it); }, solidObstacleRects(it) { if (!it || it.dead) return []; if (this.isFenceType(it.type)) { if (it.type === "gate_fence" && it.gateOpen) return []; const rect = this.fenceRect(it); return rect ? [{ ...rect, type: it.type, item: it }] : []; } if (global.TarinaiMechanicalShapeSystem.isMechanicalType(it.type)) return global.TarinaiMechanicalShapeSystem.obstacleRects(it) || []; if (it.type === "nest_box" || it.type === "pipe") return this.nestBoxSolidRects(it); return []; }, shouldIgnoreNestBoxCollisionFor(t, box) { if (!t || !box || box.dead || !globalThis.TarinaiToolRuntime?.isNestContainerItem?.(box)) return false; if (t.insideNestBoxId) return true; const targetBox = t.target === box || t.target?.shelterItem === box || t.target?.hostItem === box; if (!targetBox || !(t.state === "seek_bed" || t.state === "sleep" || t.state === "seek_temperature")) return false; const entry = this.nestBoxEntryPoint(box); const base = this.nestBoxBaseRect(box); const r = box.r || 42; const entryD = distXY(t.x, t.y, entry.x, entry.y); const centerD = distXY(t.x, t.y, box.x, box.y); const aroundDoor = entryD <= Math.max(68, r * 1.32) || centerD <= Math.max(66, r * 1.18); const inDoorColumn = base && t.x >= base.left + r * 0.30 && t.x <= base.right - r * 0.30 && t.y >= base.top + r * 0.12 && t.y <= base.bottom + r * 0.42; return Boolean(aroundDoor || inDoorColumn); }, nearbySolidObstacleRects(x, y, radius, { include = null, exclude = null, maxChecks = CONFIG.maxFenceCollisionChecks ?? 24, maxRects: requestedMaxRects = 0 } = {}) { const limit = Math.max(1, Number(maxChecks || 0) || 24); const maxRects = Math.max(12, Math.min(72, Number(requestedMaxRects || 0) || Math.ceil(limit * 2.25))); const cacheable = !include && !exclude; const stats = this.nearbyQueryStatsThisFrame || null; // Quantize cacheable probes so smooth movement reuses obstacle rect lists // across nearby frames while the inflated radius keeps collisions conservative. const q = 48; const qx = Math.floor((Number(x) || 0) / q); const qy = Math.floor((Number(y) || 0) / q); const qcx = (qx + 0.5) * q; const qcy = (qy + 0.5) * q; const inflatedRadius = Math.max(1, Number(radius || 0) || 1) + q * 0.82; const qr = Math.ceil(inflatedRadius / q); const cacheLimit = Math.min(72, Math.max(limit + 6, Math.ceil(limit * 1.35))); const cacheKey = cacheable ? `${this.spatialVersion || 0}:${this.spatialDirtyMarksTotal || 0}:${qx},${qy},${qr},${cacheLimit},${maxRects}` : ""; if (cacheable) { if (!this._solidObstacleRectQueryCache) this._solidObstacleRectQueryCache = new Map(); const cached = this._solidObstacleRectQueryCache.get(cacheKey); if (cached) { if (stats) stats.obstacleRectHits = (stats.obstacleRectHits || 0) + 1; return cached; } } else if (stats) { stats.obstacleRectBypass = (stats.obstacleRectBypass || 0) + 1; } const queryX = cacheable ? qcx : x; const queryY = cacheable ? qcy : y; const queryRadius = cacheable ? qr * q : radius; const queryLimit = cacheable ? cacheLimit : limit; const rects = []; const stamp = (this._obstacleRectQueryStamp = (this._obstacleRectQueryStamp || 0) + 1); let checked = 0; const addRectsFor = (it) => { if (!it || it === exclude || it.dead || it._obstacleRectQueryStamp === stamp) return false; if (include && !include(it)) return false; const partRects = this.solidObstacleRects(it); if (!partRects.length) return false; it._obstacleRectQueryStamp = stamp; checked += 1; const localLimit = it.type === "rotator" || it.type === "poison_block" || it.type === "reciprocator" ? maxRects : maxRects + 8; for (const rect of partRects) { if (rects.length >= localLimit) break; // Dense custom mechanical shapes can produce dozens of segment rects. // Keep only rects that can plausibly interact with this query point; // otherwise one smooth-motion collision probe spends most of its time // iterating far-away segments of the same object. if (rectDistanceToPointLocal(rect, queryX, queryY) > queryRadius + 42) continue; rects.push(rect); } return true; }; const candidates = this.nearbyObstacles?.(queryX, queryY, queryRadius, false) || this.nearbyItems?.(queryX, queryY, queryRadius, false) || []; visitNearestObstaclesLocal(queryX, queryY, candidates, queryLimit, (it) => { addRectsFor(it); return checked >= queryLimit; }, { include, exclude, stamp }); if (checked < queryLimit) { const mech = global.TarinaiMechanicalSystem; const fallback = []; for (const type of ["rotator", "poison_block", "reciprocator"]) { for (const it of this.itemsOfType?.(type) || []) { if (!it || it.dead || it._obstacleRectQueryStamp === stamp) continue; const reach = mech?.reach?.(it) || Math.max(60, it.r || 64); if (distXY(queryX, queryY, it.x, it.y) > queryRadius + reach + 36) continue; fallback.push(it); } } visitNearestObstaclesLocal(queryX, queryY, fallback, queryLimit - checked, (it) => { addRectsFor(it); return checked >= queryLimit; }, { include, exclude, stamp }); } if (stats) { stats.obstacleRectMisses = (stats.obstacleRectMisses || 0) + 1; stats.obstacleRectBuilt = (stats.obstacleRectBuilt || 0) + rects.length; } if (cacheable) { const cache = this._solidObstacleRectQueryCache || (this._solidObstacleRectQueryCache = new Map()); if (cache.size > 160) cache.clear(); cache.set(cacheKey, rects); } return rects; }, pointInRect(x, y, r, padding = 0) { return Geometry.pointInRect(x, y, r, padding) ?? false; }, resolvePoisonBlockContactDamage(t, rr) { if (!t || t.dead || this.isTarinaiHiddenInNestBox(t)) return false; if ((this.itemCounts?.poison_block || 0) <= 0) return false; const now = this.time || 0; const interval = Math.max(0.045, Math.min(0.10, Number(CONFIG?.poisonBlockProbeInterval || 0.075) || 0.075)); if ((t._nextPoisonBlockProbeAt || 0) > now) return false; t._nextPoisonBlockProbeAt = now + interval; let hit = false; for (const rect of nearbyPoisonBlockHazardRectsLocal(this, t.x, t.y, rr + 150, { maxChecks: 32 })) { hit = applyPoisonBlockContactDamageLocal(this, t, rect, rr) || hit; } return hit; }, resolvePoisonBlockItemContacts(opts = {}) { if ((this.itemCounts?.poison_block || 0) <= 0) return 0; const meltTypes = new Set(["grass_bed", "zunchi", "water", "grass"]); let possible = 0; for (const type of meltTypes) possible += this.itemCounts?.[type] || 0; if (possible <= 0) return 0; const maxPoison = Math.max(1, Math.min(48, Number(opts.maxPoison || 24) || 24)); const maxTargets = Math.max(1, Math.min(160, Number(opts.maxTargets || 80) || 80)); const poisonBlocks = this.itemsOfType?.("poison_block") || []; let removed = 0; let checkedPoison = 0; const lifecycle = global.TarinaiStructureLifecycle; for (const poison of poisonBlocks) { if (!poison || poison.dead || poison.type !== "poison_block") continue; checkedPoison += 1; if (checkedPoison > maxPoison) break; const rects = global.TarinaiMechanicalSystem.poisonHazardRects(poison) || []; if (!rects.length) continue; const reach = global.TarinaiMechanicalSystem.reach(poison) || Math.max(72, poison.r || 64); const source = this.nearbyItems?.(poison.x, poison.y, reach + 96, true) || this.items || []; let checkedTargets = 0; for (const target of source) { if (!target || target.dead || target === poison || !meltTypes.has(target.type)) continue; checkedTargets += 1; if (checkedTargets > maxTargets) break; const rr = Math.max(5, Number(target.r || target.radius || itemRadiusFor?.(target.type, 10) || 10) || 10) * (target.type === "grass_bed" ? 1.15 : 0.92); let touching = false; for (const rect of rects) { if (Geometry.circleOverlapsRect(target.x, target.y, rr, rect, 1.5)) { touching = true; break; } } if (!touching) continue; const wasLive = !target.dead; const destroyed = (lifecycle)?.deleteItem?.(this, target, { reason: "poison-block-contact", userReason: "\u6BD2\u30D6\u30ED\u30C3\u30AF\u306B\u89E6\u308C\u3066\u6D88\u3048\u305F", wake: true, breaker: poison, panicOwner: false, }); if (wasLive && destroyed) { removed += 1; this.markTerrainDirtyAt?.(target.x, target.y, Math.max(36, rr + 16), "poison-block-contact"); } } } if (removed > 0) { this.compactItems?.(); this.updateItemCounts?.("poison-block-contact"); this.markSpatialDirty?.("poison-block-contact"); this.drawListDirty = true; } return removed; }, oneWayFenceAllows(entity, rect, motionX = null, motionY = null) { return Geometry.oneWayFenceAllows(entity, rect, motionX, motionY); }, pushTarinaiOutOfRect(t, r, rr, opts = {}) { if (!t || !r) return false; if (this.oneWayFenceAllows?.(t, r, opts.oneWayDx, opts.oneWayDy)) return false; const preVx = Number(t.vx || 0) || 0; const preVy = Number(t.vy || 0) || 0; const hitRadius = Math.max(1, rr + (r.mechanical ? Math.max(2.5, Math.min(6.5, rr * 0.24)) : 0)); let cx, cy, dx, dy, d, nx, ny; let insidePenetration = 0; if (r.oriented) { const local = Geometry.rectLocalPoint(r, t.x, t.y); const clx = clamp(local.x, -(r.halfW || 0), r.halfW || 0); const cly = clamp(local.y, -(r.halfH || 0), r.halfH || 0); dx = local.x - clx; dy = local.y - cly; d = Math.hypot(dx, dy); if (d >= hitRadius) return false; if (d < 0.001) { const face = nearestRectFaceDirection( Math.abs(local.x + (r.halfW || 0)), Math.abs((r.halfW || 0) - local.x), Math.abs(local.y + (r.halfH || 0)), Math.abs((r.halfH || 0) - local.y) ); insidePenetration = face.depth; dx = face.dx; dy = face.dy; d = 1; } const n = Geometry.rectWorldNormal(r, dx / d, dy / d); nx = n.x; ny = n.y; cx = t.x - dx; cy = t.y - dy; } else { cx = clamp(t.x, r.left, r.right); cy = clamp(t.y, r.top, r.bottom); dx = t.x - cx; dy = t.y - cy; d = Math.hypot(dx, dy); if (d >= hitRadius) return false; if (d < 0.001) { const face = nearestRectFaceDirection( Math.abs(t.x - r.left), Math.abs(r.right - t.x), Math.abs(t.y - r.top), Math.abs(r.bottom - t.y) ); dx = face.dx; dy = face.dy; d = 1; } nx = dx / d; ny = dy / d; } const slop = Number.isFinite(opts.slop) ? opts.slop : 0.8; const defaultMaxPush = r.oriented && insidePenetration > 0 ? Math.max(18, hitRadius * 2.8) : Math.max(10, hitRadius * 0.92); const maxPush = Math.max(1, Number.isFinite(opts.maxPush) ? opts.maxPush : defaultMaxPush); const push = r.oriented && insidePenetration > 0 ? Math.min(insidePenetration + hitRadius + slop, maxPush) : Math.min(hitRadius - d + slop, maxPush); t.x += nx * push; t.y += ny * push; t.lastSolidObstacleCollisionSource = r.item || null; t.lastSolidObstacleCollisionAt = this.time || 0; if (r.bounce) { const restitution = Number.isFinite(r.restitution) ? r.restitution : BOUNCE_FENCE_RESTITUTION; const now = this.time || 0; const source = r.item || null; const vx = Number(t.vx || 0) || 0; const vy = Number(t.vy || 0) || 0; const ivx = Number(t.impulseVx || 0) || 0; const ivy = Number(t.impulseVy || 0) || 0; const combinedX = vx + ivx; const combinedY = vy + ivy; const toward = combinedX * nx + combinedY * ny; const recentSameFence = source && t.lastBounceFenceSource === source && now - Number(t.lastBounceFenceImpulseAt || -999) < 0.12 && toward > 0; if (!recentSameFence) { const tangentX = combinedX - toward * nx; const tangentY = combinedY - toward * ny; const minBounceSpeed = Math.max(0, Number(r.minBounceSpeed || 0) || 0); const reflected = toward < 0 ? (-toward * restitution) : Math.max(180, minBounceSpeed * 0.72); const outward = clamp(Math.max(minBounceSpeed, reflected), 0, BOUNCE_FENCE_MAX_SPEED); // Keep a little ordinary velocity for visual continuity, but place most // of the launch in the external impulse channel so action states, // sleeping and AI steering cannot immediately cancel the blow-away. t.vx = clamp(tangentX * 0.22 + nx * outward * 0.22, -BOUNCE_FENCE_MAX_SPEED, BOUNCE_FENCE_MAX_SPEED); t.vy = clamp(tangentY * 0.22 + ny * outward * 0.22, -BOUNCE_FENCE_MAX_SPEED, BOUNCE_FENCE_MAX_SPEED); t.impulseVx = clamp(tangentX * 0.36 + nx * outward * 0.78, -BOUNCE_FENCE_MAX_SPEED, BOUNCE_FENCE_MAX_SPEED); t.impulseVy = clamp(tangentY * 0.36 + ny * outward * 0.78, -BOUNCE_FENCE_MAX_SPEED, BOUNCE_FENCE_MAX_SPEED); t.lastBounceFenceSource = source; t.lastBounceFenceImpulseAt = now; t._sleepExternalMotionUntil = Math.max(Number(t._sleepExternalMotionUntil || 0) || 0, now + 1.15); global.TarinaiMovementUpdateStep?.markSleepExternalMotion?.(t, 0.016, "bounce-fence-launch"); if (t.sleeping || t.state === "sleep") global.TarinaiMovementUpdateStep?.wakeSleepingFromExternalMotion?.(t, "\u30d0\u30a6\u30f3\u30b9\u67f5\u3067\u8df3\u306d\u98db\u3070\u3055\u308c\u305f"); t.fallTimer = Math.max(Number(t.fallTimer || 0) || 0, 0.62); t.fallMax = Math.max(Number(t.fallMax || 0) || 0, Number(t.fallTimer || 0) || 0); // Clear the body beyond the contact skin so the multi-pass collision // solver cannot immediately reflect it back into the fence. const launchClearance = Math.min(16, Math.max(5, rr * 0.42)); t.x += nx * launchClearance; t.y += ny * launchClearance; } t.vx = clamp(t.vx || 0, -BOUNCE_FENCE_MAX_SPEED, BOUNCE_FENCE_MAX_SPEED); t.vy = clamp(t.vy || 0, -BOUNCE_FENCE_MAX_SPEED, BOUNCE_FENCE_MAX_SPEED); t.impulseVx = clamp(t.impulseVx || 0, -BOUNCE_FENCE_MAX_SPEED, BOUNCE_FENCE_MAX_SPEED); t.impulseVy = clamp(t.impulseVy || 0, -BOUNCE_FENCE_MAX_SPEED, BOUNCE_FENCE_MAX_SPEED); t.surpriseTimer = Math.max(t.surpriseTimer || 0, 0.34); if (t.bubble && now >= (t.lastBounceFenceBubbleAt || -999) + 0.55) { t.lastBounceFenceBubbleAt = now; t.bubble("!", 0.45, "rgba(80,130,210,0.72)"); } if (now >= (t.lastBounceFenceEffectAt || -999) + 0.08) { t.lastBounceFenceEffectAt = now; this.effects?.push(new Effect("ring", t.x, t.y, { size: Math.max(18, rr * 1.15), life: 0.18, color: "rgba(82,153,230,0.46)" })); } } else if (r.mechanical && opts.pressureCheap) { const omega = r.rotator ? clamp(Number(r.angularVelocity || 0) || 0, -4.5, 4.5) : 0; const rx = Number(t.x || 0) - Number(r.centerX ?? r.item?.x ?? r.cx ?? 0); const ry = Number(t.y || 0) - Number(r.centerY ?? r.item?.y ?? r.cy ?? 0); const tvx = r.rotator && Math.abs(omega) > 0.012 ? clamp(-ry * omega, -180, 180) : 0; const tvy = r.rotator && Math.abs(omega) > 0.012 ? clamp(rx * omega, -180, 180) : 0; t.vx = clamp(preVx * 0.62 + nx * 46 + tvx * 0.42, -360, 360); t.vy = clamp(preVy * 0.62 + ny * 46 + tvy * 0.42, -360, 360); t.impulseVx = clamp((Number(t.impulseVx || 0) || 0) * 0.45 + nx * 34, -220, 220); t.impulseVy = clamp((Number(t.impulseVy || 0) || 0) * 0.45 + ny * 34, -220, 220); t.surpriseTimer = Math.max(t.surpriseTimer || 0, 0.05); } else if (r.mechanical) { global.TarinaiMechanicalSystem.applySurfaceVelocityToCircle(t, r, nx, ny, preVx, preVy, { damping: r.rotator ? 0.78 : (r.poisonBlock ? 0.74 : 0.80), surfaceScale: r.rotator ? 0.52 : (r.poisonBlock ? 0.38 : 0.64), normalBoost: r.rotator ? 42 : (r.poisonBlock ? 30 : 36), maxSpeed: r.rotator ? 680 : (r.poisonBlock ? 560 : 620), impulseVScale: r.rotator ? 0.10 : (r.poisonBlock ? 0.05 : 0.08), impulseMax: r.rotator ? 360 : (r.poisonBlock ? 260 : 320), passiveImpulseScale: 0.82, spinKick: 0, }); t.surpriseTimer = Math.max(t.surpriseTimer || 0, r.rotator ? 0.12 : 0.10); } else if (Math.abs(dx) > Math.abs(dy)) t.vx *= -0.18; else t.vy *= -0.18; return true; }, moveTarinaiWithCollision(t, dx, dy, opts = {}) { if (!t || t.dead) return false; const moveX = Number(dx || 0) || 0; const moveY = Number(dy || 0) || 0; const dist = Math.hypot(moveX, moveY); if (dist <= 0.0001) return this.resolveSolidObstacleCollision(t, { maxPasses: 2 }); if (this.isTarinaiHiddenInNestBox?.(t)) { t.x += moveX; t.y += moveY; return false; } const rr = Math.max(8, (Number(t.radius) || 22) * 0.74); const pressure = mechanicalPressureForTarinaiLocal(this, t); if (pressure && !consumeMechanicalPressureContactLocal(this, t)) { t.x += moveX * 0.72; t.y += moveY * 0.72; const pushed = cheapMechanicalPressureContactLocal(this, t, rr, { maxSources: 2, maxRects: 5 }); if (pushed) this.markSpatialDirty?.("tarinai-pressure-cheap-contact"); return pushed; } const pressureLight = Boolean(pressure); const maxStep = pressureLight ? Math.max(8, Math.min(16, Number(opts.maxStep || rr * 0.72) || 12)) : Math.max(5, Math.min(12, Number(opts.maxStep || rr * 0.48) || 8)); const steps = Math.max(1, Math.min(48, Math.ceil(dist / maxStep))); const stepX = moveX / steps; const stepY = moveY / steps; const fastSweep = dist > rr * 1.25 || Math.hypot(Number(t.vx || 0) || 0, Number(t.vy || 0) || 0) > 170; const sweepMaxChecks = pressureLight ? Math.min(fastSweep ? 16 : 10, Math.max(6, Number(opts.maxChecks || 0) || 10)) : Math.max(opts.light ? (fastSweep ? 18 : 10) : (fastSweep ? 44 : 32), Number(opts.maxChecks || 0) || CONFIG.maxFenceCollisionChecks || 24); const sweepMaxRects = pressureLight ? (fastSweep ? 36 : 24) : (fastSweep ? 108 : 72); let collided = false; for (let i = 0; i < steps; i++) { const sx = t.x; const sy = t.y; const ex = sx + stepX; const ey = sy + stepY; const hit = firstSweptCircleObstacleHitLocal(this, t, sx, sy, ex, ey, rr, { searchRadius: Math.max(rr + 110, rr + Math.hypot(stepX, stepY) + 170), maxChecks: sweepMaxChecks, maxRects: sweepMaxRects, mechanicalFallback: !pressureLight, }); if (hit && hit.t <= 1) { const safeT = Math.max(0, hit.t - 0.035); t.x = sx + stepX * safeT; t.y = sy + stepY * safeT; const toward = (t.vx || 0) * hit.nx + (t.vy || 0) * hit.ny; if (toward < 0) { t.vx -= toward * hit.nx; t.vy -= toward * hit.ny; } collided = this.resolveSolidObstacleCollision(t, { maxPasses: 3, searchRadius: Math.max(rr + 110, rr + Math.hypot(stepX, stepY) + 170), maxChecks: sweepMaxChecks, maxRects: sweepMaxRects, slop: 0.30, skipPressureBudget: true, oneWayDx: stepX, oneWayDy: stepY, }) || true; continue; } t.x = ex; t.y = ey; const passHit = this.resolveSolidObstacleCollision(t, { maxPasses: 2, searchRadius: Math.max(rr + 96, rr + Math.hypot(stepX, stepY) + 140), maxChecks: sweepMaxChecks, maxRects: sweepMaxRects, slop: 0.35, skipPressureBudget: true, oneWayDx: stepX, oneWayDy: stepY, }); collided = passHit || collided; } if (collided) { t.lastSolidObstacleCollisionAt = this.time || 0; this.markSpatialDirty?.("tarinai-moved-collision"); } return collided; }, resolveSolidObstacleCollision(t, opts = {}) { if (!t || t.dead || this.isTarinaiHiddenInNestBox(t)) return false; const rr = Math.max(8, (Number(t.radius) || 22) * 0.74); const pressure = mechanicalPressureForTarinaiLocal(this, t); if (pressure && !opts.pressureCheap && !opts.skipPressureBudget && !consumeMechanicalPressureContactLocal(this, t)) { return cheapMechanicalPressureContactLocal(this, t, rr, { maxSources: 2, maxRects: 5 }); } this.resolvePoisonBlockContactDamage(t, rr); let pushed = false; let bounced = false; const maxPasses = Math.max(1, Math.min(4, Number(opts.maxPasses ?? 2) || 2)); const searchRadius = Math.max(rr + 40, Number(opts.searchRadius || rr + 150) || (rr + 150)); const maxChecks = Math.max(1, Number(opts.maxChecks || CONFIG.maxFenceCollisionChecks || 24) || 24); const maxRects = Math.max(12, Math.min(128, Number(opts.maxRects || 0) || Math.ceil(maxChecks * 2.5))); for (let pass = 0; pass < maxPasses; pass++) { let passPushed = false; for (const rect of this.nearbySolidObstacleRects(t.x, t.y, searchRadius, { maxChecks, maxRects })) { if (rect?.type === "nest_box" && this.shouldIgnoreNestBoxCollisionFor(t, rect.item)) continue; if (this.pushTarinaiOutOfRect(t, rect, rr, opts)) { pushed = true; passPushed = true; if (rect?.bounce) bounced = true; } } if (!passPushed) break; } if (pushed) { const pad = CONFIG.worldPadding + Math.max(2, (Number(t.radius) || 22) * 0.18); t.x = clamp(t.x, pad, this.w - pad); t.y = clamp(t.y, pad, this.h - pad); const maxV = bounced ? 1280 : 130; t.vx = clamp(t.vx || 0, -maxV, maxV); t.vy = clamp(t.vy || 0, -maxV, maxV); } return pushed; }, resolveFenceCollision(t) { return this.resolveSolidObstacleCollision(t, { maxPasses: 2 }); }, segmentIntersectsRect(x1, y1, x2, y2, r) { if (!r) return false; if (r.oriented) return segmentHitsLocalRect(x1, y1, x2, y2, r, 0); if ((x1 >= r.left && x1 <= r.right && y1 >= r.top && y1 <= r.bottom) || (x2 >= r.left && x2 <= r.right && y2 >= r.top && y2 <= r.bottom)) return true; const intersects = (ax, ay, bx, by, cx, cy, dx, dy) => { const ccw = (px, py, qx, qy, rx, ry) => (ry - py) * (qx - px) > (qy - py) * (rx - px); return ccw(ax, ay, cx, cy, dx, dy) !== ccw(bx, by, cx, cy, dx, dy) && ccw(ax, ay, bx, by, cx, cy) !== ccw(ax, ay, bx, by, dx, dy); }; return intersects(x1, y1, x2, y2, r.left, r.top, r.right, r.top) || intersects(x1, y1, x2, y2, r.right, r.top, r.right, r.bottom) || intersects(x1, y1, x2, y2, r.right, r.bottom, r.left, r.bottom) || intersects(x1, y1, x2, y2, r.left, r.bottom, r.left, r.top); }, isFenceRoutingRect(rect) { const type = rect?.item?.type || rect?.type || ""; if (!type || type === "glass_wall") return false; if (type === "gate_fence" && rect?.item?.gateOpen) return false; if (type === "one_way_fence") return true; // Path routing uses this predicate even though the queried solid rects are // not limited to fences. Nest containers have real collision footprints; // include them here so tarinai route around a non-target nest instead of // walking straight into its body. The target item is still excluded by // findTarinaiPathWaypoint/targetBlockedByObstacle, so sleeping in that // nest remains reachable through the door. if (type === "nest_box" || type === "pipe") return true; return Boolean(this.isFenceType?.(type)); }, oneWayFenceAllowsPath(x1, y1, x2, y2, rect) { const dx = (Number(x2) || 0) - (Number(x1) || 0); const dy = (Number(y2) || 0) - (Number(y1) || 0); return Geometry.oneWayFenceAllowsMotion(rect, x1, y1, dx, dy); }, pathBlockedByFence(x1, y1, x2, y2, padding = 16, opts = {}) { const cx = (x1 + x2) / 2; const cy = (y1 + y2) / 2; const maxD = Math.hypot(x2 - x1, y2 - y1) / 2 + 120; const exclude = opts?.exclude || null; for (const rect of this.nearbySolidObstacleRects(cx, cy, maxD + padding + 170, { exclude })) { if (exclude && rect?.item === exclude) continue; if (!this.isFenceRoutingRect?.(rect)) continue; if (rect?.oneWay && this.oneWayFenceAllowsPath?.(x1, y1, x2, y2, rect)) continue; if (rect?.oriented) { const r = { ...rect, halfW: (rect.halfW || 0) + padding, halfH: (rect.halfH || 0) + padding }; if (this.segmentIntersectsRect(x1, y1, x2, y2, r)) return true; } else { const r = { left: rect.left - padding, right: rect.right + padding, top: rect.top - padding, bottom: rect.bottom + padding }; if (this.segmentIntersectsRect(x1, y1, x2, y2, r)) return true; } } return false; }, targetBlockedByObstacle(actor, target, padding = 14) { if (!actor || !target || !Number.isFinite(Number(actor.x)) || !Number.isFinite(Number(actor.y)) || !Number.isFinite(Number(target.x)) || !Number.isFinite(Number(target.y))) return false; return Boolean(this.pathBlockedByFence?.(actor.x, actor.y, target.x, target.y, padding, { exclude: target })); }, pointBlockedByObstacle(x, y, padding = 14, opts = {}) { const exclude = opts?.exclude || null; const searchRadius = Math.max(48, Number(padding || 0) + 72); for (const rect of this.nearbySolidObstacleRects?.(x, y, searchRadius, { exclude, maxChecks: Math.max(10, Number(opts.maxChecks || 0) || 24) }) || []) { if (exclude && rect?.item === exclude) continue; // Directional pathfinding evaluates one-way fences on edges, not as // permanently blocked grid points. Other callers retain solid occupancy. if (rect?.oneWay && opts?.directionalOneWay === true) continue; if (this.pointInRect?.(x, y, rect, padding)) return true; } return false; }, })); })(typeof window !== "undefined" ? window : globalThis);