refactor(agent-office): rewrite OfficeRenderer with game loop, zoom/pan, Y-sorting
This commit is contained in:
@@ -1,38 +1,300 @@
|
|||||||
import { drawTileMap } from './TileMap';
|
// src/pages/agent-office/canvas/OfficeRenderer.js
|
||||||
import { AgentSprite } from './AgentSprite';
|
|
||||||
import { getCharLabel, drawNotificationBadge } from './SpriteSheet';
|
|
||||||
|
|
||||||
const STATUS_ICONS = {
|
import mapData from '../assets/office-map.json';
|
||||||
idle: null,
|
import { TileMap } from './TileMap.js';
|
||||||
working: null,
|
import { FurnitureRenderer } from './FurnitureRenderer.js';
|
||||||
waiting: '❗',
|
import { Pathfinder } from './Pathfinder.js';
|
||||||
reporting: '📋',
|
import { OverlayRenderer } from './OverlayRenderer.js';
|
||||||
break: '☕',
|
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 {
|
export class OfficeRenderer {
|
||||||
constructor(canvas, mapData) {
|
constructor(canvas) {
|
||||||
this.canvas = canvas;
|
this.canvas = canvas;
|
||||||
this.ctx = canvas.getContext('2d');
|
this.ctx = canvas.getContext('2d');
|
||||||
this.mapData = mapData;
|
|
||||||
this.renderInfo = null;
|
// 맵 & 렌더러
|
||||||
this.agents = {};
|
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._animId = null;
|
||||||
this._onClick = null;
|
|
||||||
this._onCeoClick = null;
|
|
||||||
this._ceoDocBadge = 0;
|
|
||||||
|
|
||||||
const agentIds = ['stock', 'music', 'blog', 'realestate'];
|
// 이벤트
|
||||||
for (const id of agentIds) {
|
this._setupInputHandlers();
|
||||||
this.agents[id] = new AgentSprite(id, mapData.waypoints);
|
}
|
||||||
|
|
||||||
|
_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._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() {
|
start() {
|
||||||
this._loop = this._loop.bind(this);
|
this.centerCamera();
|
||||||
this._animId = requestAnimationFrame(this._loop);
|
this._lastTime = performance.now();
|
||||||
|
this._loop(this._lastTime);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 게임 루프 중지 */
|
||||||
stop() {
|
stop() {
|
||||||
if (this._animId) {
|
if (this._animId) {
|
||||||
cancelAnimationFrame(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) {
|
_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);
|
this._update(dt);
|
||||||
ctx.fillStyle = '#1a1a2e';
|
this._render();
|
||||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
|
||||||
|
|
||||||
this.renderInfo = drawTileMap(ctx, mapData, canvas.width, canvas.height);
|
this._animId = requestAnimationFrame((t) => this._loop(t));
|
||||||
|
|
||||||
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)) {
|
_update(dt) {
|
||||||
this._drawOverlay(ctx, sprite, id);
|
for (const sprite of this.agents.values()) {
|
||||||
}
|
if (typeof sprite.update === 'function') {
|
||||||
|
sprite.update(dt);
|
||||||
// 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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_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.canvas.width = displayW * dpr;
|
||||||
|
this.canvas.height = displayH * dpr;
|
||||||
|
ctx.scale(dpr, dpr);
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
59
src/pages/agent-office/canvas/OverlayRenderer.js
Normal file
59
src/pages/agent-office/canvas/OverlayRenderer.js
Normal file
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user