HTML 미니게임 만들기: 키보드·터치 조작을 함께 넣는 방법

설치 없이 실행되는 작은 브라우저 게임은 HTML 파일 하나로 만들 수 있어요. PC의 방향키와 휴대폰의 터치를 함께 지원하려면 두 입력을 따로 움직임에 연결하지 말고, 둘 다 같은 input 상태를 바꾸게 만드는 것이 핵심입니다. 아래 예제는 사각형을 별까지 움직이는 짧은 게임이지만, 충돌·점수·다시 시작까지 갖춰 다음 게임의 뼈대로 쓰기 좋습니다.

이 글의 기준일은 2026년 9월 21일입니다. 별도 라이브러리나 서버 없이 최신 브라우저에서 열 수 있는 순수 HTML·CSS·JavaScript 예제를 다룹니다. 기존 글인 작은 웹게임의 파일 분리 기준은 프로젝트가 커질 때 참고하고, 여기서는 우선 한 파일로 완성해 봅니다.

완성할 게임은 어떻게 움직일까?

화면 안의 파란 사각형이 플레이어이고 노란 원이 목표입니다. 방향키나 화면의 네 방향 버튼을 누르면 플레이어가 움직입니다. 목표에 닿으면 점수가 하나 올라가고 목표가 다른 위치로 옮겨집니다. 제한 시간은 두지 않아 입력 처리와 충돌 판정만 차분히 확인할 수 있어요.

입력 경로는 둘이지만 게임이 읽는 값은 하나입니다. 키보드의 keydownkeyup, 터치 버튼의 pointerdownpointerup이 모두 input.left 같은 불리언 값을 바꿉니다. 게임 반복문은 입력 장치가 무엇인지 몰라도 현재 방향만 읽어 이동합니다. 이 구조를 쓰면 나중에 게임패드나 화면 스와이프를 추가해도 이동 공식을 다시 만들 필요가 없습니다.

HTML 파일 하나에 필요한 네 부분

빈 파일을 만들고 이름을 star-catch.html로 저장합니다. 확장자가 .txt로 붙지 않았는지 먼저 확인하세요. 파일 안에는 화면을 그릴 canvas, 점수 표시, 터치 버튼, 그리고 게임 로직이 들어갑니다.

  • canvas: 플레이어와 목표를 그리는 2차원 화면입니다.
  • 상태: 플레이어 위치, 목표 위치, 점수와 눌린 방향을 기억합니다.
  • 입력: 키보드와 포인터 이벤트를 같은 방향 상태에 연결합니다.
  • 반복: requestAnimationFrame()으로 위치를 갱신하고 다시 그립니다.

MDN은 requestAnimationFrame()이 다음 화면 갱신 전에 콜백을 실행하도록 브라우저에 요청한다고 설명합니다. 애니메이션 속도가 지나치게 빠른 화면에서 달라지지 않도록, 예제는 프레임 수가 아니라 경과 시간 dt를 이동 거리에 곱합니다.

그대로 저장해 실행할 수 있는 전체 코드

<!doctype html>
<html lang="ko">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width,initial-scale=1">
  <title>별 잡기</title>
  <style>
    body { font-family: system-ui, sans-serif; background:#f8fafc; color:#0f172a; }
    main { max-width:640px; margin:24px auto; padding:16px; }
    canvas { width:100%; aspect-ratio:4/3; background:#e0f2fe;
      border:2px solid #0f172a; border-radius:12px; display:block; }
    .pad { display:grid; grid-template-columns:repeat(3,72px);
      gap:8px; justify-content:center; margin-top:16px; }
    button { min-height:52px; font-size:24px; touch-action:none; }
    [data-dir="up"] { grid-column:2; }
    [data-dir="left"] { grid-column:1; grid-row:2; }
    [data-dir="down"] { grid-column:2; grid-row:2; }
    [data-dir="right"] { grid-column:3; grid-row:2; }
  </style>
</head>
<body>
<main>
  <p>점수: <strong id="score">0</strong></p>
  <canvas id="game" width="640" height="480" aria-label="별 잡기 게임 화면"></canvas>
  <div class="pad" aria-label="이동 버튼">
    <button data-dir="up" aria-label="위로 이동">▲</button>
    <button data-dir="left" aria-label="왼쪽 이동">◀</button>
    <button data-dir="down" aria-label="아래로 이동">▼</button>
    <button data-dir="right" aria-label="오른쪽 이동">▶</button>
  </div>
</main>
<script>
const canvas = document.querySelector('#game');
const ctx = canvas.getContext('2d');
const scoreEl = document.querySelector('#score');
const input = { left:false, right:false, up:false, down:false };
const player = { x:40, y:40, size:34, speed:230 };
const target = { x:420, y:260, radius:16 };
let score = 0;
let previous = performance.now();

const keyMap = {
  ArrowLeft:'left', ArrowRight:'right', ArrowUp:'up', ArrowDown:'down'
};
function setKey(event, pressed) {
  const direction = keyMap[event.key];
  if (!direction) return;
  event.preventDefault();
  input[direction] = pressed;
}
window.addEventListener('keydown', event => setKey(event, true));
window.addEventListener('keyup', event => setKey(event, false));

document.querySelectorAll('[data-dir]').forEach(button => {
  const direction = button.dataset.dir;
  const release = event => { event.preventDefault(); input[direction] = false; };
  button.addEventListener('pointerdown', event => {
    event.preventDefault();
    button.setPointerCapture(event.pointerId);
    input[direction] = true;
  });
  button.addEventListener('pointerup', release);
  button.addEventListener('pointercancel', release);
  button.addEventListener('lostpointercapture', release);
});

function move(dt) {
  let dx = Number(input.right) - Number(input.left);
  let dy = Number(input.down) - Number(input.up);
  if (dx && dy) { dx *= Math.SQRT1_2; dy *= Math.SQRT1_2; }
  player.x += dx * player.speed * dt;
  player.y += dy * player.speed * dt;
  player.x = Math.max(0, Math.min(canvas.width - player.size, player.x));
  player.y = Math.max(0, Math.min(canvas.height - player.size, player.y));
}

function touchesTarget() {
  const cx = player.x + player.size / 2;
  const cy = player.y + player.size / 2;
  return Math.hypot(cx - target.x, cy - target.y) < player.size / 2 + target.radius;
}
function placeTarget() {
  target.x = 24 + Math.random() * (canvas.width - 48);
  target.y = 24 + Math.random() * (canvas.height - 48);
}
function draw() {
  ctx.clearRect(0, 0, canvas.width, canvas.height);
  ctx.fillStyle = '#facc15';
  ctx.beginPath(); ctx.arc(target.x, target.y, target.radius, 0, Math.PI * 2); ctx.fill();
  ctx.fillStyle = '#2563eb';
  ctx.fillRect(player.x, player.y, player.size, player.size);
}
function loop(now) {
  const dt = Math.min((now - previous) / 1000, 0.05);
  previous = now;
  move(dt);
  if (touchesTarget()) { score += 1; scoreEl.textContent = score; placeTarget(); }
  draw();
  requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
</script>
</body>
</html>

키보드가 페이지까지 움직일 때 고치는 곳

방향키를 눌렀는데 플레이어와 함께 페이지가 스크롤된다면 브라우저의 기본 동작도 실행된 것입니다. 예제의 setKey()는 방향키로 확인된 경우에만 event.preventDefault()를 호출합니다. 모든 키 입력을 막지 않기 때문에 다른 입력 요소를 추가해도 예상 밖의 방해를 줄일 수 있습니다.

KeyboardEvent.key는 눌린 키의 값을 제공합니다. 오래된 숫자 키 코드 대신 ArrowLeft처럼 읽을 수 있는 값을 사용하면 의도가 분명해집니다. 키를 누르고 있는 동안 운영체제가 keydown을 반복해서 보내더라도, 이 게임은 이동 명령을 누적하지 않고 상태를 true로 유지하므로 속도가 갑자기 빨라지지 않습니다.

터치는 click보다 Pointer Events가 편한 이유

한 번 탭하는 메뉴라면 click만으로 충분하지만, 게임 방향 버튼은 누르고 있는 시간과 손가락을 떼는 순간을 알아야 합니다. Pointer Events는 마우스·펜·손가락을 같은 이벤트 모델로 다룹니다. MDN의 Pointer Events 안내사용 예제도 장치와 무관한 포인터 입력이라는 점을 설명합니다.

setPointerCapture()를 쓰면 손가락이 버튼 가장자리 밖으로 조금 움직여도 해당 포인터의 후속 이벤트를 버튼이 계속 받습니다. 다만 전화 수신, 화면 전환, 브라우저 제스처로 입력이 취소될 수 있으므로 pointercancellostpointercapture에서도 방향 상태를 반드시 풀어야 합니다. 이 해제 처리가 빠지면 손을 뗐는데도 캐릭터가 계속 움직이는 현상이 생깁니다.

CSS의 touch-action:none은 버튼 위 제스처를 브라우저의 확대·스크롤 동작으로 해석하지 않도록 합니다. 페이지 전체에 적용하지 않고 게임 버튼에만 적용한 이유는, 본문 스크롤과 확대 같은 일반적인 접근 동작을 불필요하게 막지 않기 위해서예요.

대각선 이동이 더 빠른 문제는 왜 생길까?

오른쪽으로만 움직이면 한 축의 속도는 1입니다. 오른쪽과 아래를 동시에 누르면 두 축이 각각 1이 되어 실제 이동 길이가 약 1.414배가 됩니다. 그래서 두 방향이 함께 눌렸을 때 Math.SQRT1_2, 즉 약 0.707을 각각 곱합니다. 결과적으로 어느 방향에서도 초당 이동 거리가 비슷해집니다.

이 작은 보정은 게임의 손맛을 크게 바꿉니다. 점수 계산이나 충돌 범위가 맞는데 대각선에서만 지나치게 빨라 보인다면 프레임 문제가 아니라 방향 벡터의 길이를 먼저 확인하세요.

휴대폰에서 화면은 맞는데 게임이 흐릴 때

canvas에는 그리기 좌표 크기와 화면에 표시되는 CSS 크기가 따로 있습니다. 예제는 내부 크기를 640×480으로 두고 CSS에서는 폭을 컨테이너에 맞춥니다. 비율은 4:3으로 유지되므로 작은 화면에서도 찌그러지지 않습니다.

고해상도 화면에서 더 선명하게 만들려면 기기의 픽셀 비율까지 반영하는 추가 작업이 필요합니다. 그러나 입문 예제에서 캔버스 좌표·충돌 좌표·CSS 좌표를 한꺼번에 바꾸면 오류를 찾기 어려워져요. 먼저 입력과 충돌을 완성한 뒤, 화면 크기 변경 이벤트와 devicePixelRatio 대응을 별도 단계로 나누는 편이 안전합니다.

실행되지 않을 때 확인할 순서

  1. 파일 확장자: 파일명이 star-catch.html.txt가 아닌지 확인합니다.
  2. 개발자 도구: 브라우저의 콘솔에서 첫 번째 빨간 오류와 줄 번호를 봅니다. 뒤의 오류는 첫 오류 때문에 연달아 생겼을 수 있어요.
  3. 따옴표와 괄호: 복사 과정에서 스마트 따옴표로 바뀌거나 닫는 괄호가 빠지지 않았는지 확인합니다.
  4. 키 입력: 방향키로 페이지만 움직이면 preventDefault()가 방향키 분기 안에서 실행되는지 봅니다.
  5. 터치 고착: 손을 뗀 뒤에도 움직이면 pointercancellostpointercapture 해제 코드가 있는지 확인합니다.
  6. 너무 빠른 이동: dt가 초 단위인지, 탭 전환 뒤 큰 값이 들어오지 않도록 상한을 두었는지 봅니다.

예제는 탭을 오래 비활성화했다가 돌아올 때 캐릭터가 순간이동하지 않도록 dt를 0.05초 이하로 제한합니다. 게임 시간을 엄밀히 기록해야 한다면 화면 이동과 별도의 시계를 두어야 하지만, 간단한 미니게임의 움직임 안정화에는 이 방식이 이해하기 쉽습니다.

완성 뒤 무엇을 한 가지씩 추가할까?

처음부터 이미지, 소리, 여러 스테이지를 모두 넣으면 어느 변경이 오류를 만들었는지 찾기 어렵습니다. 먼저 네 방향 이동과 목표 충돌을 PC와 휴대폰에서 확인하세요. 그다음 장애물 하나, 제한 시간 하나, 다시 시작 버튼 하나처럼 기능을 한 번에 하나씩 더하는 편이 좋습니다.

코드가 길어져 찾기 어려워질 때는 HTML에 화면 구조만 남기고 style.cssgame.js를 분리하세요. 파일을 나누는 시점은 정답이 정해진 규칙이 아니라, 한 기능을 고치려다 다른 부분을 자주 건드리게 되는 순간입니다. 먼저 한 파일로 동작을 확인하고 나누면 경로 오류와 게임 로직 오류를 구별하기도 쉽습니다.

오늘의 완료 기준은 화려한 그래픽이 아닙니다. 방향키와 터치 버튼이 같은 속도로 움직이고, 대각선이 과속하지 않으며, 손을 떼면 즉시 멈추고, 목표에 닿을 때 점수가 한 번만 올라가면 됩니다. 이 네 가지가 확인되면 작은 브라우저 게임의 입력·반복·충돌 구조는 이미 갖춘 셈입니다.

참고한 공식 문서