[Javascript] 초간단 메모리 카드 게임 만들기

최소한의 코드로 카드를 뒤집어서 맞는 짝을 찾는 메모리 카드 게임을 만듭니다.

자바스크립트 코드 100줄 이내로 구현하는 간단한 게임 구현으로 랜덤으로 카드 셔플을 해서 배치를 하고, 짝을 맞추는 방식을 알 수 있습니다.

카드 숫자 배열을 랜덤으로 섞는 방법에 대한 자세한 내용은 다음 글을 참고하면 도움이 됩니다.

> [Javascript] 배열을 이용한 카드 섞기(Card Shuffle) 구현 알고리즘 기초

0. 완성 코드 다운로드

완성 코드를 보면서 글 내용을 보면 이해가 조금 더 쉽습니다.

2023-01-28 - 3D 카드 플립 효과 개선

1. 카드 매칭 상태 이해하기

카드 게임은 뒷면이 보이는 카드들에서 임의의 두 장을 클릭해서 숫자가 맞는지를 맞추는 게임입니다.

카드는 뒷면이 보이고 있고, 클릭한 카드는 뒤집어지면서 앞면의 숫자가 표시됩니다.

그리고 임의로 선택한 두 장의 카드 숫자가 일치하면 해당 카드는 일치한 카드로 표시가 되어 앞면이 보이는 상태로 고정이 됩니다. 매치된 카드는 색상을 다르게 하거나 해서 매치된 카드임을 표시합니다.

따라서 카드는 뒷면, 앞면, 매치됨 3가지 상태를 가지게 되며, 앞면과 뒷면 상태를 왔다 갔다 하다가, 최종적으로는 매치됨 상태로 고정이 되게 됩니다.

상태의 구분은 CSS 클래스로 합니다.

"back"은 뒷면, "front"는 앞면, 그리고 매치된 카드는 앞면이면서 매치된 것이기 때문에 "front", "matched" 2개의 클래스를 가지게 됩니다. 

선택한 두 장의 카드가 매치되지 않았으면 "front" 클래스를 삭제하고 "back" 클래스를 추가해서 처음 뒷면이 보이는 상태로 되돌아갑니다.

더 이상 매치되지 않은 카드가 없으면(back 클래스를 가진 카드가 없음) 게임이 종료되고, 재 초기화를 하게 됩니다.

2. CSS 그리드(Grid)로 레이아웃 만들기

레이아웃은 최대한 간결하고 쉽게 구현합니다.

바둑판 모양의 정방형 그리드 모양으로 카드를 배치하게 되므로 CSS 그리드(Grid)로 구현을 합니다.

래퍼 태그 하나만 필요하고 나머지 개별 카드를 담는 카드 태그들은 자바스크립트를 이용해 동적으로 생성합니다.

<div class="placeholder"></div>

래퍼 그리드(Wrapper Grid)의 클래스를 정의합니다.

.placeholder{
    display:grid;
    grid-template-columns: repeat(var(--row),150px);
    gap: 20px
}

완성본이므로 그리드 컬럼 개수를 정의하는 CSS의 var(--row) 값에 대해서 추가로 설명을 합니다.

--row 변수는 원래는 다음 CSS처럼 별도의 변수가 전역으로 선언되어 있어야 합니다.

:root 가상 클래스에 행 개수를 위한 변수와 숫자(--row: 6)를 선언한 후, 이 변수를 사용해야 하지만, 자바스크립트로 --row 변수를 생성해서 사용할 것이기 때문에 :root 가상 클래스 선언이 불필요합니다.

:root{
    --row: 6;
}
.placeholder{
    display:grid;
    grid-template-columns: repeat(var(--row),150px);
    gap: 20px;
}

3. 자바스크립트 변수 초기화

사용자가 설정하거나 변경할 변수는 cardCount(카드 총 개수), row(카드 표시 행수) 2개입니다. 나머지 변수들은 동적으로 자동 생성되기 때문에 수정할 필요가 없습니다.

칼럼 수(column)는 전체 카드 개수 / 행수로 구합니다. 행수로 정확하게 나누어 떨어지지 않으면 전체 카드 개수보다 작은 배수값을 선택하고, 남는 카드들은 버립니다.

버리는 방식은 행수 * 열수를 한 값을 전체 카드 개수로 사용하는 방식으로 남는 카드들을 버립니다.

이렇게 전체 카드 개수를 보정하면 사용자가 입력한 전체 카드 개수가 그리드 형태로 딱 떨어지지 않을 때, 근사한 카드덱을 자동으로 생성하게 됩니다.

let cardCount = 20, row = 5, column = Math.floor(cardCount/5), pair = -1, pairindex = -1//카드 개수, 행수, 맞는 짝 카운트용 변수
let arrDeck = []//카드 배열

document.addEventListener('DOMContentLoaded',()=>{
    cardCount = row * column // 가로*세로 개수를 무조건 맞춤
    document.documentElement.style.setProperty('--row',row)
}

구한 행수는 CSS에서도 그리드 변수 값으로 사용할 수 있도록 setProperty() 메서드로 CSS 변수를 선언해 줍니다.

4. 카드 HTML 요소 생성

만들어져 있는 HTML 요소가 그리드 래퍼 태그 한 개이므로 래퍼 태그 안에 카드 태그들을 생성해서 채워야 합니다.

HTML 태그를 생성해서 래퍼 태그(. placeholder)에 붙일 때는 data 속성들은 추가할 필요가 없습니다.

게임을 재 초기화를 할 때 호출하는 initCard() 함수에서 data 속성에 들어갈 값들을 채워 넣기 때문에 중복이 되게 됩니다.

//UI 생성
for(let i = 0;i < cardCount;i++){
    let el = document.createElement('div')
    el.id = 'card'+i
    el.classList.add('card')
    document.querySelector('.placeholder').appendChild(el)
}

5. 카드 정보 초기화

카드 정보 초기화는 게임 재 초기화를 할 때 공통으로 사용하기 위해 별도의 함수로 구현합니다.

처음 초기화를 할 때는 DOM 객체 생성 후 다음과 같이 초기화를 합니다. 이후 게임을 재 초기화를 할 때는 reShuffle()과 initCard() 함수 2개만 호출해서 상태 및 카드 데이터만 재 설정하게 됩니다.

document.addEventListener('DOMContentLoaded',()=>{
    cardCount = row * column // 가로*세로 개수를 무조건 맞춤
    document.documentElement.style.setProperty('--row',row)

    //UI 생성
	...    
    //클릭 이벤트 핸들러
    ...
    //애니메이션 완료 핸들러 - 애니메이션 종료 후 매칭 판단
	...
    //배열 셔플
    reShuffle()
    //정보 초기화
    initCard()
})

먼저 랜덤 카드 배열을 위해서 배열을 섞은 함수인 reShuffle() 함수를 정의합니다.

reShuffle() 함수는 fyShuffler() 함수의 래퍼 함수입니다.

fyShuffler() 함수는 배열을 섞는 기능을 하는 함수이며, 피셔-예이츠 알고리즘을 사용해 카드를 섞습니다.

reShuffle() 함수는 코드 재사용을 조금 더 편하게 하기 위해 인자로 재초기화(bReInit) 여부를 확인하는 불리언 값을 받습니다. 최초 실행일 경우 cardCount 개수만큼 배열 숫자를 채우는 루프문을 추가로 실행합니다.(1 ~ cardCount의 절반까지의 숫자 2쌍을 생성합니다.)

//배열 셔플 호출용
function reShuffle(){
    for(let i = 0;i<Math.floor(cardCount/2);i++){
        arrDeck.push(i+1,i+1)
    }
    arrDeck = fyShuffler(arrDeck)
}
//배열 셔플 메인
const fyShuffler = (arr) => {
    for (let i = arr.length - 1; i > 0; i--) {
      const j = Math.floor((i + 1) * Math.random());
      [arr[i], arr[j]] = [arr[j], arr[i]]; // 배열 값 교환
    }
    return arr
}

initCard() 함수는 카드 정보를 초기화하고 초기화 직후 카드 뒷면에 배치되는 애니메이션을 실행하는 초기화를 합니다.

하트 이모지는 보기 좋게 하려고 넣은 것이고 없어도 무방합니다.

클릭한 카드의 랜덤 숫자가 몇 이고 배열에서의 순서가 어딘지 판단할 수 있도록 data-number(카드 숫자), data-index(카드 위치) 속성을 다시 섞은 카드 배열을 순회하면서 적용합니다.

인터벌 함수는 카드 뒷면에 보이도록 뒤집는 애니메이션이 50ms 간격으로 카드 순서대로 일어나도록 하는 애니메이션 지연 효과를 만드는 함수입니다.

데코레이션 효과이기 때문에  카드 게임의 기능과는 아무 관련이 없으며, 없어도 무방하지만, 그럴듯한 카드 게임인듯한 극적인 효과?를 내주기 때문에 어떤 방식으로 구현이 되는지 알아둘 필요는 있습니다.

초기 상태의 카드는 Y축으로 90도 회전되어 있기 때문에 보이지 않는 상태가 되고, ".back" 클래스를 인터벌 함수로 순차적으로 추가하면 카드 뒷면이 보이는 상태(0도 회전)가 되면서 카드 뒷면에 보이게 됩니다.

function initCard(){
    //카드에 셔플 숫자 지정
    for(let i = 0;i < cardCount;i++){
        document.querySelectorAll('.placeholder .card').forEach((card,idx)=>{
            card.dataset.number = '?'+arrDeck[idx]
            card.dataset.index = idx
        })
    }

    //초기화 애니메이션
    let init = window.setInterval(()=>{
        let card = document.querySelector('.placeholder .card:not(.back)')
        if(card){
            card.classList.remove('front')
            card.classList.remove('matched')
            card.classList.add('back')
        }else{
            window.clearInterval(init)
        }
    },50);
}

6. 카드 앞면과 뒷면 작성

카드 한 장은 앞면과 뒷면이 있습니다.

카드를 뒤집는 방식은 여러 가지 방식이 있지만, 여기서는 가장 간결한 방식을 사용합니다.

기본 카드 상태는 뒷면이 됩니다. ".card" 클래스를 적용한 블록 태그에 뒷면 이미지(cardbg.jpg)를 배경 이미지로 깔아서 기본 뒷면인 상태를 표시합니다.

생성된 카드는 Y축으로 회전을 해서 뒤집는 효과를 만들게 됩니다. 생성한 카드를 Y축으로 180도 회전하며 안 보이도록 하면 간단하게 카드 뒤집는 효과를 만들 수 있습니다.

이걸 가능하게 해주는 CSS 속성이 backface-visibility 속성입니다. 기본 값은 visible입니다.

기본 속성 값에서는 90도 회전하면 카드 배경 이미지가 보이지 않게 되지만 180도 회전하면 좌우 반전이 되어 이미지가 다시 보이게 됩니다.

backface-visibility 속성을 hidden으로 한 후, Y축으로 180도 회전하면 이미지가 보이지 않게 됩니다. 즉, 실제로 카드를 뒤집어서 뒷면이 보이지 않는 것 같은 효과가 나게 됩니다.

앞면 카드는 반대로 동작시키면 됩니다. 처음 상태가 Y축으로 180도 회전한 상태이기 때문에 보이지 않는 상태이고, 클릭 이벤트가 발생해서 뒷면을 180도 회전하면 뒷면에 속한 요소인 앞면이 Y축으로 180도 회전을 하면서 앞면이 보이게 됩니다.

앞면 카드를 별도의 태그로 만들어서 뒷면과 겹치게 해도 되지만, 2장의 카드를 회전시켜야 하는 번거로움이 발생합니다.앞면 카드를 가상 요소로 뒷면 카드에 속한 요소가 되도록 해서 관리를 단순화하고, 하나의 CSS 추가만으로 앞뒤 두 카드에 모두 회전이 적용되도록 할 수 있습니다.

앞면 카드에는 랜덤으로 섞은 배열의 숫자를 하나씩 표시합니다.

앞면 카드(:before)의 content 속성 값인 "attr(data-number)" 는 HTML 태그의 data-number 속성 값을 텍스트 내용으로 표시합니다. (배열을 셔플 해서 초기화 할 때 HTML 태그 속성 data-number에 배열의 숫자 값을 넣어놓음)

.placeholder > .card{
    background-image: url('./cardbg.jpg');
    background-position: center;
    background-size: contain;
    background-repeat: no-repeat;
    width: 140px;
    height: 200px;
    transform-style: preserve-3d;
    perspective: 1000px;
    backface-visibility: hidden;
    transform: rotateY(90deg);
    text-align: center;
}
.placeholder > .card::before{
    content: attr(data-number);
    position: absolute;
    font-size: 5em;
    line-height: 1.125;
    font-weight: bold;
    top: 50%;
    transform: rotateY(180deg) translate(50%,-50%);
    backface-visibility: hidden;
    background-color: #e8e8e8;
    color: #444;
    width: 100%;
    height: 100%;
}

7. 카드 상태별 애니메이션 설정

카드 상태는 뒷면, 앞면, 매치됨(매치되었을 때 앞면) 3가지가 있습니다.

상태 변화 없이 마우스 커서바 호버 되었을 때 효과를 주는 뒷면(호버) 상태가 추가로 마지막에 있습니다.

기본 상태일 때 뒷면은 90도 회전, 앞면은 180도 회전 상태로 둘 다 보이지 않습니다. 90도 각도 차이로 둘 다 보이지 않게 만드는 게 중요합니다. 그래야 90도 단위로 회전하면서 둘 중의 하나만 보이는 상태를 만들 수 있습니다.

카드에 ".back" 클래스가 추가되면 뒷면은 0도가 되면서 표시가 되고 앞면은 90도 상태가 되면서 보이지 않습니다.

클릭 이벤트가 발생해서 ".back" 클래스는 없어지고 ".front" 클래스가 추가됩니다. 앞면은 180도 회전하면서 표시가 되고 뒷면은 270 상태가 되면서 표시되지 않습니다.

카드 앞면은 카드 뒷면에 가상 요소로 붙어있는 요소이기 때문에 카드 뒷면에 회전하는 각도에 맞춰서 따라서 회전을 합니다. 따라서 카드 앞면인 가상 요소를 따로 제어할 필요가 없습니다.

.placeholder .card.back{ /* 뒷면 표시 */
    transition: transform 0.5s;
    transform: rotateY(0deg);
}
.placeholder .card.front{ /* 클릭 후 앞면 표시 */
    transition: transform 0.5s;
    transform: rotateY(180deg);
}
.placeholder .card.matched:before{ /* 매칭된 앞면 배경색 변경 */
    background-color: #673ab7;
    color: #fff;
}
.placeholder > .card.back:hover{ /* 카드 마우스 호버 */
    transform: scale(1.1);
    transition: transform 0.1s linear;
    box-shadow: 1px 4px 15px -3px rgba(0,0,0,0.5);
}

8. 클릭 이벤트 핸들러 추가

!중요합니다.

카드 게임 자바스크립트 코드 중에서 가장 중요하고 핵심이 되는 부분입니다.

사용자가 카드를 클릭하면 클릭한 이벤트를 처리해야 합니다. 이벤트 핸들러는 래퍼 태그에 추가합니다. 개별 카드에 추가하면 생성한 카드 모두에 이벤트 핸들러를 추가해야 하는 번거로움이 발생합니다.

래퍼태그에 이벤트 버블링이 되면 클릭한 대상을 클래스로 판단해서 이벤트를 처리할지를 결정합니다.

그리고, 카드 매칭 판단 및 매칭 여부에 따른 CSS 클래스 변경은 애니메이션이 완료된 후에 처리되어야 합니다.

그렇지 않으면 카드가 뒤집어지는 애니메이션이 발생하기 전에 CSS 클래스가 변경되면서 애니메이션이 진행되다 되돌아가거나 애니메이션이 안 되는 문제가 생기게 됩니다.

따라서, 애니메이션이 완료된 시점에 이벤트가 발생하는 transitionend 이벤트 핸들러를 래퍼 태그에 추가해서 클릭 이벤트로 CSS 클래스가 변경되면서 애니메이션이 발생하면, 애니메이션 종료 후 transitionend 이벤트 핸들러가 호출돼서 카드 매칭 여부 조건 처리를 하도록 해야 합니다.

카드 클릭 이벤트 발생 -> 클릭 이벤트 핸들러 실행 -> 카드 뒤집는 애니메이션 CSS 클래스로 변경 -> 애니메이션 종료 후 transitionend 이벤트 발생 -> transitionend 이벤트 핸들러 실행 -> 카드 매칭 여부 식별 처리 순으로 실행됩니다.

주의할 점이 있습니다.

카드가 매치되지 않으면 첫 번째 클릭한 카드 정보를 담고 있는 pair, pairindex 변수를 초기화하고 뒤집은 두 장의 카드를 다시 뒤집는 처리를 하면 됩니다.(.front -> .back)

이때 두 번째 클릭한 카드가 다른 카드가 아니고 같은 카드이면 카드를 초기화 처리를 해버리기 때문에 불필요한 액션이 발생하게 됩니다.(뒤집은 첫 번째 장은 다른 카드를 클릭해서 매칭을 확인하기 전에는 항상 그 상태가 유지되어야 합니다.)

따라서 else if(pairindex != e.target.dataset.index){} 조건문으로 명시적으로 다른 카드를 클릭했는지를 확인해서 같은 카드를 또 클릭하지 않았다는 것을 확인해야 합니다.

//클릭 이벤트 핸들러
document.querySelector('.placeholder').addEventListener('click',(e)=>{
    if(e.target.classList.contains('card') && e.target.classList.contains('back')){
        console.log('clicked');
        e.target.classList.remove('back')
        e.target.classList.add('front')
    }
})
//애니메이션 종료 후 매칭 판단
document.querySelector('.placeholder').addEventListener('transitionend',(e)=>{
    if(e.target.classList.contains('card')){
        console.log('transitionended');
        if(e.target.classList.contains('front')){
            if(pair < 0){
                pair = e.target.dataset.number
                pairindex = e.target.dataset.index
            }else{
                if(pair == e.target.dataset.number && pairindex != e.target.dataset.index){
                    //매치됨 - 컬러링
                    document.querySelectorAll('.placeholder .card.front').forEach((card)=>{card.classList.add('matched');})
                    pair = -1
                    pairindex = -1
                }else if(pairindex != e.target.dataset.index){
                    //매치안됨 - 페어 리셋
                    document.querySelectorAll('.placeholder .card.front:not(.matched)').forEach((card)=>{card.classList.remove('front');card.classList.add('back');})
                    pair = -1
                    pairindex = -1
                }
            }
        }            
    }
})

9. 게임 다시 시작하기

카드를 모두 맞췄거나 중간에 다시 시작하고 싶으면 초기화를 다시 해야 합니다.

생성한 UI는 모두 그대로 재활용합니다. HTML 요소들을 다시 생성하면 화면 재 생성으로 무거워지기만 하기 때문에 있는 요소들은 그대로 재활용합니다.

앞면이 보이는 카드들은 모두 뒷면이 보이도록 하고, 카드 배열을 다시 셔플 해서 변경하고, 변경된 배열의 값들을 카드들에 적용하면 됩니다.

카드 태그 요소와 이벤트 핸들러는 다시 생성할 필요가 없습니다.

카드가 매치되었을 때 처리하는 조건문 안에 다음 코드를 추가해서 더 이상 매칭 안된 카드가 있는지 체크합니다.

재 초기화는 doneFinding() 함수에서 모두 초기화합니다.

사용자가 중간에 게임을 다시 시작할 수 있도록 "다시시작" 버튼도 만들 것이기 때문에 공통으로 사용할 초기화 함수로 doneFinding() 함수를 사용합니다.

if(pair == e.target.dataset.number && pairindex != e.target.dataset.index){
    ...
    if(document.querySelector('.placeholder .card:not(.matched)') == null){// 더이상 매치 안된 카드가 없으면
        //완료
        console.log('card finding end.')
        doneFinding()
    }
}

doneFinding() 함수는 초기화 기능을 하는 함수 2개(reShuffle(), initCard())를 호출하는 것이 전부입니다.

두 함수는 처음 카드 게임을 초기화할 때 호출했던 함수입니다.

function doneFinding(){
    if(confirm('찾기 완료! 게임을 다시 하시겠습니까?')){
        reShuffle()
        initCard()
    }
}

사용자가 직접 카드 게임을 재 시작할 수 있도록 HTML 버튼 태그를 추가하고 클릭하면 doneFinding() 함수를 호출하도록 합니다.

<input type="button" name="init" value="다시 시작" onclick="doneFinding()">