본문 바로가기
JAVASCRIPT

[JavaScript]카드 짝 맞추기 게임

by w1z 2024. 3. 24.

 

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()">

 

출처

https://apost.dev/1222/#google_vignette

 

'JAVASCRIPT' 카테고리의 다른 글

[JavaScript]2d 벽돌깨기 게임  (1) 2024.03.24
고인물 테스트 사이트  (0) 2024.03.17