HTML6 Games설치없는 무료 웹 게임 / devlog

설치 없이, 지금 바로 플레이

브라우저에서 즐기는 무료 미니게임 모음. 로그인하면 기록이 계정에 저장되어 어느 기기에서든 이어서 볼 수 있어요.

게임 목록
게임 목록
모든 개발 이야기

카드를 옮길 때마다 아이폰에서 판이 번쩍였다

2026년 11월 1일 · 기술 · English

솔리테어를 아이폰에서 하다가 카드를 끌어 놓으면, 카드가 제자리로 날아가는 순간 판 전체가 번쩍였다. 판이 아주 잠깐 비었다가 다시 나타나는 모양이었다. 같은 날 스파이더에서는 더미를 눌러 카드를 나눌 때, 프리셀에서는 카드를 옮길 때 같은 일이 생겼다. 데스크톱 크롬에서는 한 번도 보이지 않았다.

이 카드 게임들은 캔버스가 아니라 HTML 요소로 그린다. 카드 한 장이 <div> 하나이고, 줄과 빈 칸도 요소다. 카드가 날아가는 연출은 FLIP 이라는 방법으로 만든다. 옮기기 전에 카드마다 화면 위치를 재 두고(First), 판을 새 상태로 다시 그린 다음 새 위치를 재고(Last), 그 차이만큼 카드를 예전 자리로 되돌려 놓았다가(Invert) 원래 자리로 움직인다(Play). 레이아웃은 한 번만 바뀌고 움직임은 transform 만으로 처리할 수 있어서 많이 쓰는 방법이다.

다른 게임에서 통한 방법을 그대로 옮겼다

그 직전에 패 짝맞추기에서 비슷한 증상을 고친 일이 있었다. 섞기 연출이 끝날 때 판이 깜빡였는데, 원인은 연출 동안 날아가는 패를 다른 패 위로 올리려고 바꾼 z-index 를 연출이 끝날 때 원래대로 되돌리는 데 있었다. 사파리는 화면을 몇 개의 그림 층으로 나눠 그리는데, 쌓이는 순서가 바뀌면 층을 다시 나누면서 판 전체를 새로 그리는 것으로 보았다. 그래서 z-index 를 되돌리지 않고, 움직이는 패에는 will-change: transform 을 줘서 늘 따로 그려 두게 했다. 패 짝맞추기는 이것으로 나았다.

카드 게임도 증상이 비슷해 보였다. 세 게임에 같은 고침을 넣었다. 그런데 이 원인은 짐작이었다. 아이폰에서만 생기는 현상이라 개발하는 맥에서는 재현할 수 없었고, 데스크톱에서 확인할 수 있는 것은 연출이 여전히 잘 돈다는 것뿐이었다.

실제 아이폰에서 다시 해 보니 세 게임 모두 그대로 번쩍였다.

판 전체를 새로 만들고 있었다

다시 코드를 보니 세 게임의 그리기 함수가 같은 모양이었다. 카드를 한 장 옮길 때마다 판 전체의 HTML 을 문자열로 만들어 innerHTML 에 넣었다. 솔리테어는 52장, 스파이더는 104장의 카드 요소를 매번 지우고 새로 만든 것이다. 그리고 FLIP 연출은 방금 만든 새 요소에 바로 걸렸다. 설명을 위해 줄인 모양은 이렇다.

// before: every move rebuilt the whole board
function draw() {
  board.innerHTML = columns.map((col) =>
    `<div class="col">${col.map((c) => `<div class="card" data-id="${c.id}">…</div>`).join('')}</div>`
  ).join('');
  animateFrom(before); // FLIP on brand-new elements
}

여기에 첫 번째 고침이 더해졌다. 카드마다 will-change: transform 을 줬으니, 새로 만든 카드 수십 장이 한꺼번에 각자의 그림 층을 요구했다. 사파리가 이 층들을 만들고 그리는 동안 판이 비어 보였다. 데스크톱 크롬은 이 일을 한 프레임 안에 끝내서 보이지 않았던 것으로 보인다. 패 짝맞추기에서 통한 will-change 가 여기서는 문제를 키웠다.

고친 방법은 요소를 다시 만들지 않는 것이다. 판 틀(줄, 빈 칸, 더미)은 게임을 열 때 한 번만 만든다. 카드 요소는 카드 번호마다 하나를 만들어 Map 에 넣어 두고 계속 쓴다. 그릴 때는 카드 요소를 제 줄로 옮기고, 앞면인지 뒷면인지 같은 상태는 클래스로 바꾼다. 줄의 자식 목록이 실제로 달라졌을 때만 replaceChildren 으로 바꾼다. 같은 요소가 같은 자리에 있으면 아무것도 건드리지 않는다.

const cardEls = new Map(); // card id -> element, created once
function cardEl(card) {
  let el = cardEls.get(card.id);
  if (!el) {
    el = document.createElement('div');
    el.dataset.id = card.id;
    cardEls.set(card.id, el);
  }
  el.className = 'card' + (card.up ? ' up' : '');
  return el;
}

function put(column, kids) { // touch the DOM only when the order really changed
  const same = kids.length === column.children.length &&
    kids.every((k, i) => column.children[i] === k);
  if (!same) column.replaceChildren(...kids);
}

replaceChildren 에 이미 화면에 있는 요소를 넘기면 새로 만들지 않고 그 요소를 옮긴다. 카드 한 장을 옮기면 바뀌는 줄은 두 개뿐이고, 나머지 줄과 카드는 그대로 남는다. 카드마다 줬던 will-change 는 뺐다.

요소를 계속 쓰니 새로 챙길 것도 생겼다. 예전에는 매번 새 요소라서 지난 연출이 남아 있을 수가 없었다. 이제는 같은 요소이니, 카드가 아직 날아가는 중에 다음 이동을 하면 도는 연출이 남아 있다. 그래서 다시 그리기 전에 카드가 지금 보이는 자리를 재고, 돌던 연출을 멈춘 뒤 그 자리에서 다시 날린다. 끌 때 붙였던 위치 값과 놓을 자리 점선 표시도 예전에는 요소와 함께 사라졌지만, 이제는 그리기 함수가 직접 지운다.

이렇게 바꾸고 실제 아이폰에서 세 게임을 다시 했다. 이번에는 카드를 옮겨도 판이 번쩍이지 않았다.

사흘 뒤 숫자 밀기에서 칸을 누를 때 판이 깜박인다는 기록이 들어왔다. 열어 보니 밀 때마다 판을 innerHTML 로 새로 만들고 있었다. 이번에는 바로 칸 요소를 그대로 두고 숫자와 그림만 바꾸는 쪽으로 고쳤다. 지금은 게임을 만들 때 지키는 규칙에 들어가 있다. 옮길 때마다 판을 통째로 새로 만들지 않고, 많은 요소에 will-change 를 늘 주지 않는다.

증상이 같아 보여도 원인이 같다는 보장은 없었다. 패 짝맞추기는 패를 다시 만들지 않는 게임이었고, 카드 게임은 매번 다시 만드는 게임이었다. 두 게임의 그리기 방식을 먼저 비교했다면 첫 번째 고침을 건너뛸 수 있었을 것이다.

참고한 문서

이 글과 관련된 게임

솔리테어클론다이크 솔리테어. 카드를 A부터 K까지 무늬별로 모두 올리세요. 해 보기 스파이더 솔리테어10줄에 놓인 카드를 K부터 A까지 같은 무늬로 이어 8묶음을 완성하세요. 해 보기 프리셀모든 카드가 펼쳐진 솔리테어. 빈 칸 4개를 잘 활용해 무늬별로 A부터 K까지 올리세요. 해 보기

다른 글

홈 화면에 추가하기

  1. 화면 아래(아이패드는 위)의 공유 버튼을 누르세요. 네모에서 화살표가 위로 나온 모양이에요.
  2. 목록을 내려 "홈 화면에 추가"를 누르세요.
  3. 오른쪽 위 "추가"를 누르면 홈 화면에 HTML6 Games 아이콘이 생겨요.

Safari 에서만 추가할 수 있어요. 홈 화면에서 열면 주소창 없이 앱처럼 보이고, 인터넷이 끊겨도 열립니다.