HTML5 Canvas로 2D FPS 엔진 만들기
Escape from Dogkov는 별도의 게임 엔진 없이 HTML5 Canvas 2D API만으로 만든 탑뷰 FPS 게임입니다. Unity나 Phaser 같은 프레임워크를 사용하지 않은 이유는 간단합니다. 점심시간에 브라우저 탭 하나로 바로 실행되어야 하고, 로딩 시간이 길면 안 되기 때문입니다. 이 글에서는 Canvas 2D만으로 FPS 게임 엔진을 구현한 핵심 과정을 정리합니다.
게임 루프: requestAnimationFrame
모든 실시간 게임의 기본은 게임 루프입니다. 브라우저에서 게임 루프를 구현하는 가장 좋은 방법은 requestAnimationFrame(이하 rAF)을 사용하는 것입니다. setInterval과 달리 rAF는 브라우저의 화면 주사율에 맞춰 호출되므로 부드러운 60fps 렌더링이 가능합니다.
기본 구조는 다음과 같습니다:
let lastTime = 0;
function gameLoop(timestamp) {
const dt = (timestamp - lastTime) / 1000;
lastTime = timestamp;
update(dt); // 게임 로직 업데이트
render(); // 화면 그리기
requestAnimationFrame(gameLoop);
}
requestAnimationFrame(gameLoop);
여기서 핵심은 delta time(dt)입니다. 프레임 간 시간 차이를 계산하여 이동 속도 등에 곱해주면, 프레임 레이트가 변동해도 일정한 게임 속도를 유지할 수 있습니다. 60fps에서 dt는 약 0.0167초, 30fps에서는 약 0.033초가 됩니다.
카메라 시스템: 플레이어 추적
Escape from Dogkov의 맵은 4000x3000 픽셀이지만, 화면에 보이는 영역은 브라우저 뷰포트 크기입니다. 플레이어가 이동하면 카메라가 따라가야 합니다. 이를 위해 Canvas의 translate()를 활용했습니다.
function render() {
ctx.save();
// 카메라 오프셋 계산 (플레이어를 화면 중앙에 위치)
const camX = canvas.width / 2 - player.x;
const camY = canvas.height / 2 - player.y;
ctx.translate(camX, camY);
// 월드 좌표계에서 그리기
drawMap();
drawEnemies();
drawPlayers();
drawBullets();
ctx.restore();
// 화면 좌표계에서 UI 그리기 (미니맵, 체력바 등)
drawHUD();
}
ctx.save()와 ctx.restore()로 변환 상태를 관리합니다. 월드에 존재하는 오브젝트(벽, 적, 아이템)는 translate 적용 후에 그리고, HUD(체력바, 미니맵, 탄약 표시)는 restore 이후에 그려서 화면에 고정시킵니다.
충돌 처리: AABB 방식
Dogkov의 벽과 장애물은 모두 직사각형입니다. 따라서 가장 단순하면서도 효율적인 AABB(Axis-Aligned Bounding Box) 충돌 검사를 사용했습니다.
function checkCollision(x, y, radius, wall) {
return x + radius > wall.x &&
x - radius < wall.x + wall.w &&
y + radius > wall.y &&
y - radius < wall.y + wall.h;
}
플레이어 이동 시에는 X축과 Y축을 분리해서 처리하는 것이 핵심입니다. X를 먼저 이동시켜 충돌을 검사하고, 그 다음 Y를 이동시켜 검사합니다. 이렇게 하면 벽을 따라 미끄러지는 자연스러운 이동이 가능합니다.
// X축 이동
let nextX = player.x + dx;
let colX = false;
for (let wall of walls) {
if (checkCollision(nextX, player.y, 15, wall)) {
colX = true;
break;
}
}
if (!colX) player.x = nextX;
// Y축 이동 (독립적으로 처리)
let nextY = player.y + dy;
let colY = false;
for (let wall of walls) {
if (checkCollision(player.x, nextY, 15, wall)) {
colY = true;
break;
}
}
if (!colY) player.y = nextY;
만약 X, Y를 동시에 이동시켜 충돌을 검사하면, 벽 모서리 근처에서 완전히 멈춰버리는 문제가 발생합니다. 분리 처리를 적용하면 대각선으로 벽에 부딪혀도 한쪽 축은 계속 이동할 수 있어 조작감이 훨씬 좋아집니다.
렌더링 파이프라인
Canvas 2D는 즉시 모드(immediate mode) 렌더링입니다. 매 프레임마다 화면을 지우고 모든 것을 다시 그려야 합니다. 렌더링 순서가 곧 Z-order(깊이)가 되므로, 그리는 순서를 신중하게 설계해야 합니다.
Dogkov의 렌더링 순서:
- 바닥 — 맵 전체 배경색
- 벽과 장애물 — 방 벽, 차량, 바위 등
- 루트박스 — 아이템이 들어있는 상자
- 킬 드롭 배낭 — 스캐브 처치 시 떨어지는 배낭
- 탈출 지점 — 맵 중앙의 파란색 원
- 캐릭터 — 플레이어와 스캐브 (Y좌표 기준 정렬)
- 총알 — 발사된 투사체들
- 이펙트 — 총구 화염, 피격 이펙트, 탄흔
- 안개 전쟁 — 시야 밖 영역을 덮는 어둠
- HUD — 체력바, 탄약, 미니맵, 킬 로그
특히 캐릭터 렌더링에서는 Y좌표가 낮은(위쪽) 캐릭터를 먼저 그려서, 아래쪽 캐릭터가 위쪽 캐릭터를 살짝 가리는 자연스러운 깊이감을 만들었습니다.
성능 최적화: 뷰포트 컬링
4000x3000 맵에는 수십 개의 벽, 장애물, 아이템이 존재합니다. 이걸 매 프레임 모두 그리면 성능이 저하됩니다. 해결책은 뷰포트 컬링입니다. 화면에 보이는 영역 밖의 오브젝트는 그리기를 건너뛰는 것입니다.
function isVisible(obj, camX, camY) {
const margin = 100; // 여유 마진
return obj.x + obj.w > -camX - margin &&
obj.x < -camX + canvas.width + margin &&
obj.y + obj.h > -camY - margin &&
obj.y < -camY + canvas.height + margin;
}
// 벽 그리기 시
walls.forEach(wall => {
if (isVisible(wall, camX, camY)) {
ctx.fillRect(wall.x, wall.y, wall.w, wall.h);
}
});
이 간단한 최적화만으로도 렌더링 대상이 약 30~40% 줄어들어 체감 성능이 크게 향상됩니다.
입력 처리: 한글 IME 대응
웹 게임에서 의외로 까다로운 부분이 한글 IME 입력 처리입니다. 한국 사용자가 한글 모드에서 WASD를 누르면 실제로는 ㅈ, ㅁ, ㄴ, ㅇ이 입력됩니다. keydown 이벤트의 event.key를 사용하면 이 문제를 해결할 수 없습니다.
해결책은 event.code를 사용하는 것입니다:
document.addEventListener('keydown', (e) => {
// event.key 대신 event.code 사용
switch (e.code) {
case 'KeyW': input.w = true; break;
case 'KeyA': input.a = true; break;
case 'KeyS': input.s = true; break;
case 'KeyD': input.d = true; break;
case 'KeyR': reload(); break;
case 'KeyE': switchFireMode(); break;
case 'KeyF': interact(); break;
case 'KeyB': toggleInventory(); break;
}
});
event.code는 물리적 키 위치를 나타내므로, 어떤 IME 모드에서든 동일하게 작동합니다. 한글 사용자라면 이 방식이 필수입니다.
마치며
HTML5 Canvas 2D API만으로도 충분히 플레이 가능한 FPS 게임을 만들 수 있습니다. 핵심은 게임 루프의 delta time 처리, 분리 축 충돌 검사, 카메라 translate, 뷰포트 컬링입니다. 물론 WebGL이나 전용 엔진에 비하면 한계가 있지만, "브라우저에서 바로 실행"이라는 장점은 이 모든 것을 상쇄합니다. 다음 글에서는 Socket.io를 활용한 실시간 멀티플레이 구현에 대해 다루겠습니다.