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

키보드와 터치를 함께 지원하려면, 두 입력이 같은 이동 상태를 읽되 ‘누가 누르고 있는지’는 따로 기억해야 합니다. 같은 방향키와 화면 버튼을 함께 누르다가 한쪽만 놓았을 때, 다른 쪽 입력까지 꺼지면 조작이 끊기기 때문이에요.

이 예제는 파란 사각형을 노란 원까지 움직이는 작은 게임입니다. 방향키와 화면 버튼으로 이동하고, 목표에 닿으면 1점을 얻습니다. ‘다시 시작’을 누르면 위치와 점수가 처음으로 돌아갑니다. 캐릭터 그림이나 제한 시간은 넣지 않고 입력·이동·충돌에 집중했습니다.

2026년 10월 15일 MDN 문서를 대조하고, Codex가 별도 로컬 예제를 수정·실행해 검증했습니다. 필자의 직접 제작 경험으로 소개하는 글이 아닙니다. 검증은 Ego Lite의 Chromium에서 진행했으며, 실제 아이폰·Safari에서의 동작과 배포 호환성은 별도 확인이 필요합니다.

먼저 한 파일로 실행해 보세요

  1. 아래 코드를 전부 복사해 일반 텍스트 파일 star-catch.html로 저장합니다. 파일명 끝에 .txt가 덧붙지 않았는지 확인하세요.
  2. PC 브라우저에서 파일을 엽니다. 별도 라이브러리는 필요하지 않습니다. 휴대폰에서 확인할 때는 같은 파일을 정적 웹페이지로 제공한 주소를 브라우저로 여는 방식으로 시험할 수 있습니다.
  3. 게임 화면을 클릭하거나 Tab으로 선택한 뒤 방향키를 누릅니다. 화면 버튼은 누르고 있는 동안 이동합니다. 두 방향을 함께 누르면 대각선으로 움직여요.

이 코드는 독립된 HTML 파일용입니다. 워드프레스 본문에 실행용 스크립트로 붙여 넣는 절차와는 구분하세요. HTML 파일에서 먼저 동작을 확인하면 게임 코드 문제와 게시 환경 문제를 나누기 쉽습니다.

전체 코드: 키보드·터치·점수·다시 시작

<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>목표 잡기 미니게임</title>
<style>
  * { box-sizing:border-box; }
  body { margin:0; font:16px/1.6 system-ui,sans-serif;
    background:#f8fafc; color:#0f172a; }
  main { width:min(640px,calc(100% - 24px)); margin:20px auto; }
  canvas { width:100%; height:auto; background:#e0f2fe;
    border:2px solid #0f172a; border-radius:12px; display:block; }
  canvas:focus-visible { outline:3px solid #2563eb; outline-offset:3px; }
  .pad { display:grid; grid-template-columns:repeat(3,72px);
    gap:8px; justify-content:center; margin:16px 0; }
  button { min-height:52px; font:inherit; }
  [data-dir] { font-size:24px; touch-action:none; user-select: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 id="game-area">
  <p>화면을 클릭하거나 Tab으로 선택한 뒤 방향키를 누르세요.
    터치는 아래 버튼을 누르고 있으면 움직입니다.</p>
  <p aria-live="polite">점수: <strong id="score">0</strong></p>
  <canvas id="game" width="640" height="480" tabindex="0"
    aria-label="목표 잡기 게임. 방향키로 파란 사각형을 움직입니다."></canvas>
  <div class="pad" aria-label="이동 버튼">
    <button type="button" data-dir="up" aria-label="위로 이동">▲</button>
    <button type="button" data-dir="left" aria-label="왼쪽 이동">◀</button>
    <button type="button" data-dir="down" aria-label="아래로 이동">▼</button>
    <button type="button" data-dir="right" aria-label="오른쪽 이동">▶</button>
  </div>
  <button type="button" id="restart">다시 시작</button>
</main>
<script>
const area = document.querySelector('#game-area');
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 keys = new Set();
const pointers = new Map();
const player = { x:40, y:40, size:34, speed:230 };
const target = { x:420, y:260, radius:16 };
const keyMap = {
  ArrowLeft:'left', ArrowRight:'right', ArrowUp:'up', ArrowDown:'down'
};
let score = 0;
let previous = performance.now();

function syncInput() {
  const pressed = [...pointers.values()];
  for (const dir of Object.keys(input)) {
    input[dir] = keys.has(dir) || pressed.includes(dir);
  }
}
function clearInput() {
  keys.clear();
  pointers.clear();
  syncInput();
}
window.addEventListener('keydown', event => {
  const dir = keyMap[event.key];
  if (!dir || !area.contains(document.activeElement)) return;
  if (event.target.closest?.('input,textarea,select,[contenteditable]')) return;
  event.preventDefault();
  keys.add(dir);
  syncInput();
});
window.addEventListener('keyup', event => {
  keys.delete(keyMap[event.key]);
  syncInput();
});
window.addEventListener('blur', clearInput);
document.addEventListener('visibilitychange', () => {
  if (document.hidden) clearInput();
});
canvas.addEventListener('pointerdown', () => canvas.focus());

document.querySelectorAll('[data-dir]').forEach(button => {
  button.addEventListener('pointerdown', event => {
    if (event.button !== 0) return;
    event.preventDefault();
    canvas.focus({ preventScroll:true });
    button.setPointerCapture(event.pointerId);
    pointers.set(event.pointerId, button.dataset.dir);
    syncInput();
  });
  const release = event => {
    pointers.delete(event.pointerId);
    syncInput();
  };
  for (const name of ['pointerup','pointercancel','lostpointercapture']) {
    button.addEventListener(name, 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(point = target) {
  const x = Math.max(player.x, Math.min(point.x, player.x + player.size));
  const y = Math.max(player.y, Math.min(point.y, player.y + player.size));
  return (point.x - x) ** 2 + (point.y - y) ** 2 <= target.radius ** 2;
}
function placeTarget() {
  for (let tries = 0; tries < 30; tries++) {
    target.x = 24 + Math.random() * (canvas.width - 48);
    target.y = 24 + Math.random() * (canvas.height - 48);
    if (!touchesTarget()) return;
  }
  const corners = [[24,24],[616,24],[24,456],[616,456]];
  const safe = corners.find(([x,y]) => !touchesTarget({ x,y }));
  [target.x, target.y] = safe;
}
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 resetGame() {
  clearInput();
  player.x = 40; player.y = 40;
  target.x = 420; target.y = 260;
  score = 0; scoreEl.textContent = score;
  previous = performance.now();
  draw();
}
document.querySelector('#restart').addEventListener('click', resetGame);
function loop(now) {
  const dt = Math.max(0, 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>

한쪽을 놓아도 다른 입력은 남겨두기

단순히 input.right = true와 false만 쓰면 문제가 생깁니다. 오른쪽 방향키를 누른 채 오른쪽 버튼을 클릭했다 놓을 때, 버튼의 해제 이벤트가 키보드 입력까지 지워버릴 수 있어요.

수정 예제는 키보드는 keys라는 Set에, 포인터는 pointers라는 Map에 저장합니다. syncInput()이 둘을 합쳐 최종 input을 갱신해요. 게임의 이동 함수는 입력 장치를 구분하지 않고 이 값만 읽습니다.

pointerId를 키로 쓰는 이유도 여기에 있습니다. 두 손가락을 각각 기억해야 하나를 떼도 다른 입력을 유지할 수 있어요. setPointerCapture()는 버튼 밖으로 움직인 포인터의 후속 이벤트도 받게 합니다. MDN의 Pointer events 안내에서 포인터 식별자·캡처·취소 이벤트의 관계를 확인할 수 있습니다.

pointerup뿐 아니라 pointercancel, lostpointercapture에서도 해당 포인터를 해제합니다. touch-action:none은 방향 버튼에만 적용했어요. 게임 조작을 위한 설정이 본문 전체의 스크롤·확대까지 막지 않도록 범위를 좁혔습니다.

방향키와 창 전환을 따로 처리하는 이유

방향키 처리는 KeyboardEvent.key의 ArrowLeft 같은 값을 사용합니다. 게임 영역에 초점이 있을 때만 기본 스크롤을 막고 이동 상태를 바꿉니다. 따라서 화면을 선택하지 않고 방향키부터 누르면 플레이어가 움직이지 않을 수 있어요. 입력 필드 안에서는 커서 조작을 빼앗지 않도록 제외했습니다. 키 값과 이벤트 순서는 MDN의 KeyboardEvent.key 설명을 참고했습니다.

키를 누른 채 다른 창으로 이동하면 기존 페이지가 keyup을 받지 못할 수 있습니다. 포인터 취소만 처리해서는 이 경우를 놓칩니다. 그래서 창의 blur 이벤트에서 모든 입력을 비웁니다. 탭이나 앱을 바꿔 페이지가 숨겨지는 경우에는 visibilitychange와 document.hidden을 확인해 같은 처리를 합니다.

이 예제는 다른 화면으로 이동할 때 이전 입력 상태를 유지하지 않고 초기화하는 방식을 택했습니다. 이는 게임의 선택이며, 서버와 진행 시간을 맞춰야 하는 게임에서는 일시 정지·복귀 정책도 별도로 설계해야 합니다.

대각선 속도와 충돌 판정도 화면 모양에 맞추기

가로·세로를 동시에 1씩 움직이면 이동 거리는 한 방향의 약 1.414배가 됩니다. 두 축에 Math.SQRT1_2, 약 0.707을 곱해 대각선 과속을 줄였습니다.

플레이어는 사각형인데 중심끼리의 거리만 비교하면, 모서리가 노란 원에 닿았는데도 점수를 놓칠 수 있어요. touchesTarget()은 원의 중심에서 사각형의 가장 가까운 점까지 거리를 구합니다. 그 거리가 원의 반지름 이하면 접촉으로 판단합니다.

득점 후에는 새 목표가 플레이어와 겹치지 않는 위치를 찾습니다. 무작위 위치를 최대 30번 확인하고, 계속 겹치면 네 모서리 후보 중 안전한 곳을 선택해요. 이 후보와 간격은 예제의 내부 크기 640×480, 플레이어 34, 목표 반지름 16을 기준으로 했습니다. 맵이나 물체 크기를 크게 바꾼다면 배치 조건도 다시 확인해야 합니다.

프레임 수 대신 경과 시간을 곱합니다

requestAnimationFrame()이 준 시간에서 이전 프레임 시간을 빼고, 밀리초를 초로 바꾼 dt를 속도에 곱합니다. 같은 이동 함수를 화면 갱신마다 일정 거리씩 호출하는 방식과는 달라요. MDN의 requestAnimationFrame 안내도 주사율에 따라 애니메이션이 빨라지지 않도록 시간을 사용하라고 설명합니다.

dt 상한 0.05초는 오랫동안 숨겨졌던 탭이 돌아올 때 한 번에 큰 거리를 이동하는 것을 줄이려는 장치입니다. 실제 경과 시간을 모두 따라잡는 계산은 아니므로 정확한 제한 시간이나 경기 기록용 시계로 쓰면 안 됩니다.

캔버스 내부 좌표는 640×480이고 CSS 표시 폭만 화면에 맞춰집니다. 작은 화면에서 게임을 보이게 하는 기본 예제이며, 고해상도 화면의 devicePixelRatio에 맞춘 선명도 보정은 포함하지 않았습니다. 화면 표시 크기와 충돌 좌표를 함께 바꾸면 서로 어긋날 수 있으니 별도 단계에서 다루세요.

별도 예제에서 확인한 것과 남은 확인

확인 항목 검증 방식 확인 결과
이동·해제·혼합 입력 키보드 조작과 브라우저의 키·마우스 모의 입력 키를 놓으면 멈추고, 한쪽 해제 후에도 다른 입력 유지
모의 터치 390px 폭의 모의 멀티터치·취소, 부분 해제는 합성 pointerup 이벤트 동시 입력·취소와 포인터별 해제 처리 확인
이동 거리·충돌 이동 함수에 같은 시간 입력, 모서리 접촉 좌표와 화면 경계 확인 대각선 과속·모서리 접촉 누락을 수정하고 경계 제한 확인
점수·다시 시작 목표 접촉 상태를 만들어 프레임 실행, 버튼 클릭 접촉 1회에 1점, 새 목표 겹침 방지, 점수·위치 초기화 확인

이는 Codex가 수행한 Chromium 검증입니다. 모의 터치와 합성 이벤트가 실제 휴대폰의 모든 동작을 보증하지는 않아요. 배포할 때는 사용하는 기기·브라우저에서 두 손가락 조작, 버튼 밖에서 손 떼기, 탭·앱 전환 뒤 복귀, 화면 회전과 본문 스크롤을 추가로 확인하세요.

실행이 안 되거나 손을 뗐는데 움직인다면

  • 화면이 뜨지 않음: HTML 파일 전체를 저장했는지, .html.txt가 아닌지 확인합니다. 개발자 도구 콘솔에서는 첫 오류와 줄 번호부터 보세요.
  • 방향키가 먹지 않음: 게임 화면에 초점이 있는지 확인합니다. 화면을 클릭하거나 Tab으로 선택하세요.
  • 손을 뗐는데 계속 이동: 포인터별 해제, pointercancel·lostpointercapture, 창 전환 때 입력 초기화 코드가 빠지지 않았는지 봅니다.
  • 모바일에서 버튼이 불편함: 실제 기기에서 버튼 크기·간격·화면 회전을 확인합니다. 모의 화면에서 맞아 보이는 것만으로 사용성이 검증된 것은 아닙니다.

다음 기능은 장애물 하나나 제한 시간 하나처럼 한 가지씩 추가해 보세요. 입력과 충돌이 동작하는 상태를 남겨두면, 새 오류가 어느 변경에서 시작됐는지 찾기 쉬워집니다.