← 블로그 목록 2025.02.15

게임 AI 설계: 스캐브의 3단계 행동 패턴

Escape from Dogkov에는 맵 전역에 10명의 AI 스캐브가 배치되어 있습니다. 스캐브는 플레이어 외의 유일한 위협 요소로, 게임의 긴장감과 난이도를 결정합니다. 너무 똑똑하면 점심시간에 즐기기엔 스트레스가 크고, 너무 멍청하면 재미가 없습니다. 이 글에서는 "적당히 위협적인" AI를 만들기 위해 사용한 유한 상태 머신(FSM) 설계를 소개합니다.

유한 상태 머신 (FSM)

스캐브 AI의 기본 구조는 3개의 상태를 가진 유한 상태 머신입니다:

상태 전이 조건은 다음과 같습니다:

정찰 → 경계:  반경 700px 이내에서 플레이어 발견 (시야 확보 시)
정찰 → 어그로: 플레이어에게 피격당함
경계 → 어그로: 플레이어에게 피격당함 또는 5초간 플레이어 추적
어그로 → 정찰: 8초간 플레이어를 발견하지 못함
경계 → 정찰: 5초간 위협이 사라짐

정찰 모드: 자연스러운 배회

정찰 모드의 목표는 "맵을 배회하는 자연스러운 NPC"처럼 보이게 하는 것입니다. 완전 랜덤 이동을 하면 불규칙하게 떨리는 느낌이 나므로, 목표 지점 기반 이동을 사용합니다:

// 정찰 행동
if (!scav.patrolTarget || reachedTarget(scav)) {
    // 새 목표 지점 설정 (현재 위치에서 반경 400px 이내)
    scav.patrolTarget = {
        x: clamp(scav.x + (Math.random() - 0.5) * 400, 80, 3920),
        y: clamp(scav.y + (Math.random() - 0.5) * 400, 80, 2920)
    };
    // 도착 후 1.5~4.5초 대기
    scav.patrolWait = 1.5 + Math.random() * 3;
}

if (scav.patrolWait > 0) {
    scav.patrolWait -= dt;  // 대기 중
} else {
    // 목표 지점으로 느리게 이동 (속도 30)
    moveTowards(scav, scav.patrolTarget, 30 * dt);
}

핵심은 대기 시간입니다. 목표 지점에 도착하면 1.5~4.5초 멈춰 있다가 다음 지점으로 이동합니다. 이 대기가 없으면 스캐브가 쉴 새 없이 왔다 갔다 하며 불자연스러워 보입니다.

시야 판정: 벽 뒤는 못 본다

스캐브가 플레이어를 발견하려면 두 가지 조건이 충족되어야 합니다:

  1. 거리가 700px 이내
  2. 스캐브와 플레이어 사이에 벽이 없음 (시야 확보)

시야 차단 판정은 레이캐스팅으로 구현합니다. 스캐브에서 플레이어까지 직선을 그어, 중간에 벽이 있는지 검사합니다:

function hasLineOfSight(from, to, walls) {
    const dx = to.x - from.x;
    const dy = to.y - from.y;
    const dist = Math.hypot(dx, dy);
    const steps = Math.ceil(dist / 20);  // 20px 간격으로 샘플링

    for (let i = 1; i < steps; i++) {
        const t = i / steps;
        const checkX = from.x + dx * t;
        const checkY = from.y + dy * t;

        for (let wall of walls) {
            if (checkX > wall.x && checkX < wall.x + wall.w &&
                checkY > wall.y && checkY < wall.y + wall.h) {
                return false;  // 벽에 막힘
            }
        }
    }
    return true;  // 시야 확보
}

이 판정 덕분에 방 안에 숨어있으면 스캐브가 알아채지 못합니다. 문 앞에서 매복하다가 지나가는 스캐브를 급습하는 플레이가 가능해지며, 전술적 깊이가 생깁니다.

어그로 전파: 동료 호출

한 스캐브가 공격당하면 반경 400px 이내의 다른 스캐브들도 함께 어그로 상태가 됩니다:

// 스캐브가 피격당했을 때
scav.aggro = true;
scav.aggroX = attacker.x;
scav.aggroY = attacker.y;
scav.aggroTime = Date.now();

// 근처 스캐브에게 경고
const alertRange = 400;
enemies.forEach(e => {
    if (e.dead) return;
    const dist = Math.hypot(scav.x - e.x, scav.y - e.y);
    if (dist < alertRange) {
        e.aggro = true;
        e.aggroX = attacker.x;
        e.aggroY = attacker.y;
        e.aggroTime = Date.now();
    }
});

이 메커니즘은 "한 명만 조용히 처리"하는 전략을 어렵게 만듭니다. 스캐브가 밀집된 구역에서 전투를 시작하면 여러 명이 한꺼번에 몰려올 수 있어, 교전 지점을 신중하게 선택해야 합니다.

어그로 모드: 추적과 사격

어그로 상태의 스캐브는 공격자의 마지막 알려진 위치로 이동하면서 사격합니다. 단, 150px 이내로 접근하면 이동을 멈추고 사격에 집중합니다:

if (isAggro) {
    const targetX = scav.aggroX;
    const targetY = scav.aggroY;
    scav.angle = Math.atan2(targetY - scav.y, targetX - scav.x);

    if (distToTarget > 150) {
        // 적에게 접근 (속도 55 - 정찰보다 빠름)
        moveTowards(scav, {x: targetX, y: targetY}, 55 * dt);
    }

    // 사격 (쿨다운 800ms, 정확도에 랜덤 오차 추가)
    if (now - scav.lastShot > 800) {
        const spread = (Math.random() - 0.5) * 0.3;  // ±0.15 라디안 오차
        shoot(scav, scav.angle + spread);
        scav.lastShot = now;
    }
}

사격에 랜덤 오차(spread)를 추가한 것이 중요합니다. 오차 없이 정확히 조준하면 스캐브가 매 발 명중시켜 불공평하게 느껴집니다. ±0.15 라디안(약 ±8.6도)의 오차를 주면 "스캐브도 사람처럼 빗맞힌다"는 느낌을 줍니다.

특수 모드: 탈출 시 전체 돌격

게임의 가장 극적인 순간은 탈출 미션입니다. 플레이어가 탈출 지점에서 F키를 누르면 20초 카운트다운이 시작되고, 살아있는 모든 스캐브가 탈출 지점으로 돌격합니다:

// 탈출 미션 시작 시
function onExtractionStart(extractPoint) {
    enemies.forEach(e => {
        if (e.dead) return;
        e.aggro = true;
        e.aggroX = extractPoint.x;
        e.aggroY = extractPoint.y;
        e.aggroTime = Date.now() + 30000;  // 30초간 유지
    });
}

이 메커니즘은 탈출 전까지 스캐브를 가능한 많이 처치하도록 유도합니다. 스캐브 8명이 살아있는 상태에서 탈출 미션을 시작하면 사방에서 몰려오는 적들을 20초간 막아내야 합니다. 반면 미리 처치해두면 탈출이 훨씬 수월합니다.

밸런스 조정 포인트

스캐브 AI의 난이도를 조절하는 핵심 변수들:

마치며

복잡한 AI 알고리즘 없이도 3단계 상태 머신만으로 충분히 재미있는 적 AI를 구현할 수 있습니다. 핵심은 각 상태에서의 행동을 명확하게 정의하고, 상태 전이 조건을 게임플레이에 맞게 튜닝하는 것입니다. A* 같은 고급 경로 탐색은 오버킬이었고, 단순한 "목표 지점으로 직선 이동 + 벽 충돌 시 새 목표"로도 자연스러운 움직임이 나왔습니다.

← 이전 글: 인벤토리 시스템 다음 글: 안개 전쟁 구현 →