diff --git a/src/pages/agent-office/AgentOffice.css b/src/pages/agent-office/AgentOffice.css index f85453a..5eb2c0e 100644 --- a/src/pages/agent-office/AgentOffice.css +++ b/src/pages/agent-office/AgentOffice.css @@ -5,8 +5,8 @@ display: flex; flex-direction: column; height: 100vh; - background: #0d0d1a; - color: #ffffff; + background: #0f172a; + color: #e2e8f0; font-family: 'Courier New', monospace; overflow: hidden; } @@ -32,50 +32,9 @@ font-size: 15px; color: #8b5cf6; } -.ao-topbar-status { - font-size: 11px; -} +.ao-topbar-status { font-size: 11px; } .ao-topbar-status.connected { color: #22c55e; } .ao-topbar-status.disconnected { color: #ef4444; } -.ao-topbar-right { - display: flex; - align-items: center; - gap: 10px; -} -.ao-topbar-select { - background: #2a2a3e; - color: #aaa; - border: 1px solid #444; - padding: 3px 8px; - border-radius: 4px; - font-size: 12px; - font-family: inherit; -} -.ao-topbar-zoom { - display: flex; - align-items: center; - gap: 4px; -} -.ao-topbar-zoom button { - background: #2a2a3e; - color: #aaa; - border: 1px solid #444; - width: 24px; - height: 24px; - border-radius: 4px; - cursor: pointer; - font-size: 14px; -} -.ao-topbar-zoom button:disabled { - opacity: 0.3; - cursor: default; -} -.ao-topbar-zoom span { - color: #888; - font-size: 12px; - min-width: 28px; - text-align: center; -} /* ===== Main Area ===== */ .ao-main { @@ -84,13 +43,107 @@ position: relative; overflow: hidden; } -.ao-canvas { + +/* ===== Grid Wrap ===== */ +.ao-grid-wrap { flex: 1; - cursor: grab; + overflow-y: auto; + padding: 24px; +} +.ao-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 16px; + max-width: 720px; + margin: 0 auto; +} + +/* ===== Agent Card ===== */ +.ao-card { + position: relative; + aspect-ratio: 1 / 1.15; + background: #1e293b; + border: 1px solid #334155; + border-radius: 12px; + cursor: pointer; + padding: 12px; + display: flex; + flex-direction: column; + align-items: center; + font-family: inherit; + color: inherit; + transition: transform 120ms ease, border-color 120ms ease, box-shadow 120ms ease; +} +.ao-card:hover { + transform: translateY(-2px); + border-color: var(--card-accent, #60a5fa); +} +.ao-card.active { + border-color: var(--card-accent, #60a5fa); + box-shadow: 0 0 0 2px var(--card-accent, #60a5fa); +} +.ao-card.placeholder { + opacity: 0.55; +} + +.ao-card-dot { + position: absolute; + top: 8px; + left: 8px; + width: 10px; + height: 10px; + border-radius: 50%; + background: #6b7280; + box-shadow: 0 0 0 2px #0f172a; +} +.ao-card-dot.working { background: #22c55e; } +.ao-card-dot.error { background: #ef4444; } +.ao-card-dot.waiting_approval { background: #f59e0b; } +.ao-card-dot.break { background: #94a3b8; } +.ao-card-dot.pulse { + animation: ao-pulse 1.6s ease-in-out infinite; +} +@keyframes ao-pulse { + 0%, 100% { opacity: 1; transform: scale(1); } + 50% { opacity: 0.45; transform: scale(1.2); } +} + +.ao-card-badge { + position: absolute; + top: 6px; + right: 6px; + min-width: 18px; + height: 18px; + padding: 0 5px; + background: #ef4444; + color: #fff; + border-radius: 9px; + font-size: 10px; + font-weight: bold; + display: flex; + align-items: center; + justify-content: center; +} + +.ao-card-image { + flex: 1; + width: 100%; + aspect-ratio: 1 / 1; + border-radius: 8px; + overflow: hidden; + background: #0f172a; + margin-bottom: 8px; +} +.ao-card-image img { + width: 100%; + height: 100%; + object-fit: cover; display: block; } -.ao-canvas:active { - cursor: grabbing; +.ao-card-name { + font-size: 12px; + color: #e2e8f0; + text-align: center; } /* ===== Side Panel ===== */ @@ -103,6 +156,11 @@ flex-shrink: 0; animation: slideIn 0.2s ease-out; } +.ao-sidepanel-initial { + display: flex; + align-items: center; + justify-content: center; +} @keyframes slideIn { from { transform: translateX(100%); } to { transform: translateX(0); } @@ -120,13 +178,18 @@ gap: 10px; } .ao-sidepanel-icon { - width: 36px; - height: 36px; + width: 40px; + height: 40px; border-radius: 8px; - display: flex; - align-items: center; - justify-content: center; - font-size: 18px; + border: 2px solid #444; + overflow: hidden; + flex-shrink: 0; +} +.ao-sidepanel-icon img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; } .ao-sidepanel-name { font-weight: bold; @@ -144,9 +207,7 @@ cursor: pointer; padding: 0 4px; } -.ao-sidepanel-close:hover { - color: #fff; -} +.ao-sidepanel-close:hover { color: #fff; } /* Tabs */ .ao-sidepanel-tabs { @@ -170,9 +231,7 @@ border-bottom-color: #8b5cf6; font-weight: bold; } -.ao-sidepanel-tab:hover { - color: #aaa; -} +.ao-sidepanel-tab:hover { color: #aaa; } .ao-sidepanel-content { flex: 1; overflow-y: auto; @@ -207,10 +266,7 @@ .ao-btn-quick:hover { background: #3a3a5e; } .ao-btn-quick:disabled { opacity: 0.4; } -.ao-param-row { - display: flex; - gap: 6px; -} +.ao-param-row { display: flex; gap: 6px; } .ao-input { flex: 1; background: #1a1a2e; @@ -236,177 +292,67 @@ .ao-btn-send:hover { background: #5b21b6; } .ao-btn-send:disabled { opacity: 0.4; } -/* Approval */ .ao-approval-card { background: rgba(146,64,14,0.15); border: 1px solid #92400e; border-radius: 6px; padding: 10px; } -.ao-approval-title { - color: #fbbf24; - font-size: 12px; - font-weight: bold; - margin-bottom: 4px; -} -.ao-approval-desc { - color: #ddd; - font-size: 11px; - margin-bottom: 8px; - word-break: break-all; -} -.ao-approval-actions { - display: flex; - gap: 6px; -} +.ao-approval-title { color: #fbbf24; font-size: 12px; font-weight: bold; margin-bottom: 4px; } +.ao-approval-desc { color: #ddd; font-size: 11px; margin-bottom: 8px; word-break: break-all; } +.ao-approval-actions { display: flex; gap: 6px; } .ao-btn-approve { - flex: 1; - background: #065f46; - color: #fff; - border: none; - padding: 7px; - border-radius: 4px; - font-size: 12px; - cursor: pointer; + flex: 1; background: #065f46; color: #fff; border: none; + padding: 7px; border-radius: 4px; font-size: 12px; cursor: pointer; } .ao-btn-reject { - flex: 1; - background: #7f1d1d; - color: #fff; - border: none; - padding: 7px; - border-radius: 4px; - font-size: 12px; - cursor: pointer; + flex: 1; background: #7f1d1d; color: #fff; border: none; + padding: 7px; border-radius: 4px; font-size: 12px; cursor: pointer; } /* ===== Task Tab ===== */ .ao-task-tab { display: flex; flex-direction: column; gap: 4px; } -.ao-task-item { - background: #1a1a2e; - border-radius: 4px; - padding: 8px; - cursor: pointer; -} +.ao-task-item { background: #1a1a2e; border-radius: 4px; padding: 8px; cursor: pointer; } .ao-task-item:hover { background: #222240; } -.ao-task-header { - display: flex; - align-items: center; - gap: 6px; - font-size: 12px; -} +.ao-task-header { display: flex; align-items: center; gap: 6px; font-size: 12px; } .ao-task-type { color: #ccc; font-weight: bold; flex: 1; } -.ao-task-badge { - padding: 1px 6px; - border-radius: 3px; - font-size: 10px; -} +.ao-task-badge { padding: 1px 6px; border-radius: 3px; font-size: 10px; } .ao-task-time { color: #666; font-size: 10px; } .ao-task-result { - margin-top: 6px; - background: #0d0d1a; - padding: 6px; - border-radius: 3px; - font-size: 10px; - color: #aaa; - max-height: 200px; - overflow-y: auto; - white-space: pre-wrap; - word-break: break-all; + margin-top: 6px; background: #0d0d1a; padding: 6px; border-radius: 3px; + font-size: 10px; color: #aaa; max-height: 200px; overflow-y: auto; + white-space: pre-wrap; word-break: break-all; } /* ===== Token Tab ===== */ .ao-token-tab { display: flex; flex-direction: column; gap: 12px; } -.ao-token-period { - display: flex; - gap: 4px; -} +.ao-token-period { display: flex; gap: 4px; } .ao-btn-period { - flex: 1; - background: #1a1a2e; - color: #888; - border: 1px solid #333; - padding: 5px; - border-radius: 4px; - font-size: 11px; - font-family: inherit; - cursor: pointer; -} -.ao-btn-period.active { - background: #4c1d95; - color: #fff; - border-color: #4c1d95; -} -.ao-token-grid { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 8px; -} -.ao-token-card { - background: #1a1a2e; - border-radius: 6px; - padding: 10px; - text-align: center; -} -.ao-token-label { - font-size: 10px; - color: #888; - text-transform: uppercase; - margin-bottom: 4px; -} -.ao-token-value { - font-size: 18px; - font-weight: bold; - color: #fff; + flex: 1; background: #1a1a2e; color: #888; border: 1px solid #333; + padding: 5px; border-radius: 4px; font-size: 11px; font-family: inherit; cursor: pointer; } +.ao-btn-period.active { background: #4c1d95; color: #fff; border-color: #4c1d95; } +.ao-token-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; } +.ao-token-card { background: #1a1a2e; border-radius: 6px; padding: 10px; text-align: center; } +.ao-token-label { font-size: 10px; color: #888; text-transform: uppercase; margin-bottom: 4px; } +.ao-token-value { font-size: 18px; font-weight: bold; color: #fff; } .ao-token-bar { margin-top: 4px; } .ao-token-bar-label { font-size: 10px; color: #888; margin-bottom: 4px; } -.ao-token-bar-track { - display: flex; - height: 8px; - border-radius: 4px; - overflow: hidden; - background: #1a1a2e; -} +.ao-token-bar-track { display: flex; height: 8px; border-radius: 4px; overflow: hidden; background: #1a1a2e; } .ao-token-bar-fill.input { background: #3b82f6; } .ao-token-bar-fill.output { background: #8b5cf6; } -.ao-token-bar-legend { - display: flex; - gap: 12px; - font-size: 10px; - color: #888; - margin-top: 4px; -} -.ao-token-bar-legend .dot { - display: inline-block; - width: 8px; - height: 8px; - border-radius: 50%; - margin-right: 4px; -} +.ao-token-bar-legend { display: flex; gap: 12px; font-size: 10px; color: #888; margin-top: 4px; } +.ao-token-bar-legend .dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 4px; } .ao-token-bar-legend .dot.input { background: #3b82f6; } .ao-token-bar-legend .dot.output { background: #8b5cf6; } -.ao-token-detail { - display: flex; - justify-content: space-between; - font-size: 10px; - color: #666; -} +.ao-token-detail { display: flex; justify-content: space-between; font-size: 10px; color: #666; } /* ===== Log Tab ===== */ .ao-log-tab { - max-height: 100%; - overflow-y: auto; - display: flex; - flex-direction: column; - gap: 2px; + max-height: 100%; overflow-y: auto; display: flex; flex-direction: column; gap: 2px; } .ao-log-item { - display: flex; - gap: 6px; - font-size: 11px; - padding: 3px 0; - border-bottom: 1px solid #1a1a2e; + display: flex; gap: 6px; font-size: 11px; padding: 3px 0; border-bottom: 1px solid #1a1a2e; } .ao-log-time { color: #555; min-width: 60px; } .ao-log-level { min-width: 48px; font-weight: bold; } @@ -414,26 +360,22 @@ /* ===== Common ===== */ .ao-empty { - color: #555; + color: #94a3b8; text-align: center; padding: 24px; font-size: 13px; + line-height: 1.6; } /* ===== Mobile (< 768px) ===== */ @media (max-width: 768px) { - .ao-topbar-right { gap: 6px; } - .ao-topbar-select { font-size: 11px; padding: 2px 6px; } - - .ao-main { - flex-direction: column; + .ao-grid-wrap { padding: 12px; } + .ao-grid { + grid-template-columns: repeat(2, 1fr); + gap: 10px; } + .ao-main { flex-direction: column; } - .ao-canvas { - flex: 1; - } - - /* Side panel → bottom sheet */ .ao-sidepanel { position: fixed; bottom: 0; @@ -452,9 +394,7 @@ to { transform: translateY(0); } } - .ao-sidepanel-header { - padding: 8px 12px; - } + .ao-sidepanel-header { padding: 8px 12px; } .ao-sidepanel-header::before { content: ''; display: block; @@ -464,12 +404,7 @@ border-radius: 2px; margin: 0 auto 8px; } - - .ao-sidepanel-tab { - font-size: 11px; - padding: 6px 2px; - } - + .ao-sidepanel-tab { font-size: 11px; padding: 6px 2px; } .ao-sidepanel-content { padding: 8px 12px; padding-bottom: env(safe-area-inset-bottom, 16px); diff --git a/src/pages/agent-office/AgentOffice.jsx b/src/pages/agent-office/AgentOffice.jsx index 08d082b..224d6e2 100644 --- a/src/pages/agent-office/AgentOffice.jsx +++ b/src/pages/agent-office/AgentOffice.jsx @@ -1,9 +1,11 @@ // src/pages/agent-office/AgentOffice.jsx -import { useState, useEffect, useCallback } from 'react'; +import { useState, useCallback } from 'react'; import { useAgentManager } from './hooks/useAgentManager.js'; -import { useOfficeCanvas } from './hooks/useOfficeCanvas.js'; +import { AGENT_META } from './constants.js'; import TopBar from './components/TopBar.jsx'; +import AgentGrid from './components/AgentGrid.jsx'; import SidePanel from './components/SidePanel.jsx'; +import EmptyDetailPanel from './components/EmptyDetailPanel.jsx'; import './AgentOffice.css'; export default function AgentOffice() { @@ -12,85 +14,57 @@ export default function AgentOffice() { refreshTrigger, clearNotifications } = useAgentManager(); - const { - canvasRef, updateAgentState, setAgentNotification, - setTheme, setZoom, hitTest, getZoom, wasDragging - } = useOfficeCanvas(); - + // selectedAgent: null | active agent id | "placeholder-N" const [selectedAgent, setSelectedAgent] = useState(null); - const [theme, setThemeState] = useState(localStorage.getItem('agent-office-theme') || 'modern'); - const [zoom, setZoomState] = useState(2); - // WebSocket 상태 → 캔버스 동기화 - useEffect(() => { - for (const [id, agentState] of Object.entries(agents)) { - updateAgentState(id, agentState.state, agentState.detail); - } - }, [agents, updateAgentState]); + const handleSelectAgent = useCallback((agentId) => { + setSelectedAgent(agentId); + clearNotifications(agentId); + }, [clearNotifications]); - // 알림 → 캔버스 동기화 - useEffect(() => { - for (const [id, count] of Object.entries(notifications)) { - setAgentNotification(id, count); - } - }, [notifications, setAgentNotification]); + const handleSelectPlaceholder = useCallback((placeholderKey) => { + setSelectedAgent(placeholderKey); + }, []); - // 캔버스 클릭 핸들러 - const handleCanvasClick = useCallback((e) => { - if (wasDragging()) return; // 드래그 후 발생하는 클릭 무시 - const result = hitTest(e.clientX, e.clientY); - if (result.type === 'agent') { - setSelectedAgent(result.id); - clearNotifications(result.id); - setAgentNotification(result.id, 0); - } else { - setSelectedAgent(null); - } - }, [hitTest, clearNotifications, setAgentNotification, wasDragging]); + const handleClose = useCallback(() => { + setSelectedAgent(null); + }, []); - // 테마 변경 - const handleThemeChange = useCallback((name) => { - setThemeState(name); - setTheme(name); - }, [setTheme]); - - // 줌 변경 - const handleZoomChange = useCallback((level) => { - setZoomState(level); - setZoom(level); - }, [setZoom]); - - // 선택된 에이전트의 pending task - const pendingTask = selectedAgent + const pendingTask = selectedAgent && AGENT_META[selectedAgent] ? pendingTasks.find(t => t.agent_id === selectedAgent) : null; + let rightPanel; + if (selectedAgent === null) { + rightPanel = ; + } else if (selectedAgent.startsWith('placeholder-')) { + rightPanel = ; + } else { + rightPanel = ( + + ); + } + return (
- - +
- - - {selectedAgent && ( - setSelectedAgent(null)} - refreshTrigger={refreshTrigger} +
+ - )} +
+ {rightPanel}
); diff --git a/src/pages/agent-office/assets/office-map.json b/src/pages/agent-office/assets/office-map.json deleted file mode 100644 index 9422f0f..0000000 --- a/src/pages/agent-office/assets/office-map.json +++ /dev/null @@ -1,72 +0,0 @@ -{ - "cols": 32, - "rows": 20, - "tileSize": 32, - "floor": [ - [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0], - [0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0], - [0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0], - [0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0], - [0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0], - [0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0], - [0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0], - [0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0], - [0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0], - [0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0], - [0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0], - [0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0], - [0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0], - [0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0], - [0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0], - [0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0], - [0,2,2,2,2,2,2,2,2,2,2,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0], - [0,2,2,2,2,2,2,2,2,2,2,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0], - [0,2,2,2,2,2,2,2,2,2,2,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0], - [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0] - ], - "furniture": [ - {"type": "desk_monitor", "col": 3, "row": 3, "agent": "stock", "monitors": 3}, - {"type": "desk_monitor", "col": 10, "row": 3, "agent": "music", "monitors": 1, "accent": "instrument"}, - {"type": "desk_monitor", "col": 17, "row": 3, "agent": "blog", "monitors": 2, "accent": "papers"}, - {"type": "desk_monitor", "col": 24, "row": 3, "agent": "realestate", "monitors": 2, "accent": "briefcase"}, - {"type": "desk_monitor", "col": 14, "row": 7, "agent": "lotto", "monitors": 1, "accent": "dice"}, - {"type": "meeting_table","col": 13, "row": 11,"width": 6, "height": 2}, - {"type": "sofa", "col": 2, "row": 17}, - {"type": "coffee_machine","col": 5, "row": 16}, - {"type": "bookshelf", "col": 27, "row": 16, "height": 3}, - {"type": "plant", "col": 1, "row": 1}, - {"type": "plant", "col": 30, "row": 1}, - {"type": "plant", "col": 1, "row": 14}, - {"type": "plant", "col": 30, "row": 14}, - {"type": "water_cooler", "col": 8, "row": 17} - ], - "waypoints": { - "desk_stock": {"col": 3, "row": 4}, - "desk_music": {"col": 10, "row": 4}, - "desk_blog": {"col": 17, "row": 4}, - "desk_realestate": {"col": 24, "row": 4}, - "desk_lotto": {"col": 14, "row": 8}, - "meeting": {"col": 16, "row": 13}, - "break_room": {"col": 4, "row": 17}, - "coffee": {"col": 6, "row": 17}, - "water_cooler": {"col": 8, "row": 18} - }, - "blocked": [ - [3,3],[4,3],[5,3], - [10,3],[11,3], - [17,3],[18,3],[19,3], - [24,3],[25,3],[26,3], - [14,7],[15,7], - [13,11],[14,11],[15,11],[16,11],[17,11],[18,11], - [13,12],[14,12],[15,12],[16,12],[17,12],[18,12], - [2,17],[3,17], - [5,16],[6,16], - [27,16],[27,17],[27,18], - [8,17] - ], - "tileTypes": { - "0": "wall", - "1": "floor", - "2": "floor_break" - } -} diff --git a/src/pages/agent-office/canvas/AgentSprite.js b/src/pages/agent-office/canvas/AgentSprite.js deleted file mode 100644 index 4ef01d8..0000000 --- a/src/pages/agent-office/canvas/AgentSprite.js +++ /dev/null @@ -1,261 +0,0 @@ -// src/pages/agent-office/canvas/AgentSprite.js - -import { ProceduralSprite } from './ProceduralSprite.js'; - -const WALK_SPEED = 3; // tiles per second -const WANDER_DELAY_MIN = 3; -const WANDER_DELAY_MAX = 8; -const WANDER_LIMIT_MIN = 3; -const WANDER_LIMIT_MAX = 6; -const REST_DELAY_MIN = 2; -const REST_DELAY_MAX = 20; - -export class AgentSprite { - constructor(id, meta, col, row, pathfinder) { - this.id = id; - this.meta = meta; - this.pathfinder = pathfinder; - - // 위치 (타일 좌표, 실수) - this.x = col; - this.y = row; - this.deskCol = col; - this.deskRow = row; - - // 상태 - this.state = 'idle'; // FSM 상태 (from backend) - this.detail = ''; - this.notificationCount = 0; - - // 애니메이션 - this.animState = 'idle'; // 렌더링용 상태 - this.direction = 'down'; - this.animFrame = 0; - this.animTimer = 0; - - // 이동 - this.path = []; // BFS 경로 [{col, row}, ...] - this.moveProgress = 0; // 0~1 현재 타일 → 다음 타일 - this.moveFrom = { col, row }; - this.moveTo_target = null; - - // 배회 - this._wandering = false; - this._wanderTimer = 0; - this._wanderCount = 0; - this._wanderLimit = 0; - this._restTimer = 0; - this._isResting = false; - this._isAtDesk = true; - } - - /** 매 프레임 호출 */ - update(dt) { - // 이동 처리 - if (this.path.length > 0) { - this._updateMovement(dt); - } else if (this._wandering) { - this._updateWander(dt); - } - - // 애니메이션 프레임 업데이트 - this._updateAnimation(dt); - } - - _updateMovement(dt) { - this.animState = 'walk'; - this.moveProgress += WALK_SPEED * dt; - - if (this.moveProgress >= 1) { - // 현재 구간 완료 - const arrived = this.path.shift(); - this.x = arrived.col; - this.y = arrived.row; - this.moveFrom = { col: arrived.col, row: arrived.row }; - this.moveProgress = 0; - - if (this.path.length === 0) { - // 최종 목적지 도착 - this._onArrival(); - } else { - // 다음 구간의 방향 설정 - this._updateDirection(this.path[0]); - } - } else { - // 보간 - const next = this.path[0]; - this.x = this.moveFrom.col + (next.col - this.moveFrom.col) * this.moveProgress; - this.y = this.moveFrom.row + (next.row - this.moveFrom.row) * this.moveProgress; - } - } - - _onArrival() { - const atDesk = Math.abs(this.x - this.deskCol) < 0.5 && Math.abs(this.y - this.deskRow) < 0.5; - this._isAtDesk = atDesk; - - if (this.state === 'working' || this.state === 'reporting') { - this.animState = 'type'; - this.direction = 'up'; // 모니터를 바라봄 - } else if (this.state === 'waiting') { - this.animState = 'wait'; - } else if (this.state === 'break') { - this.animState = 'break_anim'; - } else { - // idle 도착 — 배회 계속 또는 자리에서 쉬기 - if (this._wandering && this._wanderCount < this._wanderLimit) { - // 다음 배회 타이머 설정 - this._wanderTimer = WANDER_DELAY_MIN + Math.random() * (WANDER_DELAY_MAX - WANDER_DELAY_MIN); - } else if (this._wandering) { - // 배회 끝, 휴식 - this._wandering = false; - this._isResting = true; - this._restTimer = REST_DELAY_MIN + Math.random() * (REST_DELAY_MAX - REST_DELAY_MIN); - } - this.animState = 'idle'; - } - } - - _updateWander(dt) { - if (this._isResting) { - this._restTimer -= dt; - if (this._restTimer <= 0) { - this._isResting = false; - this._startWandering(); - } - return; - } - - this._wanderTimer -= dt; - if (this._wanderTimer <= 0) { - // 랜덤 인접 타일로 이동 - const target = this.pathfinder.getRandomNearbyFloor( - Math.round(this.x), Math.round(this.y), 4 - ); - if (target) { - const path = this.pathfinder.findPath( - Math.round(this.x), Math.round(this.y), target.col, target.row - ); - if (path.length > 0 && path.length <= 6) { - this.path = path; - this.moveFrom = { col: Math.round(this.x), row: Math.round(this.y) }; - this.moveProgress = 0; - this._updateDirection(path[0]); - this._wanderCount++; - } - } - // 실패해도 타이머 리셋 - this._wanderTimer = WANDER_DELAY_MIN + Math.random() * (WANDER_DELAY_MAX - WANDER_DELAY_MIN); - } - } - - _updateDirection(nextTile) { - const dx = nextTile.col - Math.round(this.x); - const dy = nextTile.row - Math.round(this.y); - if (Math.abs(dx) > Math.abs(dy)) { - this.direction = dx > 0 ? 'right' : 'left'; - } else { - this.direction = dy > 0 ? 'down' : 'up'; - } - } - - _updateAnimation(dt) { - const config = ProceduralSprite.getAnimConfig( - this.animState === 'walk' ? 'walk' : this.state - ); - this.animTimer += dt; - if (this.animTimer >= config.speed) { - this.animTimer = 0; - this.animFrame = (this.animFrame + 1) % config.frames; - } - } - - /** 백엔드 상태 변경 시 호출 */ - onStateChange(newState, detail, waypoints) { - const prevState = this.state; - this.state = newState; - this.detail = detail || ''; - - // 배회 중단 - this._wandering = false; - this._isResting = false; - - switch (newState) { - case 'working': - case 'reporting': - case 'waiting': - // 자리에 없으면 자리로 이동 - if (!this._isAtDesk) { - this._moveToDesk(); - } else { - this.animState = newState === 'waiting' ? 'wait' : 'type'; - this.direction = 'up'; - } - break; - - case 'break': { - // 휴게실로 이동 - const breakWp = waypoints.break_room || waypoints.coffee; - if (breakWp) { - this._navigateTo(breakWp.col, breakWp.row); - } - break; - } - - case 'idle': - if (prevState === 'break') { - // 휴게실에서 자리로 복귀 - this._moveToDesk(); - } - // 복귀 후 배회 시작 (도착 콜백에서 처리) - this._startWanderingAfterDelay(3); - break; - } - } - - _moveToDesk() { - this._navigateTo(this.deskCol, this.deskRow); - } - - _navigateTo(goalCol, goalRow) { - const startCol = Math.round(this.x); - const startRow = Math.round(this.y); - const path = this.pathfinder.findPath(startCol, startRow, goalCol, goalRow); - if (path.length > 0) { - this.path = path; - this.moveFrom = { col: startCol, row: startRow }; - this.moveProgress = 0; - this._updateDirection(path[0]); - } - } - - _startWanderingAfterDelay(delay) { - this._wandering = true; - this._wanderCount = 0; - this._wanderLimit = WANDER_LIMIT_MIN + Math.floor(Math.random() * (WANDER_LIMIT_MAX - WANDER_LIMIT_MIN)); - this._wanderTimer = delay; - this._isResting = false; - } - - _startWandering() { - this._startWanderingAfterDelay(WANDER_DELAY_MIN + Math.random() * (WANDER_DELAY_MAX - WANDER_DELAY_MIN)); - } - - isAtDesk() { - return this._isAtDesk; - } - - /** 렌더링 */ - draw(ctx, zoom, panX, panY, tileSize) { - const ts = tileSize * zoom; - const screenX = this.x * ts + panX + ts / 2; - const screenY = this.y * ts + panY + ts; - const spriteScale = zoom * 1.5; // 캐릭터 약간 크게 - - ProceduralSprite.draw( - ctx, this.id, - this.animState === 'walk' ? 'walk' : this.state, - this.direction, this.animFrame, - screenX, screenY, spriteScale - ); - } -} diff --git a/src/pages/agent-office/canvas/FurnitureRenderer.js b/src/pages/agent-office/canvas/FurnitureRenderer.js deleted file mode 100644 index aed99d2..0000000 --- a/src/pages/agent-office/canvas/FurnitureRenderer.js +++ /dev/null @@ -1,209 +0,0 @@ -// src/pages/agent-office/canvas/FurnitureRenderer.js - -/** - * 가구 프로시저럴 렌더러 — 테마 팔레트 기반 - * 각 가구 타입별 draw 함수, Y-sort를 위한 zY 반환 - */ -export class FurnitureRenderer { - constructor(furnitureList, tileSize) { - this.furnitureList = furnitureList; - this.tileSize = tileSize; - } - - /** - * 모든 가구를 Y-sort 순서로 반환 (에이전트와 함께 정렬하기 위함) - * @returns {Array<{type, col, row, zY, draw: Function}>} - */ - getRenderables(theme, scale, offsetX, offsetY) { - const ts = this.tileSize * scale; - return this.furnitureList.map(f => ({ - ...f, - zY: f.row, - draw: (ctx) => this._drawFurniture(ctx, f, theme, ts, offsetX, offsetY) - })); - } - - _drawFurniture(ctx, f, theme, ts, ox, oy) { - const x = f.col * ts + ox; - const y = f.row * ts + oy; - - switch (f.type) { - case 'desk_monitor': this._drawDesk(ctx, f, theme, ts, x, y); break; - case 'meeting_table': this._drawMeetingTable(ctx, f, theme, ts, x, y); break; - case 'sofa': this._drawSofa(ctx, theme, ts, x, y); break; - case 'coffee_machine':this._drawCoffeeMachine(ctx, theme, ts, x, y); break; - case 'bookshelf': this._drawBookshelf(ctx, f, theme, ts, x, y); break; - case 'plant': this._drawPlant(ctx, theme, ts, x, y); break; - case 'water_cooler': this._drawWaterCooler(ctx, theme, ts, x, y); break; - } - } - - _drawDesk(ctx, f, theme, ts, x, y) { - // 책상 상판 - const dw = ts * 2; - const dh = ts * 0.6; - ctx.fillStyle = theme.furniture.desk; - ctx.fillRect(x, y + ts * 0.2, dw, dh); - // 책상 다리 - ctx.fillStyle = theme.wall.border; - ctx.fillRect(x + ts * 0.1, y + dh + ts * 0.2, ts * 0.15, ts * 0.3); - ctx.fillRect(x + dw - ts * 0.25, y + dh + ts * 0.2, ts * 0.15, ts * 0.3); - - // 모니터들 - const monCount = f.monitors || 1; - const monW = ts * 0.5; - const monH = ts * 0.4; - const totalW = monCount * monW + (monCount - 1) * ts * 0.1; - let monX = x + (dw - totalW) / 2; - - for (let i = 0; i < monCount; i++) { - // 모니터 프레임 - ctx.fillStyle = theme.furniture.monitor; - ctx.fillRect(monX, y - monH + ts * 0.2, monW, monH); - // 화면 - ctx.fillStyle = theme.furniture.monitorScreen; - ctx.fillRect(monX + ts * 0.05, y - monH + ts * 0.25, monW - ts * 0.1, monH - ts * 0.1); - // 모니터 받침대 - ctx.fillStyle = theme.furniture.monitor; - ctx.fillRect(monX + monW * 0.35, y + ts * 0.2 - ts * 0.05, monW * 0.3, ts * 0.08); - monX += monW + ts * 0.1; - } - - // 의자 (책상 아래) - ctx.fillStyle = theme.furniture.chair; - ctx.fillRect(x + dw * 0.35, y + ts, dw * 0.3, ts * 0.5); - ctx.fillRect(x + dw * 0.3, y + ts * 0.8, dw * 0.4, ts * 0.25); - - // 에이전트별 악센트 소품 - if (f.accent === 'instrument') { - // 음표 모양 - ctx.fillStyle = theme.ui.accent; - ctx.fillRect(x + dw + ts * 0.2, y + ts * 0.3, ts * 0.1, ts * 0.5); - ctx.beginPath(); - ctx.arc(x + dw + ts * 0.2, y + ts * 0.8, ts * 0.15, 0, Math.PI * 2); - ctx.fill(); - } else if (f.accent === 'papers') { - // 서류 더미 - ctx.fillStyle = '#ffffff'; - ctx.fillRect(x + dw + ts * 0.1, y + ts * 0.3, ts * 0.35, ts * 0.45); - ctx.fillStyle = theme.text.label; - for (let i = 0; i < 3; i++) { - ctx.fillRect(x + dw + ts * 0.15, y + ts * 0.38 + i * ts * 0.1, ts * 0.25, ts * 0.02); - } - } else if (f.accent === 'briefcase') { - ctx.fillStyle = '#8B4513'; - ctx.fillRect(x + dw + ts * 0.1, y + ts * 0.5, ts * 0.4, ts * 0.3); - ctx.fillStyle = '#D4A06A'; - ctx.fillRect(x + dw + ts * 0.2, y + ts * 0.45, ts * 0.2, ts * 0.08); - } else if (f.accent === 'dice') { - ctx.fillStyle = '#ef4444'; - ctx.fillRect(x + dw + ts * 0.15, y + ts * 0.4, ts * 0.3, ts * 0.3); - ctx.fillStyle = '#ffffff'; - ctx.beginPath(); - ctx.arc(x + dw + ts * 0.3, y + ts * 0.55, ts * 0.05, 0, Math.PI * 2); - ctx.fill(); - } - } - - _drawMeetingTable(ctx, f, theme, ts, x, y) { - const w = (f.width || 4) * ts; - const h = (f.height || 2) * ts; - // 테이블 상판 - ctx.fillStyle = theme.furniture.table; - ctx.fillRect(x + ts * 0.1, y + ts * 0.1, w - ts * 0.2, h - ts * 0.2); - // 테이블 그림자 - ctx.fillStyle = 'rgba(0,0,0,0.15)'; - ctx.fillRect(x + ts * 0.15, y + h - ts * 0.1, w - ts * 0.25, ts * 0.1); - // 의자들 (상하 4개씩) - for (let i = 0; i < 4; i++) { - const cx = x + ts * 0.5 + i * (w - ts) / 3; - ctx.fillStyle = theme.furniture.chair; - ctx.fillRect(cx, y - ts * 0.3, ts * 0.4, ts * 0.35); - ctx.fillRect(cx, y + h - ts * 0.05, ts * 0.4, ts * 0.35); - } - } - - _drawSofa(ctx, theme, ts, x, y) { - ctx.fillStyle = theme.furniture.sofa; - ctx.fillRect(x, y, ts * 2, ts * 0.8); - // 등받이 - ctx.fillStyle = theme.furniture.sofa; - ctx.fillRect(x, y - ts * 0.3, ts * 2, ts * 0.35); - // 쿠션 구분선 - ctx.strokeStyle = theme.wall.border; - ctx.lineWidth = 1; - ctx.beginPath(); - ctx.moveTo(x + ts, y); - ctx.lineTo(x + ts, y + ts * 0.8); - ctx.stroke(); - } - - _drawCoffeeMachine(ctx, theme, ts, x, y) { - ctx.fillStyle = theme.furniture.coffee; - ctx.fillRect(x + ts * 0.15, y, ts * 0.7, ts * 0.8); - // 디스펜서 - ctx.fillStyle = theme.furniture.monitor; - ctx.fillRect(x + ts * 0.25, y + ts * 0.15, ts * 0.5, ts * 0.3); - // 커피 잔 - ctx.fillStyle = '#ffffff'; - ctx.fillRect(x + ts * 0.3, y + ts * 0.55, ts * 0.2, ts * 0.15); - // 스팀 - ctx.strokeStyle = 'rgba(255,255,255,0.3)'; - ctx.lineWidth = 1; - ctx.beginPath(); - ctx.moveTo(x + ts * 0.4, y + ts * 0.5); - ctx.quadraticCurveTo(x + ts * 0.45, y + ts * 0.35, x + ts * 0.4, y + ts * 0.2); - ctx.stroke(); - } - - _drawBookshelf(ctx, f, theme, ts, x, y) { - const h = (f.height || 3) * ts; - ctx.fillStyle = theme.furniture.shelf; - ctx.fillRect(x, y, ts * 0.9, h); - // 선반 및 책 - const bookColors = ['#aa4444', '#4444aa', '#44aa44', '#aaaa44', '#aa44aa', '#44aaaa']; - const shelfCount = f.height || 3; - for (let i = 0; i < shelfCount; i++) { - const sy = y + i * ts + ts * 0.1; - // 선반 판 - ctx.fillStyle = theme.furniture.shelf; - ctx.fillRect(x, sy + ts * 0.7, ts * 0.9, ts * 0.05); - // 책들 - for (let b = 0; b < 4; b++) { - ctx.fillStyle = bookColors[(i * 4 + b) % bookColors.length]; - ctx.fillRect(x + ts * 0.05 + b * ts * 0.2, sy + ts * 0.1, ts * 0.15, ts * 0.6); - } - } - } - - _drawPlant(ctx, theme, ts, x, y) { - // 화분 - ctx.fillStyle = theme.decor.pot; - ctx.fillRect(x + ts * 0.25, y + ts * 0.6, ts * 0.5, ts * 0.35); - ctx.fillRect(x + ts * 0.2, y + ts * 0.55, ts * 0.6, ts * 0.1); - // 잎 - ctx.fillStyle = theme.decor.plant; - ctx.beginPath(); - ctx.ellipse(x + ts * 0.5, y + ts * 0.35, ts * 0.3, ts * 0.25, 0, 0, Math.PI * 2); - ctx.fill(); - ctx.beginPath(); - ctx.ellipse(x + ts * 0.35, y + ts * 0.25, ts * 0.15, ts * 0.2, -0.3, 0, Math.PI * 2); - ctx.fill(); - ctx.beginPath(); - ctx.ellipse(x + ts * 0.65, y + ts * 0.25, ts * 0.15, ts * 0.2, 0.3, 0, Math.PI * 2); - ctx.fill(); - } - - _drawWaterCooler(ctx, theme, ts, x, y) { - // 본체 - ctx.fillStyle = theme.furniture.shelf; - ctx.fillRect(x + ts * 0.2, y + ts * 0.3, ts * 0.6, ts * 0.6); - // 물통 - ctx.fillStyle = 'rgba(100,180,255,0.5)'; - ctx.fillRect(x + ts * 0.3, y, ts * 0.4, ts * 0.35); - ctx.fillStyle = 'rgba(100,180,255,0.3)'; - ctx.beginPath(); - ctx.arc(x + ts * 0.5, y, ts * 0.2, 0, Math.PI * 2); - ctx.fill(); - } -} diff --git a/src/pages/agent-office/canvas/OfficeRenderer.js b/src/pages/agent-office/canvas/OfficeRenderer.js deleted file mode 100644 index a30ff1f..0000000 --- a/src/pages/agent-office/canvas/OfficeRenderer.js +++ /dev/null @@ -1,316 +0,0 @@ -// src/pages/agent-office/canvas/OfficeRenderer.js - -import mapData from '../assets/office-map.json'; -import { TileMap } from './TileMap.js'; -import { FurnitureRenderer } from './FurnitureRenderer.js'; -import { Pathfinder } from './Pathfinder.js'; -import { AgentSprite } from './AgentSprite.js'; -import { OverlayRenderer } from './OverlayRenderer.js'; -import { getTheme } from './themes.js'; - -const AGENT_META = { - stock: { displayName: '주식 트레이더', emoji: '📈', color: '#4488cc' }, - music: { displayName: '음악 프로듀서', emoji: '🎵', color: '#44aa88' }, - blog: { displayName: '블로그 마케터', emoji: '✍️', color: '#d97706' }, - realestate: { displayName: '청약 애널리스트', emoji: '🏢', color: '#c026d3' }, - lotto: { displayName: '로또 큐레이터', emoji: '🎱', color: '#ef4444' } -}; - -export class OfficeRenderer { - constructor(canvas) { - this.canvas = canvas; - this.ctx = canvas.getContext('2d'); - - // 맵 & 렌더러 - this.tileMap = new TileMap(mapData); - this.furnitureRenderer = new FurnitureRenderer(mapData.furniture, mapData.tileSize); - this.pathfinder = new Pathfinder(mapData.cols, mapData.rows); - this.overlayRenderer = new OverlayRenderer(); - - // blocked 타일 설정 - this.pathfinder.setWalls(mapData.floor); - this.pathfinder.setBlocked(mapData.blocked); - - // 테마 & 뷰포트 - this.theme = getTheme( - (typeof localStorage !== 'undefined' && localStorage.getItem('agent-office-theme')) || 'modern' - ); - this.zoom = 2; - this.panX = 0; - this.panY = 0; - this._isPanning = false; - this._panStart = { x: 0, y: 0 }; - - // 에이전트 - this.agents = new Map(); - this._initAgents(); - - // 게임 루프 - this._lastTime = 0; - this._animId = null; - this._lastDpr = window.devicePixelRatio || 1; - - // 드래그 감지 - this._mouseDownPos = { x: 0, y: 0 }; - this._wasDragging = false; - - // 이벤트 - this._setupInputHandlers(); - } - - _initAgents() { - for (const [id, meta] of Object.entries(AGENT_META)) { - const waypoint = mapData.waypoints[`desk_${id}`]; - if (!waypoint) continue; - const sprite = new AgentSprite(id, meta, waypoint.col, waypoint.row, this.pathfinder); - sprite.deskCol = waypoint.col; - sprite.deskRow = waypoint.row; - this.agents.set(id, sprite); - } - } - - /** 줌/팬/클릭 이벤트 핸들러 */ - _setupInputHandlers() { - // 마우스 휠 줌 - this.canvas.addEventListener('wheel', (e) => { - e.preventDefault(); - const oldZoom = this.zoom; - if (e.deltaY < 0) { - this.zoom = Math.min(this.zoom + 0.5, 4); - } else { - this.zoom = Math.max(this.zoom - 0.5, 1); - } - // 마우스 위치 기준 줌 - if (this.zoom !== oldZoom) { - const rect = this.canvas.getBoundingClientRect(); - const mx = e.clientX - rect.left; - const my = e.clientY - rect.top; - const ratio = this.zoom / oldZoom; - this.panX = mx - (mx - this.panX) * ratio; - this.panY = my - (my - this.panY) * ratio; - } - }, { passive: false }); - - // 마우스 드래그 패닝 - this.canvas.addEventListener('mousedown', (e) => { - if (e.button === 0) { - this._isPanning = true; - 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) => { - if (this._isPanning) { - this.panX = e.clientX - this._panStart.x; - 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._isPanning = false; - }; - window.addEventListener('mousemove', this._onMouseMove); - window.addEventListener('mouseup', this._onMouseUp); - - // 터치 (모바일) - let lastTouchDist = 0; - let lastTouchCenter = { x: 0, y: 0 }; - this.canvas.addEventListener('touchstart', (e) => { - if (e.touches.length === 1) { - this._isPanning = true; - this._panStart = { x: e.touches[0].clientX - this.panX, y: e.touches[0].clientY - this.panY }; - } else if (e.touches.length === 2) { - this._isPanning = false; - const dx = e.touches[0].clientX - e.touches[1].clientX; - const dy = e.touches[0].clientY - e.touches[1].clientY; - lastTouchDist = Math.hypot(dx, dy); - lastTouchCenter = { - x: (e.touches[0].clientX + e.touches[1].clientX) / 2, - y: (e.touches[0].clientY + e.touches[1].clientY) / 2 - }; - } - }, { passive: false }); - this.canvas.addEventListener('touchmove', (e) => { - e.preventDefault(); - if (e.touches.length === 1 && this._isPanning) { - this.panX = e.touches[0].clientX - this._panStart.x; - this.panY = e.touches[0].clientY - this._panStart.y; - } else if (e.touches.length === 2) { - const dx = e.touches[0].clientX - e.touches[1].clientX; - const dy = e.touches[0].clientY - e.touches[1].clientY; - const dist = Math.hypot(dx, dy); - const oldZoom = this.zoom; - this.zoom = Math.min(4, Math.max(1, this.zoom * (dist / lastTouchDist))); - lastTouchDist = dist; - const rect = this.canvas.getBoundingClientRect(); - const cx = lastTouchCenter.x - rect.left; - const cy = lastTouchCenter.y - rect.top; - const ratio = this.zoom / oldZoom; - this.panX = cx - (cx - this.panX) * ratio; - this.panY = cy - (cy - this.panY) * ratio; - } - }, { passive: false }); - this.canvas.addEventListener('touchend', () => { - this._isPanning = false; - }); - } - - /** 클릭 히트 테스트 — AgentOffice에서 호출 */ - hitTest(clientX, clientY) { - const rect = this.canvas.getBoundingClientRect(); - const screenX = clientX - rect.left; - const screenY = clientY - rect.top; - const { col, row } = this.tileMap.screenToTile(screenX, screenY, this.zoom, this.panX, this.panY); - - // 에이전트 히트 (역순, 최상위 우선) - for (const [id, sprite] of [...this.agents.entries()].reverse()) { - const dx = Math.abs(sprite.x - col); - const dy = Math.abs(sprite.y - row); - if (dx < 1.2 && dy < 1.5) { - return { type: 'agent', id }; - } - } - return { type: 'empty' }; - } - - /** 에이전트 상태 업데이트 (WebSocket에서 호출) */ - updateAgentState(agentId, state, detail) { - const sprite = this.agents.get(agentId); - if (!sprite) return; - sprite.onStateChange(state, detail, mapData.waypoints); - } - - /** 에이전트 알림 배지 설정 */ - setAgentNotification(agentId, count) { - const sprite = this.agents.get(agentId); - if (sprite) sprite.notificationCount = count; - } - - /** 테마 변경 */ - setTheme(themeName) { - this.theme = getTheme(themeName); - if (typeof localStorage !== 'undefined') { - localStorage.setItem('agent-office-theme', themeName); - } - } - - /** 줌 레벨 설정 */ - setZoom(level) { - const cx = this.canvas.width / 2; - const cy = this.canvas.height / 2; - const oldZoom = this.zoom; - this.zoom = Math.min(4, Math.max(1, level)); - const ratio = this.zoom / oldZoom; - this.panX = cx - (cx - this.panX) * ratio; - this.panY = cy - (cy - this.panY) * ratio; - } - - /** 카메라를 맵 중앙에 맞추기 */ - centerCamera() { - const mapW = mapData.cols * mapData.tileSize * this.zoom; - const mapH = mapData.rows * mapData.tileSize * this.zoom; - this.panX = (this.canvas.clientWidth - mapW) / 2; - this.panY = (this.canvas.clientHeight - mapH) / 2; - } - - /** 게임 루프 시작 */ - start() { - this.centerCamera(); - this._lastTime = performance.now(); - this._loop(this._lastTime); - } - - /** 게임 루프 중지 */ - stop() { - if (this._animId) { - cancelAnimationFrame(this._animId); - this._animId = null; - } - } - - _loop(timestamp) { - const dt = Math.min((timestamp - this._lastTime) / 1000, 0.1); // cap dt to avoid spiral - this._lastTime = timestamp; - - this._update(dt); - this._render(); - - this._animId = requestAnimationFrame((t) => this._loop(t)); - } - - _update(dt) { - for (const sprite of this.agents.values()) { - sprite.update(dt); - } - } - - _render() { - const ctx = this.ctx; - const dpr = window.devicePixelRatio || 1; - - // 캔버스 크기 조정 - const displayW = this.canvas.clientWidth; - const displayH = this.canvas.clientHeight; - if (this.canvas.width !== displayW * dpr || this.canvas.height !== displayH * dpr || this._lastDpr !== dpr) { - this.canvas.width = displayW * dpr; - this.canvas.height = displayH * dpr; - this._lastDpr = dpr; - } - // setTransform 방식으로 누적 없이 항상 올바른 변환 적용 - ctx.setTransform(dpr, 0, 0, dpr, 0, 0); - - ctx.imageSmoothingEnabled = false; - ctx.clearRect(0, 0, displayW, displayH); - - // 배경 - ctx.fillStyle = this.theme.wall.color; - ctx.fillRect(0, 0, displayW, displayH); - - // 1. 타일맵 (바닥 + 벽) - this.tileMap.render(ctx, this.theme, this.zoom, this.panX, this.panY); - - // 2. Y-sorted: 가구 + 에이전트 - const renderables = []; - - // 가구 - const furnitureItems = this.furnitureRenderer.getRenderables(this.theme, this.zoom, this.panX, this.panY); - renderables.push(...furnitureItems); - - // 에이전트 - for (const sprite of this.agents.values()) { - renderables.push({ - zY: sprite.y, - draw: (ctx2) => sprite.draw(ctx2, this.zoom, this.panX, this.panY, mapData.tileSize) - }); - } - - // Y좌표 정렬 - renderables.sort((a, b) => a.zY - b.zY); - for (const item of renderables) { - item.draw(ctx); - } - - // 3. 오버레이 (항상 최상위) - for (const sprite of this.agents.values()) { - this.overlayRenderer.draw(ctx, sprite, this.theme, this.zoom, this.panX, this.panY, mapData.tileSize); - } - } - - /** 드래그 여부 반환 (클릭 이벤트 필터링용) */ - wasDragging() { return this._wasDragging; } - - /** 리사이즈 처리 */ - resize() { - // 다음 프레임에서 자동 조정됨 (_render에서 크기 체크) - } - - destroy() { - this.stop(); - // window 이벤트 리스너 정리 - if (this._onMouseMove) window.removeEventListener('mousemove', this._onMouseMove); - if (this._onMouseUp) window.removeEventListener('mouseup', this._onMouseUp); - } -} diff --git a/src/pages/agent-office/canvas/OverlayRenderer.js b/src/pages/agent-office/canvas/OverlayRenderer.js deleted file mode 100644 index dc92675..0000000 --- a/src/pages/agent-office/canvas/OverlayRenderer.js +++ /dev/null @@ -1,122 +0,0 @@ -// src/pages/agent-office/canvas/OverlayRenderer.js - -/** - * 캔버스 위 오버레이 렌더링: - * - 이름 라벨 (항상) - * - 상태 배지 (항상) - * - 말풍선 (waiting 상태에서만) - * - 알림 배지 (notification > 0 일 때) - */ - -const STATE_BADGE = { - idle: { text: 'idle', bg: '#374151', fg: '#9ca3af' }, - working: { text: 'working', bg: '#1e3a5f', fg: '#60a5fa' }, - waiting: { text: 'waiting', bg: '#92400e', fg: '#fbbf24' }, - reporting: { text: 'reporting', bg: '#1e3a5f', fg: '#60a5fa' }, - break: { text: 'break', bg: '#065f46', fg: '#34d399' } -}; - -export class OverlayRenderer { - constructor() { - this._bubbleAlpha = new Map(); // agentId → alpha (fade in/out) - } - - draw(ctx, sprite, theme, zoom, panX, panY, tileSize) { - const ts = tileSize * zoom; - const centerX = sprite.x * ts + panX + ts / 2; - const topY = sprite.y * ts + panY - ts * 0.3; - - const fontSize = Math.max(10, 11 * zoom / 2); - const smallFontSize = Math.max(8, 9 * zoom / 2); - - // 1. 이름 라벨 - ctx.font = `bold ${fontSize}px 'Courier New', monospace`; - ctx.textAlign = 'center'; - ctx.fillStyle = sprite.meta.color; - ctx.fillText(sprite.meta.displayName, centerX, topY + ts * 1.85); - - // 2. 상태 배지 - const badge = STATE_BADGE[sprite.state] || STATE_BADGE.idle; - const badgeText = badge.text; - ctx.font = `${smallFontSize}px 'Courier New', monospace`; - const badgeW = ctx.measureText(badgeText).width + 8; - const badgeH = smallFontSize + 4; - const badgeX = centerX - badgeW / 2; - const badgeY = topY + ts * 1.9; - - ctx.fillStyle = badge.bg; - this._roundRect(ctx, badgeX, badgeY, badgeW, badgeH, 3); - ctx.fill(); - ctx.fillStyle = badge.fg; - ctx.textAlign = 'center'; - ctx.fillText(badgeText, centerX, badgeY + badgeH - 3); - - // 3. 말풍선 (waiting 상태에서만) - if (sprite.state === 'waiting') { - this._drawBubble(ctx, sprite, centerX, topY - ts * 0.2, zoom); - } - - // 4. 알림 배지 - if (sprite.notificationCount > 0) { - this._drawNotificationBadge(ctx, centerX + ts * 0.5, topY + ts * 0.2, sprite.notificationCount, zoom); - } - } - - _drawBubble(ctx, sprite, x, y, zoom) { - const text = '승인 대기!'; - const fontSize = Math.max(10, 11 * zoom / 2); - ctx.font = `bold ${fontSize}px 'Courier New', monospace`; - const tw = ctx.measureText(text).width; - const pw = tw + 16; - const ph = fontSize + 12; - const px = x - pw / 2; - const py = y - ph; - - // 말풍선 배경 - ctx.fillStyle = '#fbbf24'; - this._roundRect(ctx, px, py, pw, ph, 6); - ctx.fill(); - - // 꼬리 삼각형 - ctx.beginPath(); - ctx.moveTo(x - 5, py + ph); - ctx.lineTo(x + 5, py + ph); - ctx.lineTo(x, py + ph + 6); - ctx.closePath(); - ctx.fill(); - - // 텍스트 - ctx.fillStyle = '#000000'; - ctx.textAlign = 'center'; - ctx.fillText(text, x, py + ph - 5); - } - - _drawNotificationBadge(ctx, x, y, count, zoom) { - const r = Math.max(7, 8 * zoom / 2); - ctx.fillStyle = '#ef4444'; - ctx.beginPath(); - ctx.arc(x, y, r, 0, Math.PI * 2); - ctx.fill(); - - ctx.fillStyle = '#ffffff'; - ctx.font = `bold ${r}px sans-serif`; - ctx.textAlign = 'center'; - ctx.textBaseline = 'middle'; - ctx.fillText(count > 9 ? '9+' : String(count), x, y); - ctx.textBaseline = 'alphabetic'; - } - - _roundRect(ctx, x, y, w, h, r) { - ctx.beginPath(); - ctx.moveTo(x + r, y); - ctx.lineTo(x + w - r, y); - ctx.quadraticCurveTo(x + w, y, x + w, y + r); - ctx.lineTo(x + w, y + h - r); - ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h); - ctx.lineTo(x + r, y + h); - ctx.quadraticCurveTo(x, y + h, x, y + h - r); - ctx.lineTo(x, y + r); - ctx.quadraticCurveTo(x, y, x + r, y); - ctx.closePath(); - } -} diff --git a/src/pages/agent-office/canvas/Pathfinder.js b/src/pages/agent-office/canvas/Pathfinder.js deleted file mode 100644 index 94628fe..0000000 --- a/src/pages/agent-office/canvas/Pathfinder.js +++ /dev/null @@ -1,112 +0,0 @@ -// 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)]; - } -} diff --git a/src/pages/agent-office/canvas/ProceduralSprite.js b/src/pages/agent-office/canvas/ProceduralSprite.js deleted file mode 100644 index 4492ec5..0000000 --- a/src/pages/agent-office/canvas/ProceduralSprite.js +++ /dev/null @@ -1,164 +0,0 @@ -// src/pages/agent-office/canvas/ProceduralSprite.js - -/** - * 프로시저럴 픽셀 캐릭터 렌더러 (16×32px 기본 해상도) - * Phase 1: 코드로 캐릭터를 그림 - * Phase 2: SpriteLoader가 PNG 스프라이트로 대체 - */ - -const AGENT_COLORS = { - stock: { body: '#4488cc', hair: '#2255aa', accent: '#66aaee' }, - music: { body: '#44aa88', hair: '#228866', accent: '#66ccaa' }, - blog: { body: '#d97706', hair: '#b45e04', accent: '#f59e0b' }, - realestate: { body: '#c026d3', hair: '#9b1dab', accent: '#e044f0' }, - lotto: { body: '#ef4444', hair: '#cc2222', accent: '#ff6666' } -}; - -/** 애니메이션 프레임 설정 */ -const ANIM_CONFIG = { - idle: { frames: 2, speed: 0.8 }, - walk: { frames: 4, speed: 0.15, cycle: [0, 1, 2, 1] }, - type: { frames: 2, speed: 0.3 }, - wait: { frames: 2, speed: 0.5 }, - break_anim:{ frames: 2, speed: 1.0 } -}; - -export class ProceduralSprite { - /** - * 캐릭터 1프레임 렌더링 - * @param {CanvasRenderingContext2D} ctx - * @param {string} agentId - * @param {string} state - idle|walk|type|wait|break_anim - * @param {string} direction - down|up|right|left - * @param {number} frame - 현재 애니메이션 프레임 인덱스 - * @param {number} x - 캔버스 X 좌표 (캐릭터 중앙 하단) - * @param {number} y - 캔버스 Y 좌표 (캐릭터 중앙 하단) - * @param {number} scale - 렌더링 스케일 - */ - static draw(ctx, agentId, state, direction, frame, x, y, scale) { - const colors = AGENT_COLORS[agentId] || AGENT_COLORS.stock; - const px = scale; // 1 pixel = scale 크기 - const w = 16 * px; - const h = 32 * px; - const bx = x - w / 2; // 좌상단 기준 - const by = y - h; - - ctx.save(); - - // 좌우 반전 (left = right 플립) - if (direction === 'left') { - ctx.translate(x, 0); - ctx.scale(-1, 1); - ctx.translate(-x, 0); - } - - // 그림자 - ctx.fillStyle = 'rgba(0,0,0,0.2)'; - ctx.beginPath(); - ctx.ellipse(x, y, w * 0.35, px * 2, 0, 0, Math.PI * 2); - ctx.fill(); - - // 상태별 오프셋 - let bodyOffsetY = 0; - let legSpread = 0; - let armAngle = 0; - - if (state === 'walk') { - const walkFrame = ANIM_CONFIG.walk.cycle[frame % 4]; - legSpread = (walkFrame - 1) * px * 2; - bodyOffsetY = walkFrame === 1 ? -px : 0; - } else if (state === 'type') { - armAngle = frame % 2 === 0 ? 1 : -1; - bodyOffsetY = frame % 2 === 0 ? 0 : -px * 0.5; - } else if (state === 'wait') { - bodyOffsetY = Math.sin(frame * Math.PI) * px; - } else if (state === 'idle') { - bodyOffsetY = frame % 2 === 0 ? 0 : -px * 0.5; - } else if (state === 'break_anim') { - bodyOffsetY = frame % 2 === 0 ? 0 : px * 0.5; // 졸기 - } - - const by2 = by + bodyOffsetY; - - // 다리 - ctx.fillStyle = '#2a2a3e'; - // 왼쪽 다리 - ctx.fillRect(bx + px * 4 - legSpread, by2 + px * 24, px * 3, px * 8); - // 오른쪽 다리 - ctx.fillRect(bx + px * 9 + legSpread, by2 + px * 24, px * 3, px * 8); - // 신발 - ctx.fillStyle = '#333'; - ctx.fillRect(bx + px * 3 - legSpread, by2 + px * 30, px * 5, px * 2); - ctx.fillRect(bx + px * 8 + legSpread, by2 + px * 30, px * 5, px * 2); - - // 몸통 - ctx.fillStyle = colors.body; - ctx.fillRect(bx + px * 3, by2 + px * 12, px * 10, px * 13); - - // 팔 - if (state === 'type') { - // 타이핑: 팔 앞으로 뻗음 - ctx.fillStyle = colors.body; - ctx.fillRect(bx + px * 1, by2 + px * 13, px * 3, px * 8 + armAngle * px); - ctx.fillRect(bx + px * 12, by2 + px * 13, px * 3, px * 8 - armAngle * px); - // 손 - ctx.fillStyle = '#ffcc99'; - ctx.fillRect(bx + px * 1, by2 + px * 20 + armAngle * px, px * 3, px * 3); - ctx.fillRect(bx + px * 12, by2 + px * 20 - armAngle * px, px * 3, px * 3); - } else { - // 기본 팔 - ctx.fillStyle = colors.body; - ctx.fillRect(bx + px * 1, by2 + px * 13, px * 3, px * 10); - ctx.fillRect(bx + px * 12, by2 + px * 13, px * 3, px * 10); - // 손 - ctx.fillStyle = '#ffcc99'; - ctx.fillRect(bx + px * 1, by2 + px * 22, px * 3, px * 3); - ctx.fillRect(bx + px * 12, by2 + px * 22, px * 3, px * 3); - } - - // 머리 - ctx.fillStyle = '#ffcc99'; // 피부색 - ctx.fillRect(bx + px * 4, by2 + px * 2, px * 8, px * 10); - - // 머리카락 - ctx.fillStyle = colors.hair; - ctx.fillRect(bx + px * 3, by2 + px * 1, px * 10, px * 4); - if (direction === 'down' || direction === 'left' || direction === 'right') { - // 앞머리 - ctx.fillRect(bx + px * 4, by2 + px * 3, px * 2, px * 2); - } - - // 눈 - if (direction !== 'up') { - ctx.fillStyle = '#222'; - if (state === 'break_anim' && frame % 2 === 1) { - // 졸기: 눈 감음 - ctx.fillRect(bx + px * 5, by2 + px * 6, px * 2, px); - ctx.fillRect(bx + px * 9, by2 + px * 6, px * 2, px); - } else { - ctx.fillRect(bx + px * 5, by2 + px * 5, px * 2, px * 2); - ctx.fillRect(bx + px * 9, by2 + px * 5, px * 2, px * 2); - } - } - - // break 소품: 커피잔 - if (state === 'break_anim') { - ctx.fillStyle = '#ffffff'; - ctx.fillRect(bx + px * 14, by2 + px * 16, px * 3, px * 4); - ctx.fillStyle = '#8B4513'; - ctx.fillRect(bx + px * 14.5, by2 + px * 16.5, px * 2, px * 2); - } - - ctx.restore(); - } - - static getAnimConfig(state) { - const mapped = state === 'working' ? 'type' - : state === 'waiting' ? 'wait' - : state === 'reporting' ? 'type' - : state === 'break' ? 'break_anim' - : state === 'walk' ? 'walk' - : 'idle'; - return { ...(ANIM_CONFIG[mapped] || ANIM_CONFIG.idle), mapped }; - } -} diff --git a/src/pages/agent-office/canvas/SpriteLoader.js b/src/pages/agent-office/canvas/SpriteLoader.js deleted file mode 100644 index 64e72d8..0000000 --- a/src/pages/agent-office/canvas/SpriteLoader.js +++ /dev/null @@ -1,77 +0,0 @@ -// src/pages/agent-office/canvas/SpriteLoader.js - -import { ProceduralSprite } from './ProceduralSprite.js'; - -/** - * 스프라이트 로더 — PNG 스프라이트시트가 있으면 사용, 없으면 프로시저럴 폴백 - * - * 스프라이트시트 규격 (Phase 2): - * - 프레임 크기: 16×32px - * - 행: 방향 (0=down, 1=up, 2=right) - * - 열: 상태별 프레임 (idle 2 | walk 4 | type 2 | wait 2 | break 2 = 12열) - */ -export class SpriteLoader { - constructor() { - this.sprites = new Map(); // agentId → { image: Image, loaded: boolean } - } - - /** PNG 스프라이트시트 로드 시도 */ - async tryLoad(agentId, url) { - return new Promise((resolve) => { - const img = new Image(); - img.onload = () => { - this.sprites.set(agentId, { image: img, loaded: true }); - resolve(true); - }; - img.onerror = () => { - resolve(false); // 폴백 사용 - }; - img.src = url; - }); - } - - hasSprite(agentId) { - return this.sprites.has(agentId) && this.sprites.get(agentId).loaded; - } - - /** - * 에이전트 1프레임 그리기 (스프라이트 또는 프로시저럴) - */ - draw(ctx, agentId, state, direction, frame, x, y, scale) { - if (this.hasSprite(agentId)) { - this._drawFromSheet(ctx, agentId, state, direction, frame, x, y, scale); - } else { - ProceduralSprite.draw(ctx, agentId, state, direction, frame, x, y, scale); - } - } - - _drawFromSheet(ctx, agentId, state, direction, frame, x, y, scale) { - const { image } = this.sprites.get(agentId); - const frameW = 16; - const frameH = 32; - - // 방향 → 행 - const dirRow = direction === 'up' ? 1 : direction === 'right' || direction === 'left' ? 2 : 0; - - // 상태 → 열 오프셋 - const stateOffsets = { idle: 0, walk: 2, type: 6, wait: 8, break_anim: 10 }; - const mappedState = state === 'working' ? 'type' : state === 'waiting' ? 'wait' - : state === 'reporting' ? 'type' : state === 'break' ? 'break_anim' - : state === 'walk' ? 'walk' : 'idle'; - const colOffset = stateOffsets[mappedState] || 0; - - const srcX = (colOffset + frame) * frameW; - const srcY = dirRow * frameH; - const destW = frameW * scale; - const destH = frameH * scale; - - ctx.save(); - if (direction === 'left') { - ctx.translate(x, 0); - ctx.scale(-1, 1); - ctx.translate(-x, 0); - } - ctx.drawImage(image, srcX, srcY, frameW, frameH, x - destW / 2, y - destH, destW, destH); - ctx.restore(); - } -} diff --git a/src/pages/agent-office/canvas/TileMap.js b/src/pages/agent-office/canvas/TileMap.js deleted file mode 100644 index 409fbc3..0000000 --- a/src/pages/agent-office/canvas/TileMap.js +++ /dev/null @@ -1,80 +0,0 @@ -// src/pages/agent-office/canvas/TileMap.js - -/** - * 타일맵 렌더러 — 바닥, 벽, 그리드를 테마 팔레트로 렌더링 - * 가구는 FurnitureRenderer가 별도 처리 - */ -export class TileMap { - constructor(mapData) { - this.cols = mapData.cols; - this.rows = mapData.rows; - this.tileSize = mapData.tileSize; - this.floor = mapData.floor; - this.tileTypes = mapData.tileTypes; - } - - /** - * 바닥 + 벽 렌더링 - * @param {CanvasRenderingContext2D} ctx - * @param {object} theme - themes.js 에서 가져온 테마 객체 - * @param {number} scale - 줌 레벨 - * @param {number} offsetX - 패닝 X 오프셋 - * @param {number} offsetY - 패닝 Y 오프셋 - */ - render(ctx, theme, scale, offsetX, offsetY) { - const ts = this.tileSize * scale; - - for (let r = 0; r < this.rows; r++) { - for (let c = 0; c < this.cols; c++) { - const tileType = this.floor[r][c]; - const x = c * ts + offsetX; - const y = r * ts + offsetY; - - // 화면 밖이면 스킵 (CSS 공간 기준 — DPR 변환 적용된 좌표계) - if (x + ts < 0 || y + ts < 0 || x > ctx.canvas.clientWidth || y > ctx.canvas.clientHeight) continue; - - if (tileType === 0) { - // 벽 - ctx.fillStyle = theme.wall.color; - ctx.fillRect(x, y, ts, ts); - // 벽 하단 경계선 - ctx.fillStyle = theme.wall.border; - ctx.fillRect(x, y + ts - scale, ts, scale); - } else { - // 바닥 - const isBreak = this.tileTypes[String(tileType)] === 'floor_break'; - ctx.fillStyle = isBreak ? theme.floor.color2 : theme.floor.color1; - ctx.fillRect(x, y, ts, ts); - - // 체커보드 패턴 - if ((r + c) % 2 === 0) { - ctx.fillStyle = theme.floor.grid; - ctx.fillRect(x, y, ts, ts); - } - - // 그리드 선 - ctx.strokeStyle = theme.floor.grid; - ctx.lineWidth = scale * 0.5; - ctx.strokeRect(x, y, ts, ts); - } - } - } - } - - /** 화면 좌표 → 타일 좌표 변환 */ - screenToTile(screenX, screenY, scale, offsetX, offsetY) { - const ts = this.tileSize * scale; - const col = Math.floor((screenX - offsetX) / ts); - const row = Math.floor((screenY - offsetY) / ts); - return { col, row }; - } - - /** 타일 좌표 → 화면 좌표 (타일 중앙) */ - tileToScreen(col, row, scale, offsetX, offsetY) { - const ts = this.tileSize * scale; - return { - x: col * ts + offsetX + ts / 2, - y: row * ts + offsetY + ts / 2 - }; - } -} diff --git a/src/pages/agent-office/canvas/themes.js b/src/pages/agent-office/canvas/themes.js deleted file mode 100644 index d97861f..0000000 --- a/src/pages/agent-office/canvas/themes.js +++ /dev/null @@ -1,42 +0,0 @@ -// src/pages/agent-office/canvas/themes.js - -export const THEMES = { - modern: { - name: 'Modern', - wall: { color: '#1a1a2e', border: '#333', accent: '#8b5cf6' }, - floor: { color1: '#2a2a3e', color2: '#323248', grid: 'rgba(255,255,255,0.03)' }, - furniture: { desk: '#3a3a5a', chair: '#4c1d95', monitor: '#5555aa', monitorScreen: '#1a3a5a', shelf: '#2a2a4e', table: '#3a3a5a', sofa: '#2a2a4e', coffee: '#3a3a2a' }, - decor: { plant: '#22c55e', pot: '#4a3a2a', lamp: '#fbbf24', ledStrip: '#8b5cf6' }, - lighting: { ambient: 'rgba(139,92,246,0.05)', glow: 'rgba(139,92,246,0.15)' }, - text: { primary: '#ffffff', secondary: '#aaaaaa', label: '#888888' }, - ui: { panelBg: '#111111', headerBg: '#1a1a2e', border: '#333333', accent: '#8b5cf6' } - }, - retro: { - name: 'Retro', - wall: { color: '#2a1a0a', border: '#6a4a2a', accent: '#cc8844' }, - floor: { color1: '#4a3a1a', color2: '#3a2a10', grid: 'rgba(255,255,255,0.02)' }, - furniture: { desk: '#6a4a1a', chair: '#8a5a2a', monitor: '#555555', monitorScreen: '#1a3a1a', shelf: '#5a3a1a', table: '#5a4a2a', sofa: '#5a3a2a', coffee: '#4a3a1a' }, - decor: { plant: '#44aa44', pot: '#6a4a2a', lamp: '#ffcc66', brick: '#8a5a2a' }, - lighting: { ambient: 'rgba(255,200,100,0.05)', glow: 'rgba(255,200,100,0.2)' }, - text: { primary: '#ffe0b0', secondary: '#aa8866', label: '#887766' }, - ui: { panelBg: '#1a1008', headerBg: '#2a1a0a', border: '#4a3a2a', accent: '#cc8844' } - }, - minimal: { - name: 'Minimal', - wall: { color: '#fafafa', border: '#dddddd', accent: '#3b82f6' }, - floor: { color1: '#e8e8e8', color2: '#f0f0f0', grid: 'rgba(0,0,0,0.04)' }, - furniture: { desk: '#ffffff', chair: '#e0e0e0', monitor: '#333333', monitorScreen: '#e0e8f0', shelf: '#f5f5f5', table: '#ffffff', sofa: '#e8e8e8', coffee: '#f0f0f0' }, - decor: { plant: '#86efac', pot: '#ffffff', lamp: '#fbbf24', window: '#e0f0ff' }, - lighting: { ambient: 'rgba(59,130,246,0.03)', glow: 'rgba(255,255,255,0.1)' }, - text: { primary: '#1a1a1a', secondary: '#666666', label: '#999999' }, - ui: { panelBg: '#ffffff', headerBg: '#fafafa', border: '#e0e0e0', accent: '#3b82f6' } - } -}; - -export function getTheme(name) { - return THEMES[name] || THEMES.modern; -} - -export function getThemeNames() { - return Object.entries(THEMES).map(([key, val]) => ({ key, name: val.name })); -} diff --git a/src/pages/agent-office/components/TopBar.jsx b/src/pages/agent-office/components/TopBar.jsx index 6d0514e..b2db341 100644 --- a/src/pages/agent-office/components/TopBar.jsx +++ b/src/pages/agent-office/components/TopBar.jsx @@ -1,9 +1,5 @@ // src/pages/agent-office/components/TopBar.jsx -import { getThemeNames } from '../canvas/themes.js'; - -export default function TopBar({ connected, theme, onThemeChange, zoom, onZoomChange }) { - const themes = getThemeNames(); - +export default function TopBar({ connected }) { return (
@@ -12,22 +8,6 @@ export default function TopBar({ connected, theme, onThemeChange, zoom, onZoomCh ● {connected ? 'Connected' : 'Disconnected'}
-
- -
- - {zoom}x - -
-
); } diff --git a/src/pages/agent-office/hooks/useOfficeCanvas.js b/src/pages/agent-office/hooks/useOfficeCanvas.js deleted file mode 100644 index 8d4d4c8..0000000 --- a/src/pages/agent-office/hooks/useOfficeCanvas.js +++ /dev/null @@ -1,64 +0,0 @@ -// src/pages/agent-office/hooks/useOfficeCanvas.js -import { useRef, useEffect, useCallback } from 'react'; -import { OfficeRenderer } from '../canvas/OfficeRenderer.js'; - -export function useOfficeCanvas() { - const canvasRef = useRef(null); - const rendererRef = useRef(null); - - useEffect(() => { - if (!canvasRef.current) return; - - const renderer = new OfficeRenderer(canvasRef.current); - rendererRef.current = renderer; - renderer.start(); - - const handleResize = () => renderer.resize(); - window.addEventListener('resize', handleResize); - - return () => { - window.removeEventListener('resize', handleResize); - renderer.destroy(); - rendererRef.current = null; - }; - }, []); - - const updateAgentState = useCallback((agentId, state, detail) => { - rendererRef.current?.updateAgentState(agentId, state, detail); - }, []); - - const setAgentNotification = useCallback((agentId, count) => { - rendererRef.current?.setAgentNotification(agentId, count); - }, []); - - const setTheme = useCallback((themeName) => { - rendererRef.current?.setTheme(themeName); - }, []); - - const setZoom = useCallback((level) => { - rendererRef.current?.setZoom(level); - }, []); - - const hitTest = useCallback((clientX, clientY) => { - return rendererRef.current?.hitTest(clientX, clientY) || { type: 'empty' }; - }, []); - - const getZoom = useCallback(() => { - return rendererRef.current?.zoom || 2; - }, []); - - const wasDragging = useCallback(() => { - return rendererRef.current?.wasDragging?.() || false; - }, []); - - return { - canvasRef, - updateAgentState, - setAgentNotification, - setTheme, - setZoom, - hitTest, - getZoom, - wasDragging - }; -}