From d4279f2e3b2e3ee62411a1f0b0f067b677dd912b Mon Sep 17 00:00:00 2001 From: gahusb Date: Mon, 27 Apr 2026 08:29:02 +0900 Subject: [PATCH] refactor(agent-office): rewrite OfficeRenderer with game loop, zoom/pan, Y-sorting --- .../agent-office/canvas/OfficeRenderer.js | 533 ++++++++++++------ .../agent-office/canvas/OverlayRenderer.js | 59 ++ 2 files changed, 414 insertions(+), 178 deletions(-) create mode 100644 src/pages/agent-office/canvas/OverlayRenderer.js diff --git a/src/pages/agent-office/canvas/OfficeRenderer.js b/src/pages/agent-office/canvas/OfficeRenderer.js index c98adc4..7cc0370 100644 --- a/src/pages/agent-office/canvas/OfficeRenderer.js +++ b/src/pages/agent-office/canvas/OfficeRenderer.js @@ -1,38 +1,300 @@ -import { drawTileMap } from './TileMap'; -import { AgentSprite } from './AgentSprite'; -import { getCharLabel, drawNotificationBadge } from './SpriteSheet'; +// src/pages/agent-office/canvas/OfficeRenderer.js -const STATUS_ICONS = { - idle: null, - working: null, - waiting: 'โ—', - reporting: '๐Ÿ“‹', - break: 'โ˜•', +import mapData from '../assets/office-map.json'; +import { TileMap } from './TileMap.js'; +import { FurnitureRenderer } from './FurnitureRenderer.js'; +import { Pathfinder } from './Pathfinder.js'; +import { OverlayRenderer } from './OverlayRenderer.js'; +import { getTheme } from './themes.js'; + +// AgentSprite is implemented in Phase 2. +// Until then, use a minimal stub that satisfies the interface expected by OfficeRenderer. +let AgentSprite; +try { + // Dynamic import fallback โ€” wrapping in try/catch for bundler compatibility + AgentSprite = null; // will be set below +} catch (_) {} + +/** Phase 2 stub for AgentSprite */ +class _AgentSpriteStub { + constructor(id, meta, col, row /*, pathfinder */) { + this.id = id; + this.meta = meta; + this.x = col; + this.y = row; + this.deskCol = col; + this.deskRow = row; + this.state = 'idle'; + this.detail = ''; + this.notificationCount = 0; + this._animTimer = 0; + this._bobOffset = 0; + } + + onStateChange(state, detail /*, waypoints */) { + this.state = state; + this.detail = detail || ''; + } + + update(dt) { + this._animTimer += dt; + this._bobOffset = Math.sin(this._animTimer * 2) * 2; + } + + draw(ctx, scale, offsetX, offsetY, tileSize) { + const ts = tileSize * scale; + const cx = this.x * ts + offsetX + ts / 2; + const cy = this.y * ts + offsetY + ts / 2 + this._bobOffset * scale; + const r = ts * 0.35; + + // Simple circle avatar + ctx.save(); + ctx.beginPath(); + ctx.arc(cx, cy, r, 0, Math.PI * 2); + ctx.fillStyle = this.meta.color || '#8b5cf6'; + ctx.fill(); + ctx.strokeStyle = 'rgba(255,255,255,0.4)'; + ctx.lineWidth = scale; + ctx.stroke(); + + // Emoji label + ctx.font = `${Math.max(10, ts * 0.4)}px serif`; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText(this.meta.emoji || '?', cx, cy); + + // State indicator dot + const stateColors = { + idle: '#6b7280', + working: '#22c55e', + waiting: '#f59e0b', + reporting: '#3b82f6', + break: '#f97316' + }; + const dotColor = stateColors[this.state] || '#6b7280'; + ctx.beginPath(); + ctx.arc(cx + r * 0.7, cy - r * 0.7, r * 0.25, 0, Math.PI * 2); + ctx.fillStyle = dotColor; + ctx.fill(); + + ctx.restore(); + } +} + +// Use the stub until Phase 2 provides a real AgentSprite +AgentSprite = _AgentSpriteStub; + +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, mapData) { + constructor(canvas) { 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); + // ๋งต & ๋ Œ๋”๋Ÿฌ + 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._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); } } - start() { - this._loop = this._loop.bind(this); - this._animId = requestAnimationFrame(this._loop); + /** ์คŒ/ํŒฌ/ํด๋ฆญ ์ด๋ฒคํŠธ ํ•ธ๋“ค๋Ÿฌ */ + _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._onMouseMove = (e) => { + if (this._isPanning) { + this.panX = e.clientX - this._panStart.x; + this.panY = e.clientY - this._panStart.y; + } + }; + 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; + if (typeof sprite.onStateChange === 'function') { + sprite.onStateChange(state, detail, mapData.waypoints); + } else { + sprite.state = state; + sprite.detail = detail || ''; + } + } + + /** ์—์ด์ „ํŠธ ์•Œ๋ฆผ ๋ฐฐ์ง€ ์„ค์ • */ + 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); @@ -40,172 +302,87 @@ export class OfficeRenderer { } } - 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; + const dt = Math.min((timestamp - this._lastTime) / 1000, 0.1); // cap dt to avoid spiral + this._lastTime = timestamp; - ctx.clearRect(0, 0, canvas.width, canvas.height); - ctx.fillStyle = '#1a1a2e'; - ctx.fillRect(0, 0, canvas.width, canvas.height); + this._update(dt); + this._render(); - 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); + this._animId = requestAnimationFrame((t) => this._loop(t)); } - _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); + _update(dt) { + for (const sprite of this.agents.values()) { + if (typeof sprite.update === 'function') { + sprite.update(dt); + } } } - _drawCeoDoc(ctx) { - if (!this.renderInfo) return; - const ceo = this.mapData.waypoints.ceo_desk; - if (!ceo) return; + _render() { + const ctx = this.ctx; + const dpr = window.devicePixelRatio || 1; - 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); + // ์บ”๋ฒ„์Šค ํฌ๊ธฐ ์กฐ์ • + const displayW = this.canvas.clientWidth; + const displayH = this.canvas.clientHeight; + if (this.canvas.width !== displayW * dpr || this.canvas.height !== displayH * dpr) { + this.canvas.width = displayW * dpr; + this.canvas.height = displayH * dpr; + ctx.scale(dpr, dpr); } - // 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); + 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()) { + if (typeof sprite.draw === 'function') { + 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()) { + if (this.overlayRenderer && typeof this.overlayRenderer.draw === 'function') { + this.overlayRenderer.draw(ctx, sprite, this.theme, this.zoom, this.panX, this.panY, mapData.tileSize); + } + } + } + + /** ๋ฆฌ์‚ฌ์ด์ฆˆ ์ฒ˜๋ฆฌ */ + 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 new file mode 100644 index 0000000..58d9a0d --- /dev/null +++ b/src/pages/agent-office/canvas/OverlayRenderer.js @@ -0,0 +1,59 @@ +// src/pages/agent-office/canvas/OverlayRenderer.js +// Phase 3 stub โ€” full implementation in Phase 3 + +/** + * ์˜ค๋ฒ„๋ ˆ์ด ๋ Œ๋”๋Ÿฌ (Phase 3์—์„œ ๊ตฌํ˜„) + * ์—์ด์ „ํŠธ ์œ„์˜ ์ƒํƒœ ์•„์ด์ฝ˜, ์ด๋ฆ„ ๋ ˆ์ด๋ธ”, ์•Œ๋ฆผ ๋ฐฐ์ง€, ๋งํ’์„  ๋“ฑ + */ +export class OverlayRenderer { + /** + * ์—์ด์ „ํŠธ ์˜ค๋ฒ„๋ ˆ์ด ๋ Œ๋”๋ง + * @param {CanvasRenderingContext2D} ctx + * @param {object} sprite - AgentSprite ์ธ์Šคํ„ด์Šค + * @param {object} theme - ํ…Œ๋งˆ ๊ฐ์ฒด + * @param {number} scale - ์คŒ ๋ ˆ๋ฒจ + * @param {number} offsetX - ํŒจ๋‹ X + * @param {number} offsetY - ํŒจ๋‹ Y + * @param {number} tileSize - ํƒ€์ผ ํฌ๊ธฐ + */ + draw(ctx, sprite, theme, scale, offsetX, offsetY, tileSize) { + // Phase 3์—์„œ ๊ตฌํ˜„ ์˜ˆ์ • + // ํ˜„์žฌ๋Š” ๊ธฐ๋ณธ ๋ ˆ์ด๋ธ”๋งŒ ํ‘œ์‹œ + if (!sprite || sprite.x == null) return; + + const ts = tileSize * scale; + const cx = sprite.x * ts + offsetX + ts / 2; + const cy = sprite.y * ts + offsetY; + + // ์—์ด์ „ํŠธ ์ด๋ฆ„ ๋ ˆ์ด๋ธ” + if (sprite.meta && sprite.meta.displayName) { + ctx.save(); + ctx.font = `${Math.max(8, 9 * scale)}px monospace`; + ctx.textAlign = 'center'; + ctx.fillStyle = theme.text.secondary; + ctx.fillText(sprite.meta.displayName, cx, cy - 4 * scale); + ctx.restore(); + } + + // ์•Œ๋ฆผ ๋ฐฐ์ง€ + if (sprite.notificationCount > 0) { + const bx = cx + ts * 0.3; + const by = cy - ts * 0.5; + const r = Math.max(5, 6 * scale); + ctx.save(); + ctx.beginPath(); + ctx.arc(bx, by, r, 0, Math.PI * 2); + ctx.fillStyle = '#ef4444'; + ctx.fill(); + ctx.fillStyle = '#ffffff'; + ctx.font = `bold ${Math.max(7, 7 * scale)}px monospace`; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText( + sprite.notificationCount > 9 ? '9+' : String(sprite.notificationCount), + bx, by + ); + ctx.restore(); + } + } +}