앞선 실습에서 30초 표적 터치로 타이머와 점수를, 오늘 할 일 3개로 입력과 저장을 다뤘습니다. 둘 다 화면에 있는 것 하나를 다루는 연습이었습니다.

이번 것은 다릅니다. 카드 짝 맞추기는 여러 개를 한꺼번에 들고 있으면서, 그중 두 개의 관계를 판단해야 합니다. 배열을 섞고, 지금 열린 것이 몇 장인지 세고, 같은지 비교하고, 틀렸으면 되돌리는 일이 한 번에 들어옵니다. 작아 보이는데 상태 관리의 기본이 거의 다 들어 있어서, 첫 게임 다음에 해 보기 좋습니다.

먼저 해 보고 완성 기준을 정하세요

할로윈 짝 맞추기 실행하기 · 완성본 ZIP 내려받기 · 전체 프롬프트 TXT 받기

카드 16장이 덮인 채로 깔립니다. 두 장을 뒤집어 같은 그림이면 그대로 남고, 다르면 잠깐 보였다가 다시 덮입니다. 여덟 쌍을 다 맞추면 걸린 시간과 뒤집은 횟수가 뜹니다.

완성 기준은 다섯 개입니다.

  1. 시작하면 16장이 뒤섞여 전부 덮인 채로 깔린다
  2. 같은 그림 두 장은 남고, 다른 두 장은 다시 덮인다
  3. 두 장을 뒤집을 때마다 횟수가 1 오른다
  4. 여덟 쌍을 다 맞추면 결과가 뜨고 시계가 멈춘다
  5. 다시 시작하면 새로 섞인다

코드를 받기 전에 이 다섯 개를 먼저 적어 두는 쪽이 중요합니다. 기준이 없으면 화면이 그럴듯하게 움직이는 것만 보고 "됐나 보다" 하게 됩니다.

이 종류에서 꼭 한 번은 깨지는 세 자리

짝 맞추기는 만들기 쉬워 보이는데, 거의 항상 같은 세 곳에서 어긋납니다. 프롬프트에 미리 적어 둔 이유입니다.

1. 섞기가 치우칩니다

가장 흔한 섞기 코드는 이것입니다.

list.sort(() => Math.random() - 0.5);   // 이렇게 쓰지 마세요

짧고 그럴듯해서 많이 쓰이는데, 결과가 고르지 않습니다. 정렬 함수는 두 값을 비교해 순서를 정하는 규칙을 기대하는데, 난수를 돌려주면 같은 쌍을 물어볼 때마다 답이 달라집니다. 일관성 없는 비교를 받은 정렬은 자리가 처음 위치 쪽으로 쏠린 채 끝나기 쉽습니다.

완성본은 피셔-예이츠를 씁니다. 뒤에서 앞으로 한 칸씩 가면서, 자기 앞(자기 포함) 중 아무 곳이나 골라 맞바꿉니다.

function shuffle(list) {
  for (let i = list.length - 1; i > 0; i--) {
    const j = Math.floor(Math.random() * (i + 1));
    [list[i], list[j]] = [list[j], list[i]];
  }
  return list;
}

실제로 재 봤습니다. 카드 16장을 10만 번 섞으면서, 0번 카드가 어느 자리로 갔는지 셌습니다. 고르다면 한 자리당 6,250번쯤 나와야 합니다.

섞는 방식0번 카드가 제자리에 머문 횟수기대값에서 가장 많이 벗어난 자리
sort(() => Math.random() - 0.5)13,221번111.5%
피셔-예이츠6,274번2.0%

sort 방식은 첫 카드가 제자리에 머무는 일이 기대보다 두 배 넘게 일어났습니다. 두 번째 자리도 10,482번으로 많았고, 뒤로 갈수록 적어졌습니다. 섞은 것처럼 보이지만 앞쪽이 그대로 남는 쪽으로 쏠린 것입니다. 피셔-예이츠는 6,126~6,361 사이로 모두 2% 안에 들어왔습니다.

2. 세 장이 열립니다

틀린 두 장을 잠깐 보여 준 뒤 덮어야 재미가 있습니다. 그런데 그 잠깐 사이에 사용자가 세 번째 카드를 누를 수 있습니다. 그러면 열린 카드가 셋이 되고, 다음 비교가 엉뚱한 두 장을 보게 됩니다.

막는 방법은 간단합니다. 덮는 동안 잠금 하나를 걸어 둡니다.

locked = true;
setTimeout(() => {
  open.forEach(k => { /* 다시 덮기 */ });
  open = [];
  locked = false;
}, 700);

그리고 카드를 누를 때 맨 앞에서 if (locked) return; 으로 돌려보냅니다.

확인하는 법: 다른 그림 두 장을 누른 직후 0.3초 안에 세 번째 카드를 눌러 보세요. 아무 일도 없어야 맞습니다.

3. 덮인 카드가 정답을 흘립니다

이건 눈으로는 절대 안 보이는 문제입니다. 카드를 만들 때 이렇게 쓰기 쉽습니다.

<button aria-label="호박">…</button>

화면에는 뒷면만 보이지만, 화면 낭독기를 쓰는 사람에게는 덮여 있는 카드가 "호박"이라고 읽힙니다. 게임이 성립하지 않습니다.

완성본은 덮였을 때와 뒤집혔을 때의 이름을 다르게 둡니다.

b.setAttribute('aria-label', '덮인 카드 ' + (i + 1));   // 처음
card.setAttribute('aria-label', NAMES[deck[i]]);        // 뒤집은 뒤

확인하는 법: 개발자 도구에서 덮인 카드의 aria-label 을 읽어 보세요. 그림 이름이 보이면 새는 것입니다.

그림을 파일 없이 넣는 법

이 예제에는 이미지 파일이 하나도 없습니다. 호박·유령·박쥐·검은 고양이·사탕·달·거미·촛불 여덟 가지를 전부 코드로 그린 SVG 로 넣었습니다.

const ART = {
  pumpkin: '<svg viewBox="0 0 48 48">…</svg>',
  ghost:   '<svg viewBox="0 0 48 48">…</svg>',
  …
};

이렇게 하면 파일 하나로 끝나고, 인터넷이 끊겨도 돌아가고, 확대해도 깨지지 않습니다. 그림을 바꾸고 싶으면 이 덩어리만 바꾸면 됩니다. 카드 수를 늘리고 싶으면 ART 에 항목을 더하세요. 짝 수와 n / 8 표시가 따라옵니다.

저장은 막힐 수 있다고 가정하세요

최고 기록은 localStorage 에 둡니다. 그런데 시크릿 모드나 사이트 데이터가 막힌 환경에서는 읽기만 해도 예외가 납니다. 감싸지 않으면 거기서 스크립트가 멈춰 게임 전체가 안 뜹니다.

try {
  const n = Number(localStorage.getItem(BEST_KEY));
  if (Number.isSafeInteger(n) && n > 0) best = n;
} catch { /* 못 읽으면 기록 없이 그냥 시작한다 */ }

읽을 때와 쓸 때 둘 다 감싸야 합니다. 그리고 못 쓰는 환경이면 화면에 한 줄로 알려 주는 편이 친절합니다.

직접 확인한 것

완성본을 브라우저에서 자동으로 조작해 확인했습니다.

확인한 것결과
같은 그림끼리 여덟 쌍을 차례로 맞추기8쌍 모두 성립, 카드 16장 전부 끝남, 뒤집은 횟수 8
짝마다 카드가 정확히 2장인지모든 그림이 2장 (16장 = 8종 × 2)
다른 두 장을 뒤집기뒤집힌 직후엔 둘 다 열림, 0.7초 뒤 둘 다 덮임, 맞춘 짝은 0 그대로
다 맞춘 뒤걸린 시간과 횟수 표시, 최고 기록 저장
콘솔 오류없음
섞기 고름 (10만 번)피셔-예이츠 2.0% 안, sort 방식 111.5% 치우침

아직 확인하지 못한 것

  • 실제 휴대폰에서 만져 보지 않았습니다. 좁은 화면에서 한 줄 3장으로 줄어드는 것은 브라우저의 휴대폰 흉내 모드로만 봤습니다.
  • 화면 낭독기로 직접 들어 보지 않았습니다. aria-label 이 덮인 카드에서 그림을 숨긴다는 것은 속성 값으로만 확인했습니다.
  • 사파리와 파이어폭스에서는 열어 보지 않았습니다.

다음으로

이 게임에 시간 제한을 붙이면 30초 표적 터치에서 쓴 타이머가 그대로 들어갑니다. 기록을 여러 개 남기고 싶으면 오늘 할 일 3개의 저장 방식을 가져다 쓰면 됩니다. 셋 다 HTML 파일 하나라 서로 붙이기 쉽습니다.

브라우저를 벗어나 휴대폰 앱으로 내보내는 것까지 가 보고 싶으시면 Godot 첫 실습으로 이어집니다.