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