티스토리 뷰


 

 

 

윈도우에 기본 설치되어 있는 그 게임, 지뢰찾기.

평소 거들떠도 안보는 게임이었지만

군대에서 야간당직 근무 중 당직실 컴퓨터로 시간 가는 줄 모르고 했던 기억이 난다.

하지만 마지막 두 칸을 남겨두고

50 대 50의 확률에 승부를 거는 때면

추론 게임이라는 이름이 무색해진다.

그래도 간단한 규칙으로 직관적인 플레이를 제공하는 훌륭한 게임임엔 틀림없다.

나무위키에는 무려 죽기 전에 꼭 해야 할 비디오 게임이라고 하는데...

 

공부를 위해 간단한 것부터 차근차근 만들어보자!라는 생각으로 도전했지만,

어려웠다!

 

저번 테트리스의 경우,

처음부터 강좌를 보고 만들어서 어려운 줄 몰랐지만,

이번엔 처음부터 혼자 생각한지라

생각하는 데만 하루,

코딩에 하루를 썼다.

많은 시행착오가 있었지만 몰랐던 걸 많이 배울 수 있어

의미있는 작업이었다... 정말...

 


1. 게임의 규칙?

 

실제로 해볼 땐 간단한 게임이라고 생각했는데,

만들 때 생각해 보니

이 게임의 룰을 제대로 알고 했나? 다시 생각해보게 되었다.

 

여러 시행착오가 있었지만 정리한 내용은 아래와 같다.

 

1. 지뢰가 숨겨진 타일 외, 모든 타일을 클릭하면 승리한다.

2. 지뢰가 숨겨진 타일을 클릭하면 패배한다.

3. 지뢰가 없는 타일을 클릭하면 그 둘러싼 타일들 중에 지뢰가 숨겨진 타일의 개수가 표시된다.

 

이 세 가지 규칙이 기본이지만,

편의상 주변에 지뢰가 하나도 없는 타일을 클릭하면

자동으로 그 주변 타일이 클릭된 것으로 처리하는 소소한 배려가 있다.

(4. 주변에 지뢰가 없는 타일 클릭 시 둘러싼 타일 전부 자동 공개)

 

애석하게도 윈도우 설치 시 기본앱을 전부 삭제하는 나는

모작을 위한 원본이 없어 고민하다

구글 게임에서 기본으로 제공하는 구글판 지뢰찾기를 참고했다.

 

 

 

구글판 지뢰찾기를 분석해 보자면...

 

 

길게 터치하면 깃발을 꽂고

빠르게 두 번 터치하면 땅을 판다.

한 손으로 조작하기 쉬우라는 배려가 물씬 느껴져서

좋은 디자인이라 생각했고

이번엔 휴대폰으로 구동시킬 수 있게

Input.GetTouch 함수를 사용하기로 계획했지만...

 

CommandInvokationFailure: Unity Remote requirements check failed

 

유니티 리모트5와 안드로이드 SDK 오류 때문인지 리모트 환경이 안 돼서 포기해 버렸다...

다음번엔 꼭 스마트폰 구동이 가능한 프로젝트를 할 것이다...

 

그 핑계는 절대 아니지만, 위 구글판 지뢰찾기에는 결점이 있다.

바로 깃발 표시를 위해 길게 터치하는 부분.

보통 지뢰찾기의 목적이 빠른 시간 내에 모든 지뢰를 찾는 것인데

이러면 딜레이가 생긴다.

이 부분에 주목하고서 조작법을 수정했다.

 

1) 좌클릭 : 타일 확인

2) 우클릭 : 깃발 표시

 

비록 Window 용 빌드 밖에 못해서 아쉽지만

지뢰찾기를 즐기기엔

아직은 마우스만큼 좋은 방법이 없는 것 같다.

 

if (Input.GetMouseButtonDown(0))
{
    Vector3 mP = Camera.main.ScreenToWorldPoint(Input.mousePosition);
    Ray2D ray = new Ray2D(mP, Vector2.zero);
    RaycastHit2D hit = Physics2D.Raycast(ray.origin, ray.direction, 100, 1 << LayerMask.NameToLayer("Tile"));

 

타일마다 OnMouseDown() 처리를 할까 고민했지만

그러면 게임을 관리할 GameManager 말고도

Tile의 스크립트에 별도의 함수가 필요해 복잡해지니까 포기했다.

대신 Ray를 활용해 마우스 클릭한 위치의 타일을 검출하는 방식으로 만들었다.

 

 

    if (hit.collider != null)
    {
        Tile hitTile = hit.collider.GetComponent<Tile>();
        
        //지뢰가 있는 곳을 선택하면 게임 오버
        if (hitTile.isMine)
        {
            hitTile.sprt.sprite = mineSprt;
            AudioPlay(audioSource, bomb);
            StartCoroutine(CamShake(power * 2, 1));
            state = State.gameover;
            AudioPlay(cam.GetComponent<AudioSource>(), lose);
            gameoverMessage.SetActive(true);
            return;
        }

        else
        {
            TileOpen(hitTile);
        }
    }
}

 

먼저 좌클릭으로 지뢰가 숨겨진 타일을 선택하면 게임오버되도록 조건문을 작성했다.

게임의 첫 번째 분기가 지뢰가 숨겨진 타일인지 아닌지 검사하는 것이기 때문이다.

게임오버 이벤트에 맞게 폭발음, 카메라 쉐이크, 패배 BGM, 게임오버 UI 등도 준비했다.

 

하지만 지뢰가 숨겨진 타일이 아니라면 타일을 오픈하는 작업을 시작한다.

 

 


2. 주변에 숨겨진 지뢰는 몇 개?

 

다음 과제로 지뢰찾기의 가장 중요한 알고리즘인

주변 지뢰의 개수를 반환하는 방법을 해결해야 했다.

중간중간 너무 어려워서 유튜브나 다른 블로그를 참고하고 싶었지만

오기로 혼자서 끝까지 고민해 보았다.

 

생각해 본 방법은 여러 가지...

타일마다 미리 주변에 이웃한 타일 리스트를 준비하는 노동을 할 것인지,

매번 OnTrigger를 사용해 게임에 부하를 줄 것인지... 등등.

 

//TileOpen 함수는 지뢰가 아닌 곳을 클릭 했을 때,
//그리고 주변에 지뢰가 없는 타일을 확인 했을 때 호출하므로
//if(isMine) 조건이 또 필요없다.
void TileOpen(Tile t)
{
	//bool형 변수 isOpened으로 타일이 여러 번 검출되지 않게 한다.
    t.isOpened = true;
    
    //...하지만 오류가 생길까 쫄려서 콜라이더를 비활성화 하는 코드도 추가했다...
    t.GetComponent<Collider2D>().enabled = false;
    
    //간단한 파티클과 효과음 추가
    Transform prtc = Instantiate(clearTile).transform;
    prtc.position = t.transform.position;
    AudioPlay(audioSource, dig);
    
    //int digCount 변수를 준비했다.
    //지뢰가 없는 타일을 골라낼 때마다 카운팅하고,
    //그 수가 지뢰가 없는 전체 타일 수와 같으면 게임 승리이다.
    digCount++;
	
    //주변 타일에서 지뢰 수를 검출 후 int mineCount 변수에 저장하기로 한다.
    //검출 전엔 혹시모를 오류를 방지할 수 있게 0으로 초기화했다.
    t.mineCount = 0;

	//주변 8개의 타일을 검출하기 위한 방법으로 Physics2D.OverlapBoxAll를 사용하였다.
    Collider2D[] c = Physics2D.OverlapBoxAll(t.transform.position, new Vector2(1, 1), 0);

	//Physics2D.OverlapBoxAll로부터 얻어낸 Collider2D 배열로
    //반복문을 돌려 지뢰 수를 카운팅한다.
    for (int i = 0; i < c.Length; i++)
    {
        if (c[i].GetComponent<Tile>().isMine == true)
        {
            t.mineCount++;
        }
    }


	//이후 주변에 지뢰의 수에 따라 분기가 나눠진다.
    // 1) 지뢰가 아니지만 주변에 지뢰가 있어서 주변 타일을 확인하지 않는다.
    if (t.mineCount > 0)
    { 
    	//타일엔 주변에 숨겨진 지뢰의 수를 표시만 한다.
        t.sprt.sprite = numSprt[t.mineCount];
    }

    // 2)지뢰가 아니면서 주변에 지뢰가 없다.
    else if (t.mineCount == 0)
    {
    	//주변에 지뢰가 없다는 뜻은
        //그 주변의 모든 타일이 안전하다는 뜻이므로
        //어차피 일일히 전부 클릭해야하는 대신
        //한 번에 공개처리한다.
    	
        //한 번에 타일이 여럿 벗겨질테니 그에 맞춰 약간의 효과를 준다.
        StartCoroutine(CamShake(power, shakeTime));
        t.sprt.sprite = noneSprt;
        
        //검출한 주변 타일들에도 TileOpen() 함수를 호출시킨다.
        for (int i = 0; i < c.Length; i++)
        {
            if (c[i].GetComponent<Tile>().isOpened) continue;

            TileOpen(c[i].GetComponent<Tile>());
        }
    }
	
    //만약 지뢰가 없는 모든 타일을 전부 클릭했다면
    if (digCount == (tiles.Count - mineSettingData[(int)stageLv]))
    {
    	//플레이어의 승리로 게임은 종료한다.
        state = State.gameover;
        endMessage.SetActive(true);
        AudioPlay(audioSource, win);
    }

}

 

이번엔 Physics2D.OverlapBoxAll 를 사용하기로 했다.

Collider2D[]를 반환하는 함수로 주변 타일 정보를 한 번에 받을 수 있어 유용했다.

 

하지만 이 코드에서 가장 헤맨 부분은 바로 재귀함수라는 것이었다... 

 

함수 안에 자신을 재정의하는 구조

 

이번에 처음 써본지라 결과를 예측하고 문제를 파악하는데 정말 반나절을 써버렸다...;;

 

 

지뢰찾기를 실제로 해보면,

지뢰가 없는 타일을 클릭했을 때,

숫자가 0인, 그러니까 숫자가 적혀있지 않은 타일은

그 주변에 지뢰가 숨겨진 타일이 아니지만,

그 주변엔 지뢰가 숨겨져 있는 타일을 함께 공개시킨다.

 

TileOpen(){

        Collider2D[] c = Physics2D.OverlapBoxAll(t.transform.position, new Vector2(1, 1), 0);

 

        for (int i = 0; i < c.Length; i++)
        {
            if (c[i].GetComponent<Tile>().isOpened) continue;

            TileOpen();
        }

}

 

위와 같이 한 번에 주변 타일들을 받아 반복문을 사용했지만,

저러면 함수 자신이 반복문 안에 들어가 버려

반복문 안에 반복문이, 또 그 안에 반복문이 실행되는 일이 벌어진다.

그러다 보게 된 것은 콘솔창의 Memory leak 이라는 경고문이었다;

처음 이 문제를 직면했을 때 무슨 일이 벌어진 것인지 감도 안 잡혔다...

생각을 정리하기 위해 몇 번의 낙서를 끄적인 끝에

다행히 문제를 파악할 수 있었다.

 

0개짜리 타일 주변에 0개짜리 타일이 있고, 또 그 주위에 0개짜리 타일이 있다면

TileOpen()이라는 함수는 계속 반복해야 한다.

자세히 설명할 자신은 없고,

스타 1의 발키리 버그와 비슷한 경우라고 생각한다.

 

문제를 해결하는 데 두 가지 방법을 사용했다.

 

t.GetComponent().enabled = false;

중복 검출을 막기 위해 콜라이더를 비활성화시켰고

 

TileOpen(c[i].GetComponent<Tile>());

함수에 매개변수를 선언하여 반복문 실행 주체를 확실히 명시했다.

 

다행히 문제를 해결했고

작업은 세세한 디테일 구현으로 넘어갈 수 있었다.

 


3. 형변환

처음엔 public 접근 지정자로 인스펙터 상에서 직접 오브젝트를 연결하곤 했는데...

이번엔 매개변수를 적극적으로 활용하면서 코드 효율을 높일 수 있었다.

더불어 열거형을 매개변수로 사용할 때,

int값으로  변환하여 구조를 직관적으로 짤 수 있었다.

 

public enum Level
{
    easy = 0, //6*12칸 지뢰 10개
    medium = 1, //10*20칸 지뢰 35개
    hard = 2 //13*27칸 지뢰 75개
}

public class GameManager : MonoBehaviour
{
    [Header("게임 상태")]
    public State state = State.stay;
    public Level stageLv;
    int[,] tileSettingData = { { 6, 12 }, { 10, 20 }, { 13, 27 } };
    int[] mineSettingData = { 10, 35, 75 };
}

 

구글판 지뢰찾기를 참고하여 Easy, Medium, Hard 3단계 난이도에 사용하는 정보를 각각 배열로 만들었다.

 

public enum Level
{
    easy = 0,
    medium = 1,
    hard = 2
}

// 매개변수로 int 값을 받지만
public void LevelSetting(int i = 0)
{
	// 열거형으로 변환하여 사용한다.
    stageLv = (Level)i;

	//타일을 생성하고 관리하기 위한 상위 노드와 리스트를 준비
	board = new GameObject().transform;
    board.gameObject.name = "Top Tile Nord";
    board.position = new Vector3();

    tiles.Clear();
    tiles = new List<Tile>();

    gameoverMessage.SetActive(false);
    endMessage.SetActive(false);

    //카메라 사이즈 세팅
    cam.GetComponent<Camera>().orthographicSize = tileSettingData[(int)stageLv, 1] * .5f;
    cam.transform.position =
        new Vector3(tileSettingData[(int)stageLv, 0] * .5f - .5f, tileSettingData[(int)stageLv, 1] * .5f -.5f, cam.transform.position.z);

	//게임 난이도에 맞춰 해당 난이도의 세로열 수 만큼 반복
    for (int c = 0; c < tileSettingData[(int)stageLv, 1]; c++)
    {
        Transform col = new GameObject().transform; //세로열 노드 생성
        col.SetParent(board);
        col.gameObject.name = c.ToString();
        col.localPosition = new Vector3(0, c, 0);
        
		 //게임 난이도에 맞춰 세로열 노드 하위에 가로열 타일 생성
        for (int w = 0; w < tileSettingData[(int)stageLv, 0]; w++)
        {
            Tile t = Instantiate(tile, col);
            t.gameObject.SetActive(true);
            t.gameObject.name = w.ToString() + " , " + col.gameObject.name;
            t.transform.localPosition = new Vector3(w, 0, 0);
            tiles.Add(t);
        }
    }
}

 

Level.easy 의 int형 값은 0이다. 그러니까,

 

tileSettingData[0] = tileSettingData[(int)Level.easy]  = { 6, 12 }
mineSettingData[0] = mineSettingData[(int)Level.easy] =  10

 

상기한 배열 값과 같고,

쉬움 난이도에서 가로 6칸, 세로 12칸의 바탕에 숨겨진 지뢰가 10개라는 의미이다.

 

이번 작업에서 이 형변환으로 이득을 본 가장 큰 부분은 바로 DropDownUI와의 연계였다.

 

 

 

오른쪽 구글판 지뢰찾기에서는 좌측 상단 드롭다운 메뉴로 난이도를 바꾸면,

그 즉시 새 난이도로 게임을 재시작한다.

많이 사용해보지 않아 어떻게 구현해야 하는지 고민했지만

DropDown UI를 사용해 간단히 구현할 수 있었다.

 

public void LevelSetting(int i = 0)
{
    stageLv = (Level)i;

    state = State.stay;

    time = 0;
    timer.text = string.Format("{0:D3}", Mathf.CeilToInt(time));
    flagCount = mineSettingData[(int)stageLv];
    flags.text = flagCount.ToString();
    digCount = 0;

    if (board != null)
        Destroy(board.gameObject);

    board = new GameObject().transform;
    board.gameObject.name = "Top Tile Nord";
    board.position = new Vector3();

    tiles.Clear();
    tiles = new List<Tile>();

    gameoverMessage.SetActive(false);
    endMessage.SetActive(false);

    cam.GetComponent<Camera>().orthographicSize = tileSettingData[(int)stageLv, 1] * .5f;
    cam.transform.position =
        new Vector3(tileSettingData[(int)stageLv, 0] * .5f - .5f, tileSettingData[(int)stageLv, 1] * .5f -.5f, cam.transform.position.z);

    camOrigin = cam.transform.position;

    for (int c = 0; c < tileSettingData[(int)stageLv, 1]; c++)
    {
        Transform col = new GameObject().transform;
        col.SetParent(board);
        col.gameObject.name = c.ToString();
        col.localPosition = new Vector3(0, c, 0);

        for (int w = 0; w < tileSettingData[(int)stageLv, 0]; w++)
        {
            Tile t = Instantiate(tile, col);
            t.gameObject.SetActive(true);
            t.gameObject.name = w.ToString() + " , " + col.gameObject.name;
            t.transform.localPosition = new Vector3(w, 0, 0);
            tiles.Add(t);
        }
    }

    AudioPlay(cam.GetComponent<AudioSource>(), intro);
}

 

 

게임에 사용한 LevelSetting() 함수의 전문이다.

 

 

LevelSetting()는 DropDown의 Value가 변경될 때 호출시키는 Dynamic int 이벤트로 등록하고

여기에 매개변수로 DropDown의 Value를 사용한다.

 

tileSettingData[0] = { 6, 12 }
mineSettingData[0] =  10

 


4. List<Type> , Add(), Remove(), Clear() ...

지뢰찾기를 시작할 때

처음 선택한 타일이 하필 지뢰가 숨겨진 타일이라면

게임은 허무하게 끝날 것이다.

때문에 개발자들은

무조건 첫 번째 타일은 지뢰가 숨겨진 타일이 아니게 만들어 둔다.

덧붙이자면, 주변도 지뢰가 없는 타일말이다.

 

이러면 타일을 세팅하고

타일에 지뢰를 숨겨두는 타이밍을 다시 정의해야 한다.

바로 첫 번째 클릭을 하는 순간이다.

 

1) 첫번째 클릭은 어떻게 구분하는가?

public enum State
{
    stay,
    play,
    gameover
}

public class GameManager : MonoBehaviour
{
    [Header("게임 상태")]
    public State state = State.stay;
    
    //게임을 시작하면 대기상태로 타일만 세팅해둔다.
    void Start()
    {
        state = State.stay;
        LevelSetting(0);
	}
    
	void Update()
    {
 	   if (Input.GetMouseButtonDown(0))
       {
       		//stay 상태에서 클릭했다면 그 시점은 게임 시작 순간, 그 직전이다.
       		if (state == State.stay){
				state = State.play;
				MineSetting(stageLv, hit.collider);
       		}
			
            Tile hitTile = hit.collider.GetComponent<Tile>();

			//깃발 꽂은 곳을 선택하면 깃발을 취소
			if (hitTile.flagOn){
                hitTile.flagOn = false;
                flagCount++;
                flags.text = flagCount.ToString();
			}

			//지뢰가 있는 곳을 선택하면 게임 오버
			if (hitTile.isMine){
            	hitTile.sprt.sprite = mineSprt;                
                AudioPlay(audioSource, bomb);
                StartCoroutine(CamShake(power * 2, 1));            
                state = State.gameover;
                AudioPlay(cam.GetComponent<AudioSource>(), lose);            
            	gameoverMessage.SetActive(true);
                return;
            }

			else{TileOpen(hitTile);}
    	}
	}
}

 

 

if (state == State.stay) {

    state = State.play;
    MineSetting(stageLv, hit.collider); // 지뢰가 숨겨진 타일을 설정하는 함수

}

 

 

 

2) 첫번째 선택한 타일은 지뢰가 아니면서 그 주변에도 지뢰가 없어야 한다.

 

이 부분도 고민했지만 List를 사용해 쉽게 해결할 수 있었다.

void MineSetting(Level lv, Collider2D hitCol2d)
{
    //처음 선택한 타일은 무조건 안전한 타일
    Collider2D[] cols = Physics2D.OverlapBoxAll(hitCol2d.transform.position, new Vector2(1, 1), 0, 1 << LayerMask.NameToLayer("Tile"));
    
    //처음 선택한 타일을 임시 리스트에 추가하고,
    List<Tile> tmps = new List<Tile>();
    foreach (var v in cols)
    {
        tmps.Add(v.GetComponent<Tile>());
    }
	
    //전체 타일 리스트에서 처음 선택한 타일을 제거한다.
    foreach (var v in tmps)
    {
        tiles.Remove(v);
    }
	
    //타일들의 지뢰 여부를 저장할 배열을 새로 만들고
    bool[] isMines = new bool[tiles.Count];

    //난이도에 따라 지뢰 개수만큼 배열에 추가
    for (int i = 0; i < isMines.Length; i++)
    {
        if (i < mineSettingData[(int)lv])
            isMines[i] = true;
        else isMines[i] = false;
    }

    //지뢰 유무의 순서 섞기
    for (int i = 0; i < isMines.Length; i++)
    {
        int rand = Random.Range(0, isMines.Length);
        var tmp = isMines[rand];
        isMines[rand] = isMines[i];
        isMines[i] = tmp;
    }

    //섞인 지뢰 유무를 전체 배열에 적용
    for (int i = 0; i < tiles.Count; i++)
    {
        tiles[i].isMine = isMines[i];
    }
	
    //리스트에서 제거했던 타일들을 다시 추가
    foreach (var v in tmps)
    {
        tiles.Add(v);
    }
}

 

반복작업을 처리하기 전에 리스트에서 빼두었다가

작업을 마친 후 다시 리스트에 추가했다.

여기서 리스트에 리스트를 추가하는데 반복문을 썼지만

AddRange()도 쓸 수 있다.

 

 

이번엔 강좌를 참고하는 대신

시중에 공개된 간단한 게임을 모작하는 작업이었다.

구글 play 게임에는 간단한 게임들을 무료로 즐길 수 있는데,

다음번에 모작 연습을 한다면 참고하기 좋을 것 같다.

 

 

 


 

완성한 프로젝트의 빌드 파일은 아래의 주소로 다운 받을 수 있다.

https://drive.google.com/file/d/11o7p7OoSwE6S5RcgCs6pL-LcnxcP-DrF/view?usp=sharing

 

Minesweeper.zip

 

drive.google.com

 

 

 

공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2024/09   »
1 2 3 4 5 6 7
8 9 10 11 12 13 14
15 16 17 18 19 20 21
22 23 24 25 26 27 28
29 30
글 보관함