import { drawTileMap } from './TileMap'; import { AgentSprite } from './AgentSprite'; import { getCharLabel, drawNotificationBadge } from './SpriteSheet'; const STATUS_ICONS = { idle: null, working: null, waiting: '❗', reporting: '📋', break: '☕', }; export class OfficeRenderer { constructor(canvas, mapData) { this.canvas = canvas; this.ctx = canvas.getContext('2d'); this.mapData = mapData; this.renderInfo = null; this.agents = {}; this._animId = null; this._onClick = null; this._onCeoClick = null; this._ceoDocBadge = 0; const agentIds = ['stock', 'music', 'blog', 'realestate']; for (const id of agentIds) { this.agents[id] = new AgentSprite(id, mapData.waypoints); } } start() { this._loop = this._loop.bind(this); this._animId = requestAnimationFrame(this._loop); } stop() { if (this._animId) { cancelAnimationFrame(this._animId); this._animId = null; } } resize(width, height) { this.canvas.width = width; this.canvas.height = height; } setOnClick(handler) { this._onClick = handler; } handleClick(canvasX, canvasY) { if (!this.renderInfo) return null; for (const [id, sprite] of Object.entries(this.agents)) { if (sprite.hitTest(canvasX, canvasY, this.renderInfo)) { if (this._onClick) this._onClick(id); return id; } } // CEO desk click detection const ceo = this.mapData.waypoints.ceo_desk; if (ceo) { const { scale, offsetX, offsetY, tileSize } = this.renderInfo; const cx = offsetX + ceo.x * tileSize * scale; const cy = offsetY + ceo.y * tileSize * scale; const hitW = 5 * tileSize * scale; const hitH = 2 * tileSize * scale; if (canvasX >= cx - tileSize * scale && canvasY >= cy - tileSize * scale && canvasX <= cx + hitW && canvasY <= cy + hitH) { if (this._onCeoClick) this._onCeoClick(); return 'ceo_desk'; } } return null; } updateAgentState(agentId, state, detail) { const sprite = this.agents[agentId]; if (sprite) { sprite.setState(state, detail); if (state === 'idle' || state === 'working' || state === 'waiting') { sprite.moveToDesk(); } } } moveAgent(agentId, target) { const sprite = this.agents[agentId]; if (sprite) { sprite.moveTo(target); } } setOnCeoClick(handler) { this._onCeoClick = handler; } setCeoDocBadge(count) { this._ceoDocBadge = count; } setAgentNotification(agentId, count) { const sprite = this.agents[agentId]; if (sprite) sprite.setNotification(count); } _loop(timestamp) { const { ctx, canvas, mapData } = this; ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.fillStyle = '#1a1a2e'; ctx.fillRect(0, 0, canvas.width, canvas.height); this.renderInfo = drawTileMap(ctx, mapData, canvas.width, canvas.height); const now = Date.now(); for (const sprite of Object.values(this.agents)) { sprite.update(now); sprite.draw(ctx, this.renderInfo); } for (const [id, sprite] of Object.entries(this.agents)) { this._drawOverlay(ctx, sprite, id); } // CEO desk document icon this._drawCeoDoc(ctx); this._animId = requestAnimationFrame(this._loop); } _drawOverlay(ctx, sprite, agentId) { if (!this.renderInfo) return; const { scale, offsetX, offsetY, tileSize } = this.renderInfo; const cx = offsetX + sprite.x * tileSize * scale + (tileSize * scale) / 2; const cy = offsetY + sprite.y * tileSize * scale - 10 * scale; const icon = STATUS_ICONS[sprite.state]; if (icon) { ctx.font = `${14 * scale}px serif`; ctx.textAlign = 'center'; ctx.fillText(icon, cx, cy - 15 * scale); } // Notification badge (separate from status icon) if (sprite.notificationCount > 0) { drawNotificationBadge(ctx, cx, cy - 15 * scale, sprite.notificationCount, scale * 1.5); } ctx.fillStyle = 'rgba(255,255,255,0.7)'; ctx.font = `${8 * scale}px monospace`; ctx.textAlign = 'center'; ctx.fillText(getCharLabel(agentId), cx, cy + 30 * scale + 30); if (sprite.detail && (sprite.state === 'working' || sprite.state === 'waiting')) { const bubbleY = cy - 25 * scale; ctx.fillStyle = 'rgba(0,0,0,0.7)'; const textW = ctx.measureText(sprite.detail).width; ctx.fillRect(cx - textW / 2 - 6, bubbleY - 10, textW + 12, 16); ctx.fillStyle = '#fff'; ctx.font = `${7 * scale}px monospace`; ctx.fillText(sprite.detail, cx, bubbleY); } } _drawCeoDoc(ctx) { if (!this.renderInfo) return; const ceo = this.mapData.waypoints.ceo_desk; if (!ceo) return; const { scale, offsetX, offsetY, tileSize } = this.renderInfo; const dx = offsetX + (ceo.x - 1) * tileSize * scale; const dy = offsetY + (ceo.y - 1) * tileSize * scale; const docW = 12 * scale; const docH = 16 * scale; // Paper ctx.fillStyle = '#e8e0d0'; ctx.fillRect(dx, dy, docW, docH); // Lines on paper ctx.fillStyle = '#bbb'; for (let i = 0; i < 4; i++) { ctx.fillRect(dx + 2 * scale, dy + (3 + i * 3) * scale, 8 * scale, 1); } // Folded corner ctx.fillStyle = '#d0c8b8'; ctx.beginPath(); ctx.moveTo(dx + docW - 3 * scale, dy); ctx.lineTo(dx + docW, dy + 3 * scale); ctx.lineTo(dx + docW - 3 * scale, dy + 3 * scale); ctx.fill(); // Badge on document if (this._ceoDocBadge > 0) { const bx = dx + docW; const by = dy; const r = 4 * scale; ctx.beginPath(); ctx.arc(bx, by, r, 0, Math.PI * 2); ctx.fillStyle = '#f43f5e'; ctx.fill(); ctx.fillStyle = '#fff'; ctx.font = `bold ${5 * scale}px monospace`; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(this._ceoDocBadge > 9 ? '9+' : String(this._ceoDocBadge), bx, by); } } }