fix(agent-office): critical bug fixes from code review — wall pathfinding, drag/click, DPR, culling

- 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>
This commit is contained in:
2026-04-27 09:40:08 +09:00
parent 3e4f2e0934
commit 6cbdf95596
6 changed files with 37 additions and 11 deletions

View File

@@ -14,7 +14,7 @@ export default function AgentOffice() {
const { const {
canvasRef, updateAgentState, setAgentNotification, canvasRef, updateAgentState, setAgentNotification,
setTheme, setZoom, hitTest, getZoom setTheme, setZoom, hitTest, getZoom, wasDragging
} = useOfficeCanvas(); } = useOfficeCanvas();
const [selectedAgent, setSelectedAgent] = useState(null); const [selectedAgent, setSelectedAgent] = useState(null);
@@ -37,6 +37,7 @@ export default function AgentOffice() {
// 캔버스 클릭 핸들러 // 캔버스 클릭 핸들러
const handleCanvasClick = useCallback((e) => { const handleCanvasClick = useCallback((e) => {
if (wasDragging()) return; // 드래그 후 발생하는 클릭 무시
const result = hitTest(e.clientX, e.clientY); const result = hitTest(e.clientX, e.clientY);
if (result.type === 'agent') { if (result.type === 'agent') {
setSelectedAgent(result.id); setSelectedAgent(result.id);
@@ -45,7 +46,7 @@ export default function AgentOffice() {
} else { } else {
setSelectedAgent(null); setSelectedAgent(null);
} }
}, [hitTest, clearNotifications, setAgentNotification]); }, [hitTest, clearNotifications, setAgentNotification, wasDragging]);
// 테마 변경 // 테마 변경
const handleThemeChange = useCallback((name) => { const handleThemeChange = useCallback((name) => {

View File

@@ -48,6 +48,11 @@ export class OfficeRenderer {
// 게임 루프 // 게임 루프
this._lastTime = 0; this._lastTime = 0;
this._animId = null; this._animId = null;
this._lastDpr = window.devicePixelRatio || 1;
// 드래그 감지
this._mouseDownPos = { x: 0, y: 0 };
this._wasDragging = false;
// 이벤트 // 이벤트
this._setupInputHandlers(); this._setupInputHandlers();
@@ -91,12 +96,17 @@ export class OfficeRenderer {
if (e.button === 0) { if (e.button === 0) {
this._isPanning = true; this._isPanning = true;
this._panStart = { x: e.clientX - this.panX, y: e.clientY - this.panY }; this._panStart = { x: e.clientX - this.panX, y: e.clientY - this.panY };
this._mouseDownPos = { x: e.clientX, y: e.clientY };
this._wasDragging = false;
} }
}); });
this._onMouseMove = (e) => { this._onMouseMove = (e) => {
if (this._isPanning) { if (this._isPanning) {
this.panX = e.clientX - this._panStart.x; this.panX = e.clientX - this._panStart.x;
this.panY = e.clientY - this._panStart.y; this.panY = e.clientY - this._panStart.y;
const dx = e.clientX - this._mouseDownPos.x;
const dy = e.clientY - this._mouseDownPos.y;
if (Math.abs(dx) > 5 || Math.abs(dy) > 5) this._wasDragging = true;
} }
}; };
this._onMouseUp = () => { this._onMouseUp = () => {
@@ -244,11 +254,13 @@ export class OfficeRenderer {
// 캔버스 크기 조정 // 캔버스 크기 조정
const displayW = this.canvas.clientWidth; const displayW = this.canvas.clientWidth;
const displayH = this.canvas.clientHeight; const displayH = this.canvas.clientHeight;
if (this.canvas.width !== displayW * dpr || this.canvas.height !== displayH * dpr) { if (this.canvas.width !== displayW * dpr || this.canvas.height !== displayH * dpr || this._lastDpr !== dpr) {
this.canvas.width = displayW * dpr; this.canvas.width = displayW * dpr;
this.canvas.height = displayH * dpr; this.canvas.height = displayH * dpr;
ctx.scale(dpr, dpr); this._lastDpr = dpr;
} }
// setTransform 방식으로 누적 없이 항상 올바른 변환 적용
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.imageSmoothingEnabled = false; ctx.imageSmoothingEnabled = false;
ctx.clearRect(0, 0, displayW, displayH); ctx.clearRect(0, 0, displayW, displayH);
@@ -287,6 +299,9 @@ export class OfficeRenderer {
} }
} }
/** 드래그 여부 반환 (클릭 이벤트 필터링용) */
wasDragging() { return this._wasDragging; }
/** 리사이즈 처리 */ /** 리사이즈 처리 */
resize() { resize() {
// 다음 프레임에서 자동 조정됨 (_render에서 크기 체크) // 다음 프레임에서 자동 조정됨 (_render에서 크기 체크)

View File

@@ -13,7 +13,7 @@ export class Pathfinder {
/** blocked 타일 세팅 (wall + furniture footprint) */ /** blocked 타일 세팅 (wall + furniture footprint) */
setBlocked(blockedList) { setBlocked(blockedList) {
this.blocked.clear(); // Do NOT clear — setWalls already added wall tiles
for (const [col, row] of blockedList) { for (const [col, row] of blockedList) {
this.blocked.add(`${col},${row}`); this.blocked.add(`${col},${row}`);
} }
@@ -65,9 +65,9 @@ export class Pathfinder {
const nr = current.row + dr; const nr = current.row + dr;
const nk = key(nc, nr); const nk = key(nc, nr);
if (visited.has(nk) || this.isBlocked(nc, nr)) continue; if (visited.has(nk)) continue;
// 골 지점은 blocked여도 이동 가능 (에이전트가 자기 자리에 앉으려면) // 골 지점은 blocked여도 이동 가능 (에이전트가 자기 자리에 앉으려면)
if (nk !== goalKey && this.blocked.has(nk)) continue; if (nk !== goalKey && this.isBlocked(nc, nr)) continue;
visited.add(nk); visited.add(nk);
parent.set(nk, key(current.col, current.row)); parent.set(nk, key(current.col, current.row));

View File

@@ -30,8 +30,8 @@ export class TileMap {
const x = c * ts + offsetX; const x = c * ts + offsetX;
const y = r * ts + offsetY; const y = r * ts + offsetY;
// 화면 밖이면 스킵 // 화면 밖이면 스킵 (CSS 공간 기준 — DPR 변환 적용된 좌표계)
if (x + ts < 0 || y + ts < 0 || x > ctx.canvas.width || y > ctx.canvas.height) continue; if (x + ts < 0 || y + ts < 0 || x > ctx.canvas.clientWidth || y > ctx.canvas.clientHeight) continue;
if (tileType === 0) { if (tileType === 0) {
// 벽 // 벽

View File

@@ -45,7 +45,12 @@ export default function TaskTab({ agentId, refreshTrigger }) {
<span className="ao-task-time">{formatTime(task.created_at)}</span> <span className="ao-task-time">{formatTime(task.created_at)}</span>
</div> </div>
{expanded === task.id && task.result_data && ( {expanded === task.id && task.result_data && (
<pre className="ao-task-result">{JSON.stringify(JSON.parse(task.result_data), null, 2)}</pre> <pre className="ao-task-result">
{(() => {
try { return JSON.stringify(JSON.parse(task.result_data), null, 2); }
catch { return task.result_data; }
})()}
</pre>
)} )}
</div> </div>
); );

View File

@@ -47,6 +47,10 @@ export function useOfficeCanvas() {
return rendererRef.current?.zoom || 2; return rendererRef.current?.zoom || 2;
}, []); }, []);
const wasDragging = useCallback(() => {
return rendererRef.current?.wasDragging?.() || false;
}, []);
return { return {
canvasRef, canvasRef,
updateAgentState, updateAgentState,
@@ -54,6 +58,7 @@ export function useOfficeCanvas() {
setTheme, setTheme,
setZoom, setZoom,
hitTest, hitTest,
getZoom getZoom,
wasDragging
}; };
} }