4개 시스템이 서로 상태를 덮어쓸 때 — 수동 이동 vs 자동 전투 충돌기
스타일의 실시간 전술 배틀 + 로그라이크 게임입니다. Babylon.js 기반 3D, 타일맵 위에서 유닛들이 자동으로 전투하되, 플레이어가 수동으로 이동 명령을 내릴 수도 있는 구조죠.
문제는 이 "자동"과 "수동"이 만나는 지점에서 터졌습니다.
유닛을 클릭하고 타일을 찍어 이동 명령을 내렸는데, 유닛이 꼼짝도 안 합니다. 제자리에서 허공을 향해 칼만 휘두르다가 몬스터한테 맞아 죽는 겁니다. 분명 이동 명령을 내렸는데요.
문제: 4개 시스템의 상태 쟁탈전
Claude와 함께 원인을 추적해보니, 문제는 하나의 유닛 상태를 4개 시스템이 서로 덮어쓰고 있다는 것이었습니다.
원인 체인:
issueMoveCommand→ 커맨드만 큐잉하고 유닛 상태는 COMBAT 그대로UnitAI(0.3초마다) → 적이 사정거리 내면enterCombat()→ COMBAT 유지CombatSystem(0.1초마다) → 동일하게enterCombat()→ COMBAT 유지MovementSystem.update→if (unit.state === COMBAT) return→ 이동 안 함- 결과: 커맨드는 있지만 이동은 영원히 실행 안 됨
정리하면 이런 구조입니다:
[CommandSystem] → "이동해!" → 커맨드 큐에 추가
[UnitAI] → "적 발견!" → enterCombat() → COMBAT 상태
[CombatSystem] → "전투 중!" → enterCombat() → COMBAT 유지
[MovementSystem] → "COMBAT이네? 패스" → 이동 안 함
CommandSystem은 명령을 큐에 넣기만 하고, 실제 이동은 MovementSystem이 담당합니다. 그런데 MovementSystem은 COMBAT 상태면 아예 처리를 안 합니다. 한편 UnitAI와 CombatSystem은 0.1~0.3초마다 적이 보이면 끊임없이 COMBAT을 유지시킵니다.
플레이어의 수동 명령이 자동 전투 시스템에게 완전히 묻혀버리는 거죠.
1차 시도: 단순히 전투 상태를 해제하자
Claude의 첫 번째 접근은 단순했습니다.
issueMoveCommand에서exitCombat()호출 → COMBAT → IDLE로 전환UnitAI/CombatSystem→ MOVING 상태면 자동 전투 진입 차단
// issueMoveCommand에서
unit.exitCombat();
// UnitAI에서
if (unit.state === UnitState.MOVING) return; // 전투 진입 차단
// CombatSystem에서도 동일
if (unit.state === UnitState.MOVING) return;
논리적으로는 맞아 보입니다. 이동 명령 → 전투 해제 → MOVING 상태 → AI가 못 건드림 → 이동 완료.
그런데 이게 너무 과한 수정이었습니다. 이동 중에 적에게 맞아도 반격을 전혀 안 하는 겁니다. 유닛이 몬스터 사이를 그냥 무방비로 지나가는 모습은... 전술 게임이 아니라 평화 행진이 되어버렸습니다.
방향 전환: 그런데 진짜 원하는 동작이 뭐지?
여기서 제가 Claude에게 정확한 동작 사양을 다시 정리해줬습니다.
원하는 동작:
- 수동 이동 명령 → 전투 중단, 즉시 이동
- 이동 중 공격 받아도 → 반격 안 함, 이동만 계속
- 목적지 도착 → 수동 명령 해제 → 그때서야 AI가 적 감지해서 자동 전투
Claude가 수정 계획을 보여주고... 바로 코드를 고치기 시작했습니다.
이때 제가 한 마디 했습니다.
"수정 계획을 보여주고 바로 시작하면 어떻게 해? 나한테 사인을 받고 해야지"
AI가 아무리 똑똑해도, 게임 디자인 의도는 사람이 결정하는 겁니다. "이동 중 반격 허용"과 "이동 중 반격 차단"은 코드 한 줄 차이지만 게임 플레이 경험은 완전히 다릅니다. 이걸 AI가 알아서 판단하면 안 되죠.
최종 해결: playerCommanded 플래그
승인을 받은 후, 3단계에 걸쳐 깔끔하게 수정했습니다.
1단계: 수동 명령 플래그 추가
// BattleScene.ts
private playerCommandedUnits: Set<Unit> = new Set();
issueMoveCommand(unit: Unit, targetTile: Tile) {
unit.exitCombat();
this.playerCommandedUnits.add(unit);
this.movementSystem.setPlayerCommanded(unit, true);
// ... 이동 명령 큐잉
}
2단계: MovementSystem에서 수동 명령 유닛 특별 처리
// MovementSystem.ts
update(deltaTime: number) {
for (const unit of this.units) {
if (unit.state === UnitState.COMBAT && !unit.playerCommanded) {
continue; // 자동 전투 중이면 이동 안 함
}
// playerCommanded면 COMBAT 상태여도 이동 계속
this.updateMovement(unit, deltaTime);
}
}
3단계: 이동 완료 시 플래그 해제
// MovementSystem.ts
completeMovement(unit: Unit) {
unit.playerCommanded = false;
unit.state = UnitState.IDLE;
// → AI가 다시 적을 감지하면 자동 전투 시작
}
최종 흐름:
수동 이동 클릭 → exitCombat() + playerCommanded=true → MOVING
↓
이동 중 → UnitAI/CombatSystem이 MOVING 체크 → 전투 진입 안 함
↓
맞아도 반격 없이 이동 계속
↓
목적지 도착 → playerCommanded=false + IDLE
↓
AI가 적 감지 → COMBAT 진입 → 자동 전투 시작
교훈: AI와 협업할 때 "사인"이 필요한 이유
1. 상태 머신 설계에서 "소유권"을 명확히 하세요
4개 시스템이 하나의 unit.state를 마음대로 바꿀 수 있으면, 예측 불가능한 동작이 나옵니다. playerCommanded 같은 플래그를 통해 "지금 이 유닛의 상태는 누가 관리하는가"를 명시적으로 선언해야 합니다.
수동 명령 중: 플레이어가 소유 → AI/전투 시스템 개입 금지
수동 명령 없음: AI가 소유 → 자동 판단으로 상태 전환
2. AI에게 "계획 승인" 프로세스를 강제하세요
Claude는 문제를 빠르게 파악하고 수정 계획까지 세우는 건 훌륭합니다. 하지만 게임 디자인 의도는 코드에서 읽을 수 없습니다. "이동 중 반격을 허용할 것인가?"는 순전히 게임 디자인 결정이고, 이걸 AI가 임의로 판단하면 원치 않는 방향으로 갈 수 있습니다.
수정 계획을 보여준 뒤 반드시 확인을 받는 워크플로우가 필요합니다. 이후 Claude에게 이 규칙을 명시적으로 전달한 뒤로는 매번 "이 방향으로 진행할까요?"라고 확인하게 되었고, 작업 효율이 오히려 올라갔습니다.
3. 수정은 한 곳이 아니라 "체인 전체"를 봐야 합니다
이 버그의 교과서적인 함정은, issueMoveCommand에서 exitCombat()만 호출하면 될 것 같다는 점입니다. 하지만 0.1초 후에 UnitAI가, 0.3초 후에 CombatSystem이 다시 COMBAT으로 되돌립니다. 상태 변경의 전체 체인을 파악하지 않으면 미봉책만 쌓이게 됩니다.
마무리
게임 개발에서 "자동"과 "수동"이 공존하는 시스템은 거의 반드시 상태 충돌을 만듭니다. RTS, 전술 게임, MOBA 할 것 없이 다 겪는 문제죠. 핵심은 상태의 소유권을 명시적으로 관리하는 것, 그리고 AI 도구와 협업할 때는 디자인 의도에 대한 확인 단계를 빼먹지 않는 것입니다.
Claude가 실수로 바로 코드를 고치기 시작한 덕분에, 오히려 협업 프로세스를 명확하게 정립할 수 있었습니다. 때로는 삽질이 최고의 교훈을 줍니다.