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
- };
-}