본 콘텐츠의 이미지 및 내용은 AI로 생성되었습니다.
본 콘텐츠의 이미지 및 내용을 무단으로 복제, 배포, 수정하여 사용할 경우 저작권법에 의해 법적 제재를 받을 수 있습니다.
이미지 로딩 중...
AI Generated
2026. 2. 2. · 3 Views
고급 AI 시스템 완벽 가이드
게임 개발에서 사용되는 고급 AI 시스템의 핵심 개념들을 초급 개발자도 이해할 수 있도록 쉽게 설명합니다. 행동 트리부터 적응형 난이도까지, 실무에서 바로 활용할 수 있는 지식을 담았습니다.
목차
1. 행동 트리
김개발 씨는 첫 게임 프로젝트에서 적 캐릭터의 AI를 구현하게 되었습니다. 처음에는 if-else 문으로 모든 행동을 처리했는데, 코드가 점점 복잡해지면서 스파게티처럼 엉키기 시작했습니다.
선배 박시니어 씨가 다가와 말했습니다. "행동 트리를 써보는 게 어때요?"
**행동 트리(Behavior Tree)**는 AI의 의사결정 구조를 나무 형태로 표현한 것입니다. 마치 회사의 조직도처럼 위에서 아래로 명령이 내려가고, 각 노드가 성공 또는 실패를 보고합니다.
이것을 제대로 이해하면 복잡한 AI 로직도 깔끔하게 관리할 수 있습니다.
다음 코드를 살펴봅시다.
// 행동 트리의 기본 노드 정의
enum NodeStatus { success, failure, running }
abstract class BehaviorNode {
NodeStatus execute();
}
// 순차 실행 노드 - 자식들을 순서대로 실행
class SequenceNode extends BehaviorNode {
final List<BehaviorNode> children;
SequenceNode(this.children);
@override
NodeStatus execute() {
for (var child in children) {
var status = child.execute();
if (status != NodeStatus.success) return status;
}
return NodeStatus.success;
}
}
// 선택 노드 - 성공하는 자식을 찾을 때까지 시도
class SelectorNode extends BehaviorNode {
final List<BehaviorNode> children;
SelectorNode(this.children);
@override
NodeStatus execute() {
for (var child in children) {
var status = child.execute();
if (status != NodeStatus.failure) return status;
}
return NodeStatus.failure;
}
}
김개발 씨는 입사 3개월 차 주니어 개발자입니다. 오늘도 열심히 게임 속 적 캐릭터의 AI를 구현하던 중, 이상한 버그를 발견했습니다.
적이 플레이어를 발견했는데도 가만히 서 있거나, 갑자기 엉뚱한 방향으로 달려가는 것이었습니다. 선배 개발자 박시니어 씨가 다가와 코드를 살펴봅니다.
"아, 여기가 문제네요. if-else가 20단계나 중첩되어 있으니 어디서 잘못됐는지 찾기도 어렵겠어요.
행동 트리를 한번 적용해 볼까요?" 그렇다면 행동 트리란 정확히 무엇일까요? 쉽게 비유하자면, 행동 트리는 마치 회사의 업무 지시 체계와 같습니다.
사장님이 "매출을 올려라"라고 지시하면, 그 아래 팀장들이 각자의 방식으로 업무를 수행합니다. 영업팀은 신규 고객을 찾고, 마케팅팀은 광고를 집행합니다.
한 팀이 성공하면 전체 목표도 달성되는 것처럼, 행동 트리도 비슷한 방식으로 작동합니다. 행동 트리가 없던 시절에는 어땠을까요?
개발자들은 모든 조건을 if-else 문으로 직접 처리해야 했습니다. "플레이어가 보이면 공격하고, 안 보이면 순찰하고, 체력이 낮으면 도망가고..." 이런 조건들이 쌓이면서 코드가 길어지고, 실수하기도 쉬웠습니다.
더 큰 문제는 나중에 새로운 행동을 추가하거나 기존 행동을 수정할 때였습니다. 어디를 건드려야 할지 파악하는 것만으로도 시간이 걸렸습니다.
바로 이런 문제를 해결하기 위해 행동 트리가 등장했습니다. 행동 트리를 사용하면 각 행동을 독립적인 노드로 분리할 수 있습니다.
또한 노드들을 조합해서 복잡한 행동 패턴을 만들 수 있습니다. 무엇보다 시각적으로 AI의 흐름을 파악할 수 있다는 큰 이점이 있습니다.
위의 코드를 한 줄씩 살펴보겠습니다. 먼저 NodeStatus 열거형을 보면 노드가 반환할 수 있는 세 가지 상태를 정의합니다.
success는 성공, failure는 실패, running은 아직 실행 중이라는 의미입니다. 다음으로 SequenceNode는 자식 노드들을 순서대로 실행합니다.
하나라도 실패하면 전체가 실패로 처리됩니다. 반대로 SelectorNode는 자식 중 하나만 성공하면 전체가 성공합니다.
실제 현업에서는 어떻게 활용할까요? 예를 들어 RPG 게임의 몬스터 AI를 구현한다고 가정해봅시다.
"플레이어 감지 → 거리 확인 → 공격"이라는 시퀀스와 "근접 공격 시도 → 원거리 공격 시도"라는 셀렉터를 조합하면 자연스러운 전투 AI가 완성됩니다. 많은 AAA 게임 회사에서 이런 패턴을 적극적으로 사용하고 있습니다.
하지만 주의할 점도 있습니다. 초보 개발자들이 흔히 하는 실수 중 하나는 트리를 너무 깊게 만드는 것입니다.
트리가 10단계 이상 깊어지면 오히려 if-else보다 관리하기 어려워질 수 있습니다. 따라서 적절한 깊이를 유지하고, 필요하면 서브 트리로 분리해야 합니다.
다시 김개발 씨의 이야기로 돌아가 봅시다. 박시니어 씨의 설명을 들은 김개발 씨는 고개를 끄덕였습니다.
"아, 그래서 그랬군요! 트리 구조로 만들면 어디서 문제가 생겼는지 바로 찾을 수 있겠네요!" 행동 트리를 제대로 이해하면 더 깔끔하고 유지보수하기 쉬운 AI 코드를 작성할 수 있습니다.
여러분도 오늘 배운 내용을 실제 프로젝트에 적용해 보세요.
실전 팁
💡 - 트리의 깊이는 5단계 이내로 유지하고, 복잡한 행동은 서브 트리로 분리하세요
- 디버깅을 위해 각 노드에 로깅 기능을 추가하면 AI 흐름을 추적하기 쉬워집니다
2. 유틸리티 AI
김개발 씨는 행동 트리로 적 AI를 구현했지만, 뭔가 부족함을 느꼈습니다. 적들이 너무 예측 가능하게 행동하는 것이었습니다.
"플레이어가 보이면 무조건 공격하는데, 좀 더 상황에 맞게 행동하면 좋겠어요." 박시니어 씨가 미소를 지으며 말했습니다. "그럼 유틸리티 AI를 배워볼 때가 됐네요."
유틸리티 AI는 각 행동에 점수를 매기고, 가장 높은 점수를 받은 행동을 선택하는 방식입니다. 마치 사람이 여러 선택지 중에서 가장 이득이 되는 것을 고르는 것처럼, AI도 상황을 종합적으로 판단하여 최선의 행동을 결정합니다.
다음 코드를 살펴봅시다.
// 유틸리티 AI의 행동 정의
class Action {
final String name;
final double Function() calculateScore;
final void Function() execute;
Action(this.name, this.calculateScore, this.execute);
}
// 유틸리티 AI 시스템
class UtilityAI {
final List<Action> actions;
UtilityAI(this.actions);
void update(double health, double distance, double ammo) {
// 각 행동의 점수 계산
Action? bestAction;
double bestScore = double.negativeInfinity;
for (var action in actions) {
double score = action.calculateScore();
if (score > bestScore) {
bestScore = score;
bestAction = action;
}
}
// 가장 높은 점수의 행동 실행
bestAction?.execute();
}
}
// 사용 예시
final ai = UtilityAI([
Action('공격', () => (100 - distance) * 0.5 + ammo * 0.3, () => attack()),
Action('도망', () => (100 - health) * 0.8, () => flee()),
Action('재장전', () => ammo < 20 ? 80.0 : 0.0, () => reload()),
]);
김개발 씨는 행동 트리를 마스터했다고 생각했습니다. 하지만 게임을 테스트하던 중 플레이어들의 피드백이 들어왔습니다.
"적이 너무 단순해요. 체력이 1%여도 무조건 돌격하더라고요." 박시니어 씨가 김개발 씨의 고민을 듣고 설명을 시작했습니다.
"행동 트리는 정해진 규칙대로 움직이기 때문에 그런 문제가 생겨요. 이번에는 유틸리티 AI를 알려드릴게요." 그렇다면 유틸리티 AI란 정확히 무엇일까요?
쉽게 비유하자면, 유틸리티 AI는 마치 점심 메뉴를 고르는 과정과 같습니다. 배가 많이 고프면 양이 많은 음식에 높은 점수를, 다이어트 중이면 샐러드에 높은 점수를 줍니다.
지갑 사정, 시간, 건강 상태 등을 종합적으로 고려해서 가장 점수가 높은 메뉴를 선택하는 것처럼, AI도 여러 요소를 계산해서 최선의 행동을 결정합니다. 단순한 조건 분기만으로는 어떤 문제가 있었을까요?
기존 방식에서는 "체력이 30% 이하면 도망"처럼 딱딱한 기준을 정해야 했습니다. 하지만 현실에서 사람은 그렇게 행동하지 않습니다.
체력이 40%여도 적이 멀리 있으면 계속 싸울 수 있고, 체력이 50%여도 적이 너무 강하면 도망칠 수 있습니다. 이런 유연한 판단을 구현하기가 어려웠습니다.
유틸리티 AI는 이 문제를 점수 시스템으로 해결합니다. 각 행동마다 유틸리티 함수를 정의합니다.
이 함수는 현재 상황을 입력받아 그 행동이 얼마나 유용한지를 점수로 반환합니다. 공격 행동의 점수는 적과의 거리가 가까울수록, 탄약이 많을수록 높아집니다.
도망 행동의 점수는 체력이 낮을수록 높아집니다. AI는 매 순간 모든 행동의 점수를 계산하고, 가장 높은 점수의 행동을 선택합니다.
코드를 자세히 살펴보겠습니다. Action 클래스는 행동의 이름, 점수 계산 함수, 실행 함수를 담고 있습니다.
UtilityAI 클래스는 모든 행동을 순회하며 점수를 계산하고, 가장 높은 점수의 행동을 실행합니다. 사용 예시를 보면 공격, 도망, 재장전 각각에 대해 상황별 점수 공식을 정의한 것을 볼 수 있습니다.
실제 게임에서는 어떻게 활용될까요? 심즈 시리즈가 대표적인 예입니다.
심들은 배고픔, 피로, 사교 욕구 등 여러 상태값을 가지고 있고, 각 행동의 유틸리티는 이 상태값들에 따라 달라집니다. 배가 고프면 냉장고의 유틸리티가 올라가고, 피곤하면 침대의 유틸리티가 올라가는 식입니다.
주의할 점도 있습니다. 유틸리티 함수를 설계하는 것이 생각보다 까다롭습니다.
점수 공식의 가중치를 잘못 설정하면 AI가 한 가지 행동만 반복하거나, 반대로 너무 산만하게 행동할 수 있습니다. 충분한 테스트와 튜닝이 필요합니다.
김개발 씨는 유틸리티 AI를 적용한 후 적들이 훨씬 자연스럽게 행동하는 것을 보고 감탄했습니다. "이제 적이 상황 판단을 하는 것 같아요!"
실전 팁
💡 - 유틸리티 함수는 0에서 100 사이의 정규화된 값을 반환하도록 설계하면 비교가 쉬워집니다
- 행동 간 전환이 너무 빈번하면 히스테리시스(이전 행동에 보너스 점수)를 추가하세요
3. 길찾기 A스타 알고리즘
김개발 씨의 게임에서 적들이 벽에 부딪혀 제자리에서 멈추는 버그가 발생했습니다. "적이 플레이어를 쫓아오긴 하는데, 장애물만 만나면 멈춰버려요." 박시니어 씨가 웃으며 말했습니다.
"길찾기 알고리즘을 안 넣었구나. A* 알고리즘을 알아볼까?"
*A 알고리즘**은 시작점에서 목표점까지의 최단 경로를 찾는 대표적인 길찾기 알고리즘입니다. 마치 내비게이션이 최적의 경로를 안내해주는 것처럼, 게임 속 캐릭터도 장애물을 피해 목표 지점까지 효율적으로 이동할 수 있게 해줍니다.
다음 코드를 살펴봅시다.
class Node {
final int x, y;
double g = 0; // 시작점에서 현재까지의 비용
double h = 0; // 현재에서 목표까지의 추정 비용
double get f => g + h; // 총 비용
Node? parent;
Node(this.x, this.y);
}
class AStar {
List<Node> findPath(Node start, Node goal, List<List<bool>> grid) {
final openList = <Node>[start];
final closedList = <Node>[];
while (openList.isNotEmpty) {
// f값이 가장 낮은 노드 선택
openList.sort((a, b) => a.f.compareTo(b.f));
final current = openList.removeAt(0);
if (current.x == goal.x && current.y == goal.y) {
return _reconstructPath(current);
}
closedList.add(current);
// 이웃 노드 탐색
for (var neighbor in _getNeighbors(current, grid)) {
if (closedList.contains(neighbor)) continue;
double tentativeG = current.g + 1;
if (tentativeG < neighbor.g || !openList.contains(neighbor)) {
neighbor.g = tentativeG;
neighbor.h = _heuristic(neighbor, goal);
neighbor.parent = current;
if (!openList.contains(neighbor)) openList.add(neighbor);
}
}
}
return []; // 경로 없음
}
double _heuristic(Node a, Node b) =>
(a.x - b.x).abs() + (a.y - b.y).abs().toDouble();
}
김개발 씨는 적 캐릭터가 플레이어를 향해 직선으로만 이동하도록 구현했습니다. 처음에는 잘 작동하는 것 같았습니다.
하지만 맵에 벽과 장애물을 배치하자 문제가 드러났습니다. 적이 벽에 부딪히면 그대로 멈춰버리는 것이었습니다.
박시니어 씨가 화이트보드에 그림을 그리기 시작했습니다. "A*는 에이스타라고 읽어요.
길찾기의 정석이라고 할 수 있죠." A* 알고리즘을 이해하려면 먼저 그래프 탐색의 개념을 알아야 합니다. 게임 맵을 바둑판처럼 격자로 나눈다고 생각해보세요.
각 칸은 노드가 되고, 인접한 칸끼리는 간선으로 연결됩니다. 시작점에서 목표점까지 가는 방법은 무수히 많습니다.
A* 알고리즘은 그중에서 가장 짧은 경로를 효율적으로 찾아줍니다. A*의 핵심은 f = g + h 공식입니다.
여기서 g는 시작점에서 현재 노드까지 실제로 이동한 비용입니다. h는 현재 노드에서 목표점까지의 예상 비용으로, 이를 휴리스틱이라고 부릅니다.
f는 이 둘을 합한 값으로, 전체 경로의 예상 비용을 나타냅니다. A*는 항상 f값이 가장 낮은 노드부터 탐색합니다.
마치 내비게이션이 경로를 안내하는 것과 비슷합니다. 내비게이션도 현재까지 온 거리와 목적지까지 남은 거리를 모두 고려해서 최적의 경로를 추천합니다.
지금까지 30km를 왔고, 목적지까지 10km 남았다면 총 40km입니다. 다른 경로는 지금까지 20km를 왔지만 목적지까지 30km나 남았다면 총 50km입니다.
당연히 전자를 선택하겠죠. 코드의 흐름을 따라가 보겠습니다.
openList는 탐색할 노드들을 담는 리스트입니다. closedList는 이미 탐색을 마친 노드들입니다.
매 반복마다 openList에서 f값이 가장 낮은 노드를 꺼내서 처리합니다. 목표에 도달하면 parent를 따라가며 경로를 재구성합니다.
휴리스틱 함수 선택도 중요합니다. 코드에서는 맨해튼 거리를 사용했습니다.
이는 가로와 세로 이동만 가능할 때 적합합니다. 대각선 이동이 가능하다면 유클리드 거리나 체비셰프 거리를 사용하는 것이 좋습니다.
휴리스틱이 실제 비용보다 크면 최적 경로를 보장하지 못하므로 주의해야 합니다. 김개발 씨는 A* 알고리즘을 적용한 후 적들이 벽을 요리조리 피해가며 플레이어를 쫓아오는 것을 보고 뿌듯함을 느꼈습니다.
"이제 진짜 게임 같아요!"
실전 팁
💡 - 대규모 맵에서는 계층적 A*나 JPS(Jump Point Search)로 성능을 개선할 수 있습니다
- 휴리스틱은 항상 실제 비용 이하로 설정해야 최적 경로를 보장합니다
4. 내비게이션 메쉬
김개발 씨는 A* 알고리즘으로 길찾기를 구현했지만, 넓은 맵에서 성능 문제가 발생했습니다. "격자가 너무 많아서 계산이 오래 걸려요." 박시니어 씨가 고개를 끄덕였습니다.
"격자 기반은 한계가 있어요. 내비게이션 메쉬를 사용해볼까요?"
**내비게이션 메쉬(NavMesh)**는 이동 가능한 영역을 다각형으로 나눈 것입니다. 마치 지도에서 도로만 표시한 것처럼, 캐릭터가 걸을 수 있는 영역만 정의하여 길찾기 효율을 크게 높입니다.
현대 3D 게임에서 가장 널리 사용되는 길찾기 방식입니다.
다음 코드를 살펴봅시다.
// 내비게이션 메쉬의 폴리곤 정의
class NavPolygon {
final List<Vector2> vertices;
final List<NavPolygon> neighbors;
Vector2 center;
NavPolygon(this.vertices) : neighbors = [] {
center = vertices.reduce((a, b) => a + b) / vertices.length.toDouble();
}
bool containsPoint(Vector2 point) {
// 점이 폴리곤 내부에 있는지 확인
int crossings = 0;
for (int i = 0; i < vertices.length; i++) {
var v1 = vertices[i];
var v2 = vertices[(i + 1) % vertices.length];
if ((v1.y <= point.y && v2.y > point.y) ||
(v2.y <= point.y && v1.y > point.y)) {
double x = v1.x + (point.y - v1.y) / (v2.y - v1.y) * (v2.x - v1.x);
if (point.x < x) crossings++;
}
}
return crossings % 2 == 1;
}
}
// NavMesh 경로 탐색
class NavMesh {
final List<NavPolygon> polygons;
NavMesh(this.polygons);
List<Vector2> findPath(Vector2 start, Vector2 goal) {
var startPoly = _findPolygon(start);
var goalPoly = _findPolygon(goal);
// 폴리곤 단위로 A* 실행
var polyPath = _aStarOnPolygons(startPoly, goalPoly);
// 경로 스무딩 (펀넬 알고리즘)
return _smoothPath(polyPath, start, goal);
}
}
김개발 씨의 게임 맵은 1000x1000 크기였습니다. A* 알고리즘은 잘 작동했지만, 적이 여러 마리 등장하면 프레임이 뚝뚝 떨어졌습니다.
백만 개의 노드를 탐색해야 했기 때문입니다. 박시니어 씨가 새로운 접근법을 제안했습니다.
"격자로 나누지 말고, 이동 가능한 영역을 큰 다각형으로 나눠보세요. 그러면 노드 수가 확 줄어요." 내비게이션 메쉬는 발상의 전환에서 시작됩니다.
격자 기반 길찾기는 맵 전체를 작은 칸으로 나눕니다. 하지만 대부분의 게임 맵에서 이동 가능한 영역은 넓고 비어 있습니다.
이런 영역을 하나의 큰 다각형으로 표현하면 노드 수를 획기적으로 줄일 수 있습니다. 마치 지하철 노선도를 생각해보세요.
서울 지하철 2호선을 타고 강남에서 홍대까지 간다고 합시다. 실제로는 수많은 역을 지나지만, 환승 없이 간다면 중간 역들은 신경 쓸 필요가 없습니다.
내비게이션 메쉬도 마찬가지입니다. 넓은 빈 공간은 하나의 다각형으로 처리하고, 경계 부분만 신경 쓰면 됩니다.
NavMesh의 장점은 명확합니다. 1000x1000 격자는 백만 개의 노드가 필요하지만, 같은 맵을 NavMesh로 표현하면 수십~수백 개의 다각형으로 충분합니다.
A*를 다각형 단위로 실행하면 탐색 시간이 크게 단축됩니다. 코드에서 핵심적인 부분을 살펴보겠습니다.
NavPolygon 클래스는 각 다각형을 정의합니다. vertices는 다각형의 꼭짓점들이고, neighbors는 인접한 다각형들입니다.
containsPoint 메서드는 주어진 점이 다각형 내부에 있는지 판단합니다. NavMesh 클래스의 findPath는 먼저 시작점과 목표점이 속한 다각형을 찾고, 다각형 단위로 A*를 실행한 후, 펀넬 알고리즘으로 경로를 부드럽게 다듬습니다.
실제 게임 엔진에서는 NavMesh를 자동으로 생성해줍니다. Unity의 NavMesh 시스템이나 Unreal의 Navigation 시스템이 대표적입니다.
맵을 스캔해서 이동 가능한 영역을 자동으로 다각형화합니다. Flutter Flame에서는 직접 구현하거나 bonfire 같은 패키지를 활용할 수 있습니다.
김개발 씨는 NavMesh를 적용한 후 적 100마리가 동시에 길찾기를 해도 프레임이 유지되는 것을 보고 놀랐습니다.
실전 팁
💡 - NavMesh 생성은 게임 시작 전에 미리 해두고, 런타임에는 경로 탐색만 수행하세요
- 동적으로 변하는 장애물은 로컬 회피 알고리즘과 병행해서 처리하세요
5. 군집 행동
김개발 씨는 물고기 떼나 새 무리처럼 자연스럽게 움직이는 적 무리를 구현하고 싶었습니다. 하지만 각 개체마다 개별적으로 AI를 적용하니 움직임이 부자연스러웠습니다.
"왜 무리처럼 안 움직이고 각자 따로 노는 것 같죠?"
**군집 행동(Flocking)**은 간단한 규칙 세 가지만으로 자연스러운 무리 행동을 만들어내는 기법입니다. 분리, 정렬, 응집이라는 규칙을 조합하면 마치 살아있는 생명체 무리처럼 움직이는 AI를 구현할 수 있습니다.
다음 코드를 살펴봅시다.
class Boid {
Vector2 position;
Vector2 velocity;
Boid(this.position) : velocity = Vector2.zero();
// 군집 행동의 세 가지 규칙
Vector2 calculateSteering(List<Boid> neighbors) {
var separation = _separation(neighbors) * 1.5; // 분리
var alignment = _alignment(neighbors) * 1.0; // 정렬
var cohesion = _cohesion(neighbors) * 1.0; // 응집
return separation + alignment + cohesion;
}
// 분리: 너무 가까운 이웃과 거리 유지
Vector2 _separation(List<Boid> neighbors) {
var steer = Vector2.zero();
for (var other in neighbors) {
double d = position.distanceTo(other.position);
if (d > 0 && d < 25) {
var diff = (position - other.position).normalized() / d;
steer += diff;
}
}
return steer;
}
// 정렬: 이웃들과 같은 방향으로 이동
Vector2 _alignment(List<Boid> neighbors) {
var avgVelocity = Vector2.zero();
for (var other in neighbors) {
avgVelocity += other.velocity;
}
return neighbors.isEmpty ? avgVelocity : avgVelocity / neighbors.length.toDouble();
}
// 응집: 이웃들의 중심으로 이동
Vector2 _cohesion(List<Boid> neighbors) {
var center = Vector2.zero();
for (var other in neighbors) {
center += other.position;
}
if (neighbors.isEmpty) return center;
center /= neighbors.length.toDouble();
return (center - position).normalized();
}
}
김개발 씨는 영화에서 본 좀비 떼처럼 밀려오는 적 무리를 구현하고 싶었습니다. 처음에는 모든 적에게 똑같은 목표 지점을 주었습니다.
결과는 처참했습니다. 적들이 한 줄로 서서 기차놀이하듯 이동하거나, 같은 지점에 겹쳐서 뭉쳐버렸습니다.
박시니어 씨가 유명한 알고리즘을 소개했습니다. "1986년에 크레이그 레이놀즈가 발표한 Boids 알고리즘이 있어요.
놀랍게도 규칙이 세 개밖에 없어요." 첫 번째 규칙은 **분리(Separation)**입니다. 너무 가까이 있는 이웃과는 거리를 유지합니다.
사람들이 붐비는 지하철에서 무의식적으로 거리를 두려고 하는 것과 같습니다. 이 규칙이 없으면 모든 개체가 한 점에 뭉쳐버립니다.
두 번째 규칙은 **정렬(Alignment)**입니다. 주변 이웃들과 같은 방향으로 이동합니다.
고속도로에서 차들이 대체로 같은 방향으로 흘러가는 것처럼요. 이 규칙이 무리 전체가 일관된 방향으로 움직이게 만듭니다.
세 번째 규칙은 **응집(Cohesion)**입니다. 이웃들의 중심 쪽으로 이동합니다.
친구들과 걷다가 무의식적으로 무리 중앙으로 모이려는 행동과 같습니다. 이 규칙이 무리가 흩어지지 않고 함께 다니게 만듭니다.
이 세 가지 규칙만으로 놀라운 일이 일어납니다. 새 떼가 하늘을 날 때 보이는 복잡한 패턴, 물고기 떼가 포식자를 피해 갈라졌다가 다시 모이는 행동, 이 모든 것이 세 가지 단순한 규칙에서 창발합니다.
각 개체는 자신의 규칙만 따를 뿐인데, 전체적으로는 복잡하고 자연스러운 행동이 나타나는 것입니다. 코드에서 가중치 조절이 중요합니다.
separation에 1.5, alignment와 cohesion에 1.0을 곱한 것을 볼 수 있습니다. 이 가중치에 따라 무리의 성격이 달라집니다.
분리 가중치를 높이면 느슨한 무리가, 응집 가중치를 높이면 빽빽한 무리가 됩니다. 게임에 맞게 튜닝해야 합니다.
김개발 씨는 군집 행동을 적용한 적 무리를 보고 감탄했습니다. "마치 진짜 살아있는 것 같아요!"
실전 팁
💡 - 이웃 탐색에 공간 분할(쿼드트리) 자료구조를 사용하면 성능이 크게 향상됩니다
- 리더를 지정하고 나머지는 리더를 따르게 하면 더 통제된 군집 행동을 구현할 수 있습니다
6. 적응형 난이도
김개발 씨의 게임이 출시되었습니다. 하지만 리뷰가 극과 극으로 갈렸습니다.
"너무 쉬워요"라는 고수 플레이어와 "너무 어려워요"라는 초보 플레이어. 모두를 만족시킬 방법이 없을까요?
**적응형 난이도(DDA, Dynamic Difficulty Adjustment)**는 플레이어의 실력에 맞춰 게임 난이도를 실시간으로 조절하는 시스템입니다. 마치 좋은 선생님이 학생의 수준에 맞춰 설명 방식을 바꾸는 것처럼, 게임도 플레이어에게 적절한 도전을 제공합니다.
다음 코드를 살펴봅시다.
class DynamicDifficulty {
double difficultyLevel = 0.5; // 0.0 ~ 1.0
// 플레이어 성과 지표
int recentDeaths = 0;
int recentKills = 0;
double averageHealthOnClear = 0.5;
// 난이도 조절 (매 스테이지 종료 시 호출)
void adjustDifficulty() {
double performanceScore = _calculatePerformance();
// 부드러운 난이도 조절
if (performanceScore > 0.7) {
// 잘하고 있음 - 난이도 상승
difficultyLevel = (difficultyLevel + 0.05).clamp(0.0, 1.0);
} else if (performanceScore < 0.3) {
// 어려워하고 있음 - 난이도 하락
difficultyLevel = (difficultyLevel - 0.08).clamp(0.0, 1.0);
}
// 지표 리셋
recentDeaths = 0;
recentKills = 0;
}
double _calculatePerformance() {
double deathPenalty = recentDeaths * 0.15;
double killBonus = (recentKills / 10).clamp(0.0, 0.5);
double healthBonus = averageHealthOnClear * 0.3;
return (killBonus + healthBonus - deathPenalty).clamp(0.0, 1.0);
}
// 난이도에 따른 적 스탯 조절
double getEnemyHealthMultiplier() => 0.7 + (difficultyLevel * 0.6);
double getEnemyDamageMultiplier() => 0.8 + (difficultyLevel * 0.4);
int getEnemySpawnCount(int base) => (base * (0.8 + difficultyLevel * 0.5)).round();
}
게임 개발에서 난이도 설정은 영원한 숙제입니다. 너무 쉬우면 지루하고, 너무 어려우면 포기하게 됩니다.
문제는 플레이어마다 실력 차이가 크다는 것입니다. 박시니어 씨가 업계의 해결책을 소개했습니다.
"레지던트 이블 4가 유명해요. 플레이어가 죽으면 적이 약해지고, 너무 잘하면 적이 강해지죠.
플레이어 대부분은 눈치채지도 못해요." 적응형 난이도의 핵심은 투명성입니다. 플레이어에게 "당신이 못해서 난이도를 낮췄습니다"라고 알리면 기분이 나쁠 것입니다.
좋은 DDA 시스템은 플레이어가 인지하지 못하게 자연스럽게 조절합니다. 적의 체력을 10% 낮추거나, 아이템 드롭률을 약간 높이는 식으로요.
어떤 지표를 측정해야 할까요? 사망 횟수는 가장 직관적인 지표입니다.
같은 구간에서 반복해서 죽는다면 어려워하고 있다는 신호입니다. 클리어 시 남은 체력도 중요합니다.
체력 1%로 간신히 클리어했다면 힘겨웠다는 의미이고, 체력 90%로 클리어했다면 쉬웠다는 의미입니다. 플레이 시간도 참고할 수 있습니다.
코드의 핵심 부분을 살펴보겠습니다. difficultyLevel은 0.0에서 1.0 사이의 값으로 현재 난이도를 나타냅니다.
adjustDifficulty는 플레이어 성과를 계산하고 난이도를 조절합니다. 중요한 점은 난이도 상승보다 하락 폭이 더 크다는 것입니다.
0.05 상승, 0.08 하락으로 설정되어 있습니다. 이는 플레이어가 좌절하는 것을 방지하기 위함입니다.
난이도를 적용하는 방법도 다양합니다. 코드에서는 적의 체력 배율, 공격력 배율, 스폰 수를 조절하고 있습니다.
이 외에도 적의 반응 속도를 늦추거나, 패턴을 단순화하거나, 체력 회복 아이템을 더 많이 드롭하는 방법도 있습니다. 주의할 점도 있습니다.
난이도 변화가 너무 급격하면 플레이어가 이상함을 느낍니다. 갑자기 적이 확 약해지거나 강해지면 몰입이 깨집니다.
또한 일부 하드코어 게이머는 DDA를 싫어합니다. 옵션에서 끌 수 있게 하는 것도 고려해야 합니다.
김개발 씨는 적응형 난이도를 적용한 후 리뷰가 개선되는 것을 확인했습니다. "이제 초보자도 고수도 재미있다고 해요!"
실전 팁
💡 - 난이도 조절은 눈에 띄지 않는 요소(적 반응 속도, 아이템 드롭률)부터 시작하세요
- 최소/최대 난이도 한계를 설정하여 너무 쉽거나 어려워지는 것을 방지하세요
이상으로 학습을 마칩니다. 위 내용을 직접 코드로 작성해보면서 익혀보세요!
댓글 (0)
함께 보면 좋은 카드 뉴스
AAA급 게임 프로젝트 완벽 가이드
Flutter와 Flame 엔진을 활용하여 AAA급 퀄리티의 모바일 게임을 개발하는 전체 과정을 다룹니다. 기획부터 앱 스토어 출시까지, 실무에서 필요한 모든 단계를 이북처럼 술술 읽히는 스타일로 설명합니다.
빌드와 배포 자동화 완벽 가이드
Flutter 앱 개발에서 GitHub Actions를 활용한 CI/CD 파이프라인 구축부터 앱 스토어 자동 배포까지, 초급 개발자도 쉽게 따라할 수 있는 빌드 자동화의 모든 것을 다룹니다.
게임 분석과 메트릭스 완벽 가이드
Flutter와 Flame으로 개발한 게임의 성공을 측정하고 개선하는 방법을 배웁니다. Firebase Analytics 연동부터 A/B 테스팅, 리텐션 분석까지 데이터 기반 게임 운영의 모든 것을 다룹니다.
게임 보안과 치팅 방지 완벽 가이드
Flutter와 Flame 게임 엔진에서 클라이언트 보안부터 서버 검증까지, 치터들로부터 게임을 보호하는 핵심 기법을 다룹니다. 초급 개발자도 쉽게 따라할 수 있는 실전 보안 코드와 함께 설명합니다.
애니메이션 시스템 커스터마이징 완벽 가이드
Flutter와 Flame 게임 엔진에서 고급 애니메이션 시스템을 구현하는 방법을 다룹니다. 스켈레탈 애니메이션부터 절차적 애니메이션까지, 게임 개발에 필요한 핵심 애니메이션 기법을 실무 예제와 함께 배워봅니다.