refactor(agent-office): rewrite AgentSprite with BFS movement and idle wandering

This commit is contained in:
2026-04-27 08:32:16 +09:00
parent f01a432329
commit 7dd2cc9793

View File

@@ -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;
/** 매 프레임 호출 */
update(dt) {
// 이동 처리
if (this.path.length > 0) {
this._updateMovement(dt);
} else if (this._wandering) {
this._updateWander(dt);
}
setState(newState, detail = '') {
this.state = newState;
this.detail = detail;
this.frameIndex = 0;
// 애니메이션 프레임 업데이트
this._updateAnimation(dt);
}
moveTo(target) {
const wp = this.waypoints[target];
if (wp) {
this.targetX = wp.x;
this.targetY = wp.y;
}
}
_updateMovement(dt) {
this.animState = 'walk';
this.moveProgress += WALK_SPEED * dt;
moveToDesk() {
this.targetX = this.deskPos.x;
this.targetY = this.deskPos.y;
}
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;
update(now) {
const speed = getAnimSpeed(this.state);
if (now - this._lastFrameTime > speed) {
this.frameIndex++;
this._lastFrameTime = now;
}
const dx = this.targetX - this.x;
const dy = this.targetY - this.y;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist > 0.1) {
const step = Math.min(this._moveSpeed, dist);
this.x += (dx / dist) * step;
this.y += (dy / dist) * step;
if (this.path.length === 0) {
// 최종 목적지 도착
this._onArrival();
} else {
this.x = this.targetX;
this.y = this.targetY;
// 다음 구간의 방향 설정
this._updateDirection(this.path[0]);
}
} else {
// 보간
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
);
}
}