6 분 소요

Intro

  1. 게임개요
    • 게임 이름 : 수박게임
    • 게임 장르 : 퍼즐게임(모바일 싱글)
    • 게임 플랫폼 : 모바일(Android)
    • 게임 설명 : 주어진 공간 안에서 같은 종류의 과일을 합쳐 더 큰 과일을 만들어내는 퍼즐게임입니다.
    • 게임 목표 : 과일을 합쳐 최종적으로 수박 2개를 만들어내는것이 목표입니다.
    • 개발 기간 : 2일
  2. 게임 시스템
    • 게임 규칙 : 화면을 터치(드래그)하여 과일을 떨어뜨릴 위치를 정하고 터치를 떼어 과일을 떨어뜨릴 수 있습니다. 한정된 공간을 최대한 활용하여 제한선을 넘지않으면서 수박을 만들어내야합니다.
    • 레벨 디자인 : 체리부터 수박까지 총 11개의 레벨로 구성됩니다. 과일의 레벨이 높아질수록 크기가 커져 합쳐지기 어렵도록 합니다.
  3. 게임 다운로드

게임화면 구성

게임 화면 구성은 아래와같습니다.

image

  • 플레이 화면
    • 갈색의 네모 틀안에 과일을 떨어뜨려 과일을 합치게됩니다.
    • 화면을 터치 & 드래그 하여 떨어뜨릴 위치를 선정할 수 있습니다.
      • 터치하면 떨어뜨릴 위치를 보여주는 Drop Line이 표시됩니다.
    • 같은 과일을 합치면 더 높은 단계의 과일이 생성됩니다.
    • 일정 높이 이상 과일이 쌓이면 경계선 이펙트가 출력되며, 경계선에 과일이 닿은지 일정시간이 지나면 게임오버가됩니다.
  • UI
    • 현재 점수가 표시됩니다.
    • 다음에 나올 과일이 표시됩니다.
    • 일시정지 버튼을 눌러 게임을 일시정지할 수 있습니다.
    • 게임오버, 게임클리어시 UI가 활성화됩니다.
      • 게임을 재시작할 수 있습니다.

구현

수박게임의 주요 로직은 다음과 같습니다.

  • 과일 생성
    • 떨어뜨릴 과일을 생성합니다.
    • 다음 순서에 생성될 과일도 미리 생성합니다.
  • 과일 움직이기
    • 화면 터치(클릭) & 드래그를 통해 과일을 움직일 수 있고 터치를 떼면 과일을 떨어뜨립니다.
  • 과일 합치기
    • 같은 과일이 접촉하면 다음단계의 과일이 생성됩니다.
  • 게임 종료
    • 경계선을 넘어 게임오버가된경우
    • 수박 두개를 만들어 게임이 끝난경우

과일

수박게임의 과일 에셋을 구하여 프리팹을 만들어주었습니다.

image


각 프리팹에는

물리를 적용하기위한 Rigidbody, 충돌을 위한 Collider 컴포넌트가 추가되어있습니다.

또한 컨트롤과 충돌이벤트등을 구현하기위한 Fruit 스크립트가 추가되어있습니다.

과일 생성

과일을 생성하고 관리하는것은 FruitManager 클래스가 담당하였습니다.

image

/*
                            << FruitManager >>

        - 현재/다음 과일의 생성 및 관리(풀링)
            - 다음 과일은 UI에 이미지로 표시
        
        - 터치 & 드래그 이벤트 관리

        - 게임 리셋
            - 씬에 활성화되어있는 과일들을 모두 풀로 반환
 */


public class FruitManager : MonoBehaviour
{
    [SerializeField] private Transform spawnPoint;                  // 과일 생성 위치
    [SerializeField] private Transform poolParent;                  // 풀링 위치
    [SerializeField] private GameObject[] fruitPrefabs;             // 과일 프리팹
    [SerializeField] private Sprite[] fruitSprites;                 // 과일 이미지
    [SerializeField] private Image nextFruitImage;                  // 다음 과일 이미지

    #region ** Fields **
    public Fruit currentFruit;                                      // 현재 과일
    public Fruit nextFruit;                                         // 다음 과일

    public bool isWaitingNextFruit = false;                         // 중복방지용 플래그
    public int watermelonCount;                                     // 수박 갯수(2개가 되면 목표달성)

    private int nextFruitLevel;                                     // 다음 과일 레벨
    private int poolSize = 5;                                      // 과일 풀 사이즈(과일별)

    private Dictionary<int, Queue<Fruit>> fruitPool = new();        // 과일 풀
    private List<Fruit> activeFruits = new();                       // 활성화 되어있는 과일(게임 리셋용)

    #endregion

    private static FruitManager instance;
    public static FruitManager Instance => instance;


    private void Awake()
    {
        if (instance != null && instance != this)
        {
            Destroy(gameObject);
            return;
        }

        instance = this;
        InitPool();
    }

    private void Start()
    {
        MakeFruit();
    }
   
    // 풀 초기화
    private void InitPool()
    {
        // 과일 레벨별
        for(int i = 0;i<fruitPrefabs.Length;i++)
        {
            fruitPool[i] = new Queue<Fruit>();

            // 과일당 10개
            for(int j = 0;j<poolSize;j++)
            {
                GameObject obj = Instantiate(fruitPrefabs[i], poolParent);
                obj.SetActive(false);
                Fruit fruit = obj.GetComponent<Fruit>();
                fruitPool[i].Enqueue(fruit);
            }
        }
    }

    // 풀에서 과일 꺼내쓰기
    private Fruit GetFruitFromPool(int level)
    {
        // 풀에 여분이 있을 때
        if(fruitPool[level].Count > 0)
        {
            Fruit fruit = fruitPool[level].Dequeue();
            activeFruits.Add(fruit);
            fruit.gameObject.SetActive(true);
            return fruit;
        }
        // 풀에 여분이 없을 때
        else
        {
            GameObject obj = Instantiate(fruitPrefabs[level]);
            return obj.GetComponent<Fruit>();
        }
    }

    // 과일 생성
    private Fruit SpawnFruitObj(int level)
    {
        Fruit fruit = GetFruitFromPool(level);

        // 위치 설정
        fruit.transform.position = spawnPoint.position;
        fruit.transform.rotation = Quaternion.identity;

        // 드랍되기 전까지 비활성화
        fruit.GetComponent<Rigidbody2D>().simulated = false;

        return fruit;
    }

    
    // 다음 과일 만들기
    public void MakeFruit()
    {
        // 게임 시작시
        if (currentFruit == null)
        {
            // 제일 처음 과일은 체리
            currentFruit = SpawnFruitObj(0);

        }
        else
        {
            currentFruit = SpawnFruitObj(nextFruit.level);
        }

        // 랜덤(체리~오렌지)
        nextFruitLevel = Random.Range(0, 5);
        // 다음에 올 과일
        nextFruit = fruitPrefabs[nextFruitLevel].GetComponent<Fruit>();
        // 다음에 올 과일 아이콘 설정
        nextFruitImage.sprite = fruitSprites[nextFruitLevel];
        nextFruitImage.SetNativeSize();
    }

    // 다음 레벨 과일 생성
    public void SpawnNextLevel(int level, Vector3 position)
    {
        Fruit fruit = GetFruitFromPool(level + 1);
        fruit.transform.position = position;
        fruit.transform.rotation = Quaternion.identity;
        fruit.GetComponent<Rigidbody2D>().simulated = true;
    }

    // 사용한 과일을 풀에 반환하기
    public void ReturnFruitToPool(Fruit fruit)
    {
        fruit.gameObject.SetActive(false);
        fruitPool[fruit.level].Enqueue(fruit);
    }

    // 게임 재시작시
    public void Reset()
    {
        foreach(var fruit in activeFruits)
        {
            ReturnFruitToPool(fruit);
        }

        activeFruits.Clear();
        currentFruit = null;
        nextFruit = null;
        isWaitingNextFruit = false;

        MakeFruit();
    }

    // 드래그
    public void TouchDown()
    {
        if (currentFruit == null || isWaitingNextFruit || GameManager.Instance.isPaused || 
            GameManager.Instance.isGameOver || GameManager.Instance.isGameClear) return;

        currentFruit.Drag();
    }

    // 드랍
    public void TouchUp()
    {
        if (currentFruit == null || isWaitingNextFruit || GameManager.Instance.isPaused ||
            GameManager.Instance.isGameOver || GameManager.Instance.isGameClear) return;

        currentFruit.Drop();
        StartCoroutine(WaitForNextFruit());
    }

    // 1초 딜레이 후 과일 세팅
    IEnumerator WaitForNextFruit()
    {
        isWaitingNextFruit = true;

        yield return new WaitForSeconds(1f);        

        MakeFruit();

        isWaitingNextFruit = false;
    }
}
  • 오브젝트 풀링을 사용하여 과일을 미리 생성하였습니다.

  • 첫번째 과일은 체리로 고정하였고, 그 이후의 과일은 체리~오렌지까지 랜덤하게 생성하도록 하였습니다.

image

과일 컨트롤

과일의 컨트롤과 충돌이벤트의 구현을 Fruit 클래스가 담당하였습니다.

과일은 터치&드래그로 움직일 수 있으며, 터치를 뗄 때 과일을 떨어뜨릴 수 있도록 하였습니다.

/*
                            << Fruit >>

        - 과일 움직임
            - 터치 & 드래그
        
        - 과일 합쳐짐
            - Collsion 이벤트로 구현

        - 경계선 이펙트 활성화 요청 및 게임오버
            - Trigger 이벤트로 구현
 */


public class Fruit : MonoBehaviour
{
    [SerializeField] private GameObject dropLine;           // 드랍위치 표시줄

    private bool isDrag;                                    // 드래그 여부
    private bool isMerge;                                   // 합쳐짐 여부
    private float boundaryEffectTimer = 0f;                 // 경계선 활성화 타이머
    private float deadTimer = 0f;                           // 게임오버 타이머

    private Rigidbody2D rigid;
    private Animator anim;

    public int level;                                       // 과일 레벨

    readonly private int hashLevel = Animator.StringToHash("Level");

    private void Awake()
    {
        Init();
    }

    private void OnEnable()
    {
        anim.SetInteger(hashLevel, level);
    }

    private void Update()
    {
        if (GameManager.Instance.isGameOver || GameManager.Instance.isGameClear || GameManager.Instance.isPaused)
            return;

        MoveFruit();
    }

    // 컴포넌트 초기화
    private void Init()
    {
        rigid = GetComponent<Rigidbody2D>();
        anim = GetComponent<Animator>();
    }

    // 과일 합치기
    private void OnCollisionEnter2D(Collision2D collision)
    {
        if (!collision.gameObject.CompareTag("Fruit")) return;

        Fruit other = collision.gameObject.GetComponent<Fruit>();

        // 같은 과일만 합치기
        if (level == other.level && !isMerge && !other.isMerge && level < 10)
        {
            // 두 과일의 중간위치 계산
            Vector3 midPos = (transform.position + other.transform.position) / 2f;

            isMerge = true;
            other.isMerge = true;

            FruitManager.Instance.ReturnFruitToPool(this);
            FruitManager.Instance.ReturnFruitToPool(other);

            // 다음 레벨 과일 생성
            FruitManager.Instance.SpawnNextLevel(level, midPos);
            AudioManager.Instance.PlaySFX(AudioManager.Sfx.Pop);

            // 점수 획득
            AddScore();

            // 수박(레벨10)을 완성시켰을 때
            if(level == 9)
            {
                FruitManager.Instance.watermelonCount++;

                // 수박을 두개 만들었을경우 게임 종료
                if(FruitManager.Instance.watermelonCount == 2)
                {
                    GameManager.Instance.GameClear();
                }
            }
        }
    }

    // 경계선 이펙트 활성화 및 게임오버 처리
    private void OnTriggerStay2D(Collider2D collision)
    {
        // 경계선 활성화
        if (collision.CompareTag("BoundaryEffect")) 
        {
            boundaryEffectTimer += Time.deltaTime;
            if (boundaryEffectTimer > 1f)
            {
                collision.GetComponent<BoundaryEffect>().boundaryRenderer.enabled = true;
            }
        }
        // 게임 오버
        else if(collision.CompareTag("Boundary"))
        {
            deadTimer += Time.deltaTime;
            if (deadTimer > 4f)
            {
                GameManager.Instance.GameOver();
            }
        }
    }

    // 경계선 비활성화
    private void OnTriggerExit2D(Collider2D collision)
    {
        if (collision.CompareTag("BoundaryEffect"))
        {
            boundaryEffectTimer = 0f;
            collision.GetComponent<BoundaryEffect>().boundaryRenderer.enabled = false;
        }

        if(collision.CompareTag("Boundary"))
        {
            deadTimer = 0f;
        }
    }

    // 과일 움직이기
    private void MoveFruit()
    {
        if (!isDrag || FruitManager.Instance.isWaitingNextFruit) return;

        // 마우스 포인터 따라가기
        Vector3 mousePos = Camera.main.ScreenToWorldPoint(Input.mousePosition);

        // 가로로 최대 이동가능한 영역
        float minX = -1.96f + transform.localScale.x / 2f;
        float maxX = 1.96f - transform.localScale.x / 2f;
        float clampedX = Mathf.Clamp(mousePos.x, minX, maxX);

        // 가로로만 이동
        mousePos.z = 0;
        mousePos.y = transform.position.y;
        transform.position = new Vector3(clampedX, transform.position.y, transform.position.z);
    }
    
    // 점수 획득
    private void AddScore()
    {
        GameManager.Instance.score += (level+1) * 2 + 1;
    }

    // 드래그
    public void Drag()
    {
        isDrag = true;
        dropLine.SetActive(true);
    }

    // 과일 드랍
    public void Drop()
    {
        isDrag = false;
        dropLine.SetActive(false);
        rigid.simulated = true;
    }
}
  • CollisionEnter 이벤트에서 과일끼리의 충돌을 처리하였습니다.
    • 같은 과일의 경우 합쳐지도록하였고, 수박이 만들어졌을때 count를 늘려 게임의 클리어 여부를 판단하였습니다.

image

  • Trigger 이벤트에서 게임 오버를 판단하였습니다.
    • 과일이 특정 높이 이상 쌓이게되면 경계선이 나타나며, 경계선에 일정시간 닿아있을경우 게임오버가됩니다.

image

게임오버 및 게임클리어

게임 클리어 및 실패시 UI를 아래와같이 구현하였습니다.

  • 게임 클리어
    • UI의 시각적 이펙트를 넣어보았습니다.

image

  • 게임 오버

image

그 밖의것은 깃허브에 방문하면 확인할 수 있습니다.

댓글남기기