From eab8ef295b76c92775408cfb1471e16fc321f029 Mon Sep 17 00:00:00 2001 From: gahusb Date: Mon, 27 Apr 2026 08:28:53 +0900 Subject: [PATCH] feat(agent-office): add BFS pathfinder for agent movement --- src/pages/agent-office/canvas/Pathfinder.js | 112 ++++++++++++++++++++ 1 file changed, 112 insertions(+) create mode 100644 src/pages/agent-office/canvas/Pathfinder.js diff --git a/src/pages/agent-office/canvas/Pathfinder.js b/src/pages/agent-office/canvas/Pathfinder.js new file mode 100644 index 0000000..5d93d2c --- /dev/null +++ b/src/pages/agent-office/canvas/Pathfinder.js @@ -0,0 +1,112 @@ +// src/pages/agent-office/canvas/Pathfinder.js + +/** + * BFS 4방향 경로 탐색 (대각선 없음) + * blocked 타일과 벽 타일을 회피하여 최단 경로 반환 + */ +export class Pathfinder { + constructor(cols, rows) { + this.cols = cols; + this.rows = rows; + this.blocked = new Set(); + } + + /** blocked 타일 세팅 (wall + furniture footprint) */ + setBlocked(blockedList) { + this.blocked.clear(); + for (const [col, row] of blockedList) { + this.blocked.add(`${col},${row}`); + } + } + + /** wall 타일도 blocked로 추가 (floor 배열에서 0인 셀) */ + setWalls(floorGrid) { + for (let r = 0; r < this.rows; r++) { + for (let c = 0; c < this.cols; c++) { + if (floorGrid[r][c] === 0) { + this.blocked.add(`${c},${r}`); + } + } + } + } + + isBlocked(col, row) { + if (col < 0 || col >= this.cols || row < 0 || row >= this.rows) return true; + return this.blocked.has(`${col},${row}`); + } + + /** + * BFS 최단 경로 + * @returns {Array<{col, row}>} 시작점 제외, 도착점 포함 경로. 경로 없으면 빈 배열. + */ + findPath(startCol, startRow, goalCol, goalRow) { + if (startCol === goalCol && startRow === goalRow) return []; + + const key = (c, r) => `${c},${r}`; + const startKey = key(startCol, startRow); + const goalKey = key(goalCol, goalRow); + + const queue = [{ col: startCol, row: startRow }]; + const visited = new Set([startKey]); + const parent = new Map(); + + const dirs = [ + { dc: 0, dr: -1 }, // up + { dc: 0, dr: 1 }, // down + { dc: -1, dr: 0 }, // left + { dc: 1, dr: 0 } // right + ]; + + while (queue.length > 0) { + const current = queue.shift(); + + for (const { dc, dr } of dirs) { + const nc = current.col + dc; + const nr = current.row + dr; + const nk = key(nc, nr); + + if (visited.has(nk) || this.isBlocked(nc, nr)) continue; + // 골 지점은 blocked여도 이동 가능 (에이전트가 자기 자리에 앉으려면) + if (nk !== goalKey && this.blocked.has(nk)) continue; + + visited.add(nk); + parent.set(nk, key(current.col, current.row)); + queue.push({ col: nc, row: nr }); + + if (nc === goalCol && nr === goalRow) { + return this._reconstructPath(parent, startKey, goalKey); + } + } + } + + return []; // 경로 없음 + } + + _reconstructPath(parent, startKey, goalKey) { + const path = []; + let current = goalKey; + while (current !== startKey) { + const [c, r] = current.split(',').map(Number); + path.unshift({ col: c, row: r }); + current = parent.get(current); + } + return path; + } + + /** idle 배회용: start 주변 반경 내 랜덤 walkable 타일 */ + getRandomNearbyFloor(col, row, radius = 4) { + const candidates = []; + for (let dr = -radius; dr <= radius; dr++) { + for (let dc = -radius; dc <= radius; dc++) { + const nc = col + dc; + const nr = row + dr; + if (nc === col && nr === row) continue; + if (!this.isBlocked(nc, nr)) { + candidates.push({ col: nc, row: nr }); + } + } + } + if (candidates.length === 0) return null; + return candidates[Math.floor(Math.random() * candidates.length)]; + } +}