시체가 길을 막는다 — 타일 기반 전술 게임의 사망 처리 삽질기
죽었는데 왜 거기 서 있어?
타일 기반 전술 게임을 만들다 보면, 유닛의 "생사"라는 개념이 생각보다 많은 시스템에 얽혀 있다는 걸 깨닫게 됩니다. 이번 글에서는 사망한 유닛의 시체가 타일을 점유해서 다른 유닛이 이동할 수 없었던 버그를 고치면서 연쇄적으로 터진 문제들을 정리합니다.
프로젝트는 BabylonJS 기반의 3D 전술 게임 myDGC. 웨이브 형식으로 몬스터가 몰려오고, 플레이어 유닛들이 타일 위에서 이동하며 전투하는 구조입니다.
문제의 시작: 시체가 교통 체증을 만든다
전투 중 적을 처치하면 유닛이 쓰러지는 애니메이션이 재생됩니다. 여기까진 좋았습니다. 문제는 그 시체가 타일 점유 데이터에서 해제되지 않는다는 것이었습니다.
타일맵의 occupiedTiles는 유닛이 해당 타일 위에 있다는 걸 기록하는 데이터인데, 사망한 유닛이 여기서 빠지지 않으니 다른 유닛들이 그 타일을 "점유 중"으로 인식합니다. 결과적으로 시체 옆을 지나가는 경로가 막혀버리는 현상이 발생했습니다.
Claude Code와 함께 수정 시작
Claude Code에게 문제를 설명하자 세 가지를 한 번에 처리하는 방향을 제안했습니다.
모두 적용했습니다. 변경 요약:
1. 시체 타일 점유 해제 + 10초 후 제거
MovementSystem.cancelMove- 죽은 유닛은occupiedTiles에서 제거BattleScene.onCombatEvent- 킬 시movementSystem.unregisterUnit()+floatingHPBarSystem.unregisterUnit()호출scheduleCorpseRemoval()- 10초 후 1초간 페이드아웃 → 메시 dispose
핵심 로직은 간단합니다:
// 킬 이벤트 발생 시
if (event.type === 'kill') {
// 1. 타일 점유 즉시 해제
this.movementSystem.unregisterUnit(target);
this.floatingHPBarSystem.unregisterUnit(target);
// 2. 10초 후 페이드아웃 → 메시 제거
this.scheduleCorpseRemoval(target);
}
사망 즉시 타일 점유를 해제하고, 시체 메시는 10초간 유지한 뒤 1초 페이드아웃으로 자연스럽게 사라지게 했습니다. 시체를 클릭해도 findPlayerUnitByMesh에서 !unit.isAlive 체크가 이미 있어서 선택되지 않습니다.
여기까지는 순조로웠습니다. 진짜 삽질은 이 수정이 건드린 연쇄 반응에서 시작됩니다.
연쇄 반응 1: 점프 중 공중 정지
시체 점유 해제를 수정하면서 MovementSystem을 건드렸더니, 기존에 숨어 있던 이동 관련 버그가 수면 위로 올라왔습니다.
MovementSystem.updateMovement에서 다음 타일 점유 체크를 progress 값과 관계없이 항상 수행하고 있었습니다. 유닛이 이미 점프 중(progress > 0)인데 적이 다음 타일에 있으면 공중에서 멈추는 기현상이 발생했습니다.
// 수정 전: 점프 중에도 매 프레임 점유 체크
if (isOccupied(nextTile)) return; // 공중에서 멈춤!
// 수정 후: 이동 시작 전에만 체크
if (progress === 0 && isOccupied(nextTile)) return;
수정:
progress === 0일 때만 (아직 이동 시작 전) 점유 체크로 블로킹. 이미 이동 중이면 해당 타일까지는 이동 완료 후 전투 시스템이 처리하도록 변경.
연쇄 반응 2: 수동 이동 명령이 무시되는 문제
시체 처리를 고치고 나니, 이번엔 플레이어가 수동으로 이동 명령을 내려도 유닛이 제자리에서 허공을 공격하다 죽는 현상이 보고됐습니다.
Claude Code가 원인 체인을 분석했습니다:
원인 체인:
issueMoveCommand→ 커맨드만 큐잉하고 유닛 상태는 COMBAT 그대로UnitAI(0.3초마다) → 적이 사정거리 내면enterCombat()→ COMBAT 유지CombatSystem(0.1초마다) → 동일하게enterCombat()→ COMBAT 유지MovementSystem.update→if (unit.state === COMBAT) return→ 이동 안 함- 결과: 커맨드는 있지만 이동은 영원히 실행 안 됨
4개 시스템이 서로 상태를 덮어쓰면서 교착 상태가 발생한 것입니다. 수정은 playerCommandedUnits Set을 도입해서 수동 명령을 받은 유닛을 별도 추적하는 방식이었습니다.
// 수동 이동 명령 시
issueMoveCommand(unit, target) {
unit.exitCombat(); // 전투 즉시 해제
unit.setPlayerCommanded(true); // 수동 명령 플래그
this.movementSystem.requestMove(unit, target);
}
// MovementSystem: 수동 명령 유닛은 COMBAT에서도 이동 계속
if (unit.state === COMBAT && !unit.playerCommanded) return;
// 이동 완료 시 플래그 해제
completeMovement(unit) {
unit.setPlayerCommanded(false);
}
처음엔 단순히 MOVING 상태면 전투 진입을 차단했는데, 그러면 이동 중 피격 시 반격이 안 되는 문제가 생겼습니다. 플래그 방식으로 바꿔서 "이동은 계속하되 맞으면 때린다"를 구현했습니다.
연쇄 반응 3: 경로가 지형에 묻히는 렌더링 문제
이동 관련 수정을 하면서 경로 표시 기능을 개선했는데, 경로 메시가 3D 지형 아래에 묻혀서 안 보이는 문제가 있었습니다.
처음엔 Y 오프셋을 올리는 미봉책을 시도했지만, Claude Code가 스스로 반성합니다:
죄송합니다. 처음부터 깊이 버퍼 문제를 제대로 파악했어야 했는데, 단순히 Y 오프셋만 올리는 미봉책을 시도했네요.
결국 렌더링 그룹의 깊이 버퍼를 클리어하는 것이 정답이었습니다:
scene.setRenderingAutoClearDepthStencil(1, true, true, false);
렌더링 그룹 1을 그리기 전에 깊이 버퍼를 클리어해서, 경로 메시가 지형 깊이값에 관계없이 항상 위에 렌더링됩니다.
연쇄 반응 4: 높이 차이와 전투 시스템
타일 이동 문제를 고치다 보니 높이 시스템과 전투의 상호작용 문제도 드러났습니다. 언덕 아래에서 위로 근접 공격이 가능한 건 이상하잖아요?
원인:
Unit.canAttack이 맨해튼 거리(X,Y 좌표)만 체크하고 높이 차이를 전혀 고려하지 않음
근접 유닛은 같은 높이에서만, 원거리 유닛은 아래→위 공격 시 높이 차이에 비례한 miss 확률을 적용했습니다. 그런데 맵 높이 데이터를 확인하는 과정에서 또 한 번 실수가 있었습니다:
제가 틀렸습니다. y=5, y=6 행에 0.5, 1.5 높이가 있습니다. 처음에 위쪽 몇 줄만 보고 "0, 1, 2만 있다"고 잘못 판단했습니다.
데이터를 대충 보고 판단한 대가를 치른 셈입니다. 실제로는 0, 0.5, 1, 1.5, 2 총 5단계 높이가 있었고, 이에 맞춰 MAX_CLIMB_HEIGHT를 0.5로, MAX_DROP_HEIGHT를 Infinity로 설정했습니다.
// 올라가기: 0.5 단차씩만 가능
const MAX_CLIMB_HEIGHT = 0.5;
// 내려가기: 제한 없음 (절벽에서 뛰어내리기 가능)
const MAX_DROP_HEIGHT = Infinity;
보너스: 공격 중 회전 덮어쓰기
마지막으로 발견한 버그. 유닛이 공격할 때 대상을 바라보도록 회전시키는데, syncUnitMeshPositions가 매 프레임 이동 방향으로 회전을 덮어쓰고 있었습니다.
// 수정: 공격 중이면 이동 방향 회전을 스킵
if (!this.isUnitAttacking(unit)) {
// 이동 방향으로 부드러운 회전
mesh.rotationQuaternion = Quaternion.Slerp(...);
}
교훈 정리
1. 사망은 "상태 변경"이 아니라 "시스템 해제"다
유닛이 죽으면 단순히 isAlive = false만 설정하는 게 아닙니다. 이동 시스템, 타일 점유, HP바, AI, 전투 시스템 등 모든 관련 시스템에서 해당 유닛을 해제해야 합니다.
2. 상태 머신의 경합 조건을 조심하라
UnitAI, CombatSystem, MovementSystem이 각각 유닛 상태를 변경하면 교착 상태가 발생합니다. "누가 이 상태를 변경할 권한이 있는가"를 명확히 정의해야 합니다.
3. 데이터를 대충 보지 말자
맵 높이 데이터를 위쪽 몇 줄만 보고 "정수만 있다"고 판단했다가 설계를 다시 해야 했습니다. 데이터는 전체를 봐야 합니다.
4. 미봉책보다 근본 원인을
Y 오프셋을 올리는 것보다 깊이 버퍼를 제대로 처리하는 것이 정답이었습니다. 첫 번째 시도가 "그럴듯해 보이는 우회"라면 한 번 더 생각해봐야 합니다.
시체 하나가 타일을 점유하는 단순한 버그에서 시작해서, 이동 시스템, 전투 시스템, 렌더링, 높이 시스템까지 연쇄적으로 수정하게 된 하루였습니다. 게임 개발에서 "간단한 버그"란 없다는 걸 다시 한 번 느꼈습니다.