diff --git a/src/pages/agent-office/canvas/AgentSprite.js b/src/pages/agent-office/canvas/AgentSprite.js index 0f0ecfd..4ef01d8 100644 --- a/src/pages/agent-office/canvas/AgentSprite.js +++ b/src/pages/agent-office/canvas/AgentSprite.js @@ -1,89 +1,261 @@ -import { drawAgent, getAnimSpeed } from './SpriteSheet'; +// 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(agentId, waypoints) { - this.agentId = agentId; - this.waypoints = waypoints; - this.state = 'idle'; + 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; - const deskKey = `${agentId}_desk`; - const desk = waypoints[deskKey] || { x: 5, y: 3 }; - this.x = desk.x; - this.y = desk.y; - this.targetX = desk.x; - this.targetY = desk.y; - this.deskPos = { x: desk.x, y: desk.y }; + // 애니메이션 + this.animState = 'idle'; // 렌더링용 상태 + this.direction = 'down'; + this.animFrame = 0; + this.animTimer = 0; - this.frameIndex = 0; - this._lastFrameTime = 0; - this._moveSpeed = 0.05; + // 이동 + 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; } - setNotification(count) { - this.notificationCount = count; - } - - setState(newState, detail = '') { - this.state = newState; - this.detail = detail; - this.frameIndex = 0; - } - - moveTo(target) { - const wp = this.waypoints[target]; - if (wp) { - this.targetX = wp.x; - this.targetY = wp.y; - } - } - - moveToDesk() { - this.targetX = this.deskPos.x; - this.targetY = this.deskPos.y; - } - - update(now) { - const speed = getAnimSpeed(this.state); - if (now - this._lastFrameTime > speed) { - this.frameIndex++; - this._lastFrameTime = now; + /** 매 프레임 호출 */ + update(dt) { + // 이동 처리 + if (this.path.length > 0) { + this._updateMovement(dt); + } else if (this._wandering) { + this._updateWander(dt); } - const dx = this.targetX - this.x; - const dy = this.targetY - this.y; - const dist = Math.sqrt(dx * dx + dy * dy); + // 애니메이션 프레임 업데이트 + this._updateAnimation(dt); + } - if (dist > 0.1) { - const step = Math.min(this._moveSpeed, dist); - this.x += (dx / dist) * step; - this.y += (dy / dist) * step; + _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 { - this.x = this.targetX; - this.y = this.targetY; + // 보간 + 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; } } - draw(ctx, renderInfo) { - const { scale, offsetX, offsetY, tileSize } = renderInfo; - const canvasX = offsetX + this.x * tileSize * scale + (tileSize * scale) / 2; - const canvasY = offsetY + this.y * tileSize * scale + (tileSize * scale) / 2; + _onArrival() { + const atDesk = Math.abs(this.x - this.deskCol) < 0.5 && Math.abs(this.y - this.deskRow) < 0.5; + this._isAtDesk = atDesk; - const isMoving = Math.abs(this.targetX - this.x) > 0.1 || Math.abs(this.targetY - this.y) > 0.1; - const drawState = isMoving ? 'walk' : this.state; - - drawAgent(ctx, this.agentId, canvasX, canvasY, drawState, this.frameIndex, scale * 1.5); + 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'; + } } - hitTest(canvasX, canvasY, renderInfo) { - const { scale, offsetX, offsetY, tileSize } = renderInfo; - const cx = offsetX + this.x * tileSize * scale + (tileSize * scale) / 2; - const cy = offsetY + this.y * tileSize * scale + (tileSize * scale) / 2; - const hitW = 20 * scale; - const hitH = 30 * scale; + _updateWander(dt) { + if (this._isResting) { + this._restTimer -= dt; + if (this._restTimer <= 0) { + this._isResting = false; + this._startWandering(); + } + return; + } - return canvasX >= cx - hitW && canvasX <= cx + hitW && - canvasY >= cy - hitH && canvasY <= cy + hitH; + 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 + ); } }