- Pathfinder.setBlocked: remove blocked.clear() to preserve wall tiles set by setWalls() - Pathfinder.findPath: fix dead-code goal exception — remove redundant isBlocked check, keep goal-tile exception in single guard - OfficeRenderer: track mouseDownPos/_wasDragging; expose wasDragging() method for click-after-drag suppression - OfficeRenderer._render: track _lastDpr to detect monitor DPR changes; use setTransform instead of scale to avoid accumulation - TileMap.render: use clientWidth/clientHeight for viewport culling (CSS space, not buffer pixels) - TaskTab: wrap JSON.parse in try/catch to prevent crash on malformed result_data Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
113 lines
3.2 KiB
JavaScript
113 lines
3.2 KiB
JavaScript
// 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) {
|
|
// Do NOT clear — setWalls already added wall tiles
|
|
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)) continue;
|
|
// 골 지점은 blocked여도 이동 가능 (에이전트가 자기 자리에 앉으려면)
|
|
if (nk !== goalKey && this.isBlocked(nc, nr)) 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)];
|
|
}
|
|
}
|