← 블로그 목록
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 };
}
한계 & 개선 여지
- 도지 무빙은 단순 sine — 진짜 회피는 못 함
- 로켓점프 불가
- 파워업 우선순위 없음 — Quad가 떠도 모르면 그냥 지나감
- 스플래시 견제 없음 — 코너 너머로 로켓을 안 쏨
- 차후 개선 후보: A* path finding, 파워업 타이밍 학습, 다른 봇과 분업
현재 수준: "5분 매치에서 적당히 킬도 빼앗고, 가끔 사고도 치는" 동료.