앞선 실습에서 30초 표적 터치로 타이머와 점수를, 오늘 할 일 3개로 입력과 저장을 다뤘습니다. 둘 다 화면에 있는 것 하나를 다루는 연습이었습니다.
이번 것은 다릅니다. 카드 짝 맞추기는 여러 개를 한꺼번에 들고 있으면서, 그중 두 개의 관계를 판단해야 합니다. 배열을 섞고, 지금 열린 것이 몇 장인지 세고, 같은지 비교하고, 틀렸으면 되돌리는 일이 한 번에 들어옵니다. 작아 보이는데 상태 관리의 기본이 거의 다 들어 있어서, 첫 게임 다음에 해 보기 좋습니다.
먼저 해 보고 완성 기준을 정하세요
할로윈 짝 맞추기 실행하기 · 완성본 ZIP 내려받기 · 전체 프롬프트 TXT 받기
카드 16장이 덮인 채로 깔립니다. 두 장을 뒤집어 같은 그림이면 그대로 남고, 다르면 잠깐 보였다가 다시 덮입니다. 여덟 쌍을 다 맞추면 걸린 시간과 뒤집은 횟수가 뜹니다.
완성 기준은 다섯 개입니다.
- 시작하면 16장이 뒤섞여 전부 덮인 채로 깔린다
- 같은 그림 두 장은 남고, 다른 두 장은 다시 덮인다
- 두 장을 뒤집을 때마다 횟수가 1 오른다
- 여덟 쌍을 다 맞추면 결과가 뜨고 시계가 멈춘다
- 다시 시작하면 새로 섞인다
코드를 받기 전에 이 다섯 개를 먼저 적어 두는 쪽이 중요합니다. 기준이 없으면 화면이 그럴듯하게 움직이는 것만 보고 "됐나 보다" 하게 됩니다.
이 종류에서 꼭 한 번은 깨지는 세 자리
짝 맞추기는 만들기 쉬워 보이는데, 거의 항상 같은 세 곳에서 어긋납니다. 프롬프트에 미리 적어 둔 이유입니다.
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 첫 실습으로 이어집니다.