← 블로그 목록 2026-05-19

Bot AI: 거리 밴드별 무기 선택 + LoS 추적

혼자 매치를 들어가도 빈 방이 적적하지 않게 봇을 채울 수 있게 했습니다. 방장이 대기실에서 "봇 추가" 한 번에 한 명씩. 봇 AI는 100줄 정도로 짧지만 "그럴듯한" 상대 역할을 합니다.

봇은 어떻게 sim에 들어가나

봇은 별도 클라이언트 프로세스가 아닙니다. 호스트 노드 내부에 사는 가짜 플레이어:

room.addBot();   // 호스트가 룸에 봇 추가
// → sim.addPlayer({ id: 'bot_5', name: 'BOT-5', isBot: true })
// 봇의 input 객체는 클라이언트가 아닌 stepBot()이 매 tick 채움.

다른 플레이어 입장에선 봇과 진짜 사용자가 구분 불가 (이름 옆 [BOT] 태그만 다름).

매 tick 봇이 하는 일

function stepBot(p, sim, now) {
  // 1) 가장 가까운 visible enemy 찾기
  let target = null, bestD = Infinity;
  for (const q of sim.players.values()) {
    if (q.id === p.id || q.dead) continue;
    const d = Math.hypot(q.x - p.x, q.y - p.y);
    if (d > SIGHT_RANGE) continue;
    // LoS 체크: 벽이 가로막으면 못 봄
    const hit = castRay(p.x, p.y, q.x, q.y, sim.walls, null, p.id);
    if (hit && hit.t < 0.98) continue;
    if (d < bestD) { bestD = d; target = q; }
  }

  if (target) {
    // 2) 약간의 lead aim
    const aimX = target.x + target.vx * 0.10;
    const aimY = target.y + target.vy * 0.10;
    const desired = Math.atan2(aimY - p.y, aimX - p.x);
    // 각도 smooth (max 0.45 rad/tick)
    let da = desired - p.input.angle;
    while (da > PI) da -= PI*2; while (da < -PI) da += PI*2;
    p.input.angle = p.input.angle + Math.max(-0.45, Math.min(0.45, da));
    p.input.fire = Math.abs(da) < 0.20;  // 대충 조준 되면 사격

    // 3) perpendicular strafe (좌우 회피 무빙)
    moveTowards(p, sideStep);

    // 4) 거리 밴드별 무기 선택
    maybeSwitch(p, pickWeapon(p, bestD));
  } else {
    // wander: 가까운 픽업 또는 랜덤 셀로 이동
    if (!ai.dest || now - ai.lastDecisionAt > 500) {
      ai.dest = pickWanderDest(p, sim);
      ai.lastDecisionAt = now;
    }
    moveTowards(p, ai.dest);
  }
}

무기 선택 함수

function pickWeapon(p, d) {
  let pref;
  if (d < 60)        pref = ['gauntlet', 'shotgun', 'machinegun'];
  else if (d < 220)  pref = ['shotgun', 'plasma', 'machinegun', 'rocket'];
  else if (d < 500)  pref = ['rocket', 'plasma', 'grenade', 'lightning', 'machinegun', 'bfg'];
  else if (d < 800)  pref = ['rocket', 'lightning', 'plasma', 'railgun', 'machinegun', 'bfg'];
  else               pref = ['railgun', 'lightning', 'machinegun', 'rocket', 'bfg'];
  for (const k of pref) {
    if (!p.hasWeapon[k]) continue;
    if (isFinite(WEAPON_DEFS[k].maxAmmo) && p.weapons[k] <= 0) continue;
    return k;
  }
  return 'machinegun';
}

봇이 마법적으로 잘 쏴서가 아니라 무기 선택이 사람답기 때문에 "괜찮은" 봇처럼 보입니다. 가까이서 레일건 쏘는 봇은 멍청해 보이지만, 가까이서 샷건 쓰는 봇은 위협적입니다.

Strafe 무빙

봇이 일직선으로만 다가오면 잡기 너무 쉽습니다. perpendicular 방향으로 좌우 strafe를 추가:

const strafeDir = Math.sin(now * 0.002 + seed) > 0 ? 1 : -1;
const px = -Math.sin(desired), py = Math.cos(desired);
moveTowards(p, { x: p.x + px * strafeDir * 100, y: p.y + py * strafeDir * 100 });

매 500ms마다 방향이 바뀌는 sine. 봇별로 seed를 다르게 줘서 동기화되지 않게.

Wander: 픽업 방향으로

적이 없을 땐 60% 확률로 가장 가까운 픽업으로 향합니다. 나머지 40%는 랜덤 좌표:

function pickWanderDest(p, sim) {
  const pickups = [...sim.pickups.values()].filter(pk => pk.available);
  if (pickups.length && Math.random() < 0.6) {
    pickups.sort((a,b) => dist(a.def, p) - dist(b.def, p));
    return { x: pickups[0].def.x, y: pickups[0].def.y };
  }
  return { x: 100 + Math.random()*2200, y: 100 + Math.random()*1400 };
}

한계 & 개선 여지

현재 수준: "5분 매치에서 적당히 킬도 빼앗고, 가끔 사고도 치는" 동료.

← 이펙트 시스템 Fly.io 배포 →