Android 보드게임 Oh-Mok!

Repository & Download

github play store link

게임 특징

  • Unity
  • Android
  • Photon
  • 전략 보드게임
  • 서브 프로그래머
  • 기획자 3, 프로그래머 3, 그래픽 2, 사운드 1
  • Google play store 출시

설명

프로젝트의 서브 프로그래머로써 참여하여 유니티의 파티클 시스템을 사용한 effect 제작, Dotween 에셋을 사용한 간단한 카드 뒤집기, 확대 등등의 애니메이션 효과 제작, 각 필요한 파트에 사운드 추가, json을 이용한 유저 데이터 저장, 게임 매니저의 무승부 기능과 타이머 추가, 네트워크 매니저의 두 클라이언트의 카드가 서로 동기화 되는 기능, 무승부 제안이 다른 클라이언트에도 동기화되어 전달되는 기능, 이외 간단한 레이어 조정과 버그 수정 등 여러 파트를 오가며 다양한 업무를 수행하였습니다. 여러 파트를 개발하였고 첫 프로젝트였던 만큼 정말 다 적기 힘들만큼 많은 것을 배울 수 있는 프로젝트였습니다.

구현 파트

void Update() {
        if(timeron) {
            time+=Time.deltaTime; //time이란 int변수에 각 턴의 지나간 시간을 저장
            if(time>=30) {
                if(isMyTurn) endMyTurn(); //시간이 30초를 지나면 (자기턴일때) 턴을 종료
            }
        }
    }

[PunRPC] void startMyTurn()
    {
        isMyTurn = true;
        canuseCard = true;  // 카드를 사용할 수 있게 함
        timeron=true;
        for (int i = 0; i < 81; i++)
        {
            if (gomokuData[i] == 0)   // 아직 돌을 두지 않은 부분만 클릭할 수 있게 함
                gomokuTable[i].interactable = true;
        }
        PV.RPC("timermake", RpcTarget.AllBuffered); //두 클라이언트 양쪽에 모두 'timermake' 함수를 실행시킴
        NetWorkManager.instance.printScreenString("나의 턴");  // '나의 턴' 출력
    }

[PunRPC] void timermake() {
    if(timerins!=null) Destroy(timerins); //만약 타이머가 이미 있다면 파괴함
    if(isMyTurn) {
        timerins=Instantiate(timer, new Vector3(-150,-550,10), Quaternion.identity); // 자기쪽 위치
        timerins.transform.SetParent(this.transform.parent.transform,false); //timer는 unity UI의 fill image 기능을 사용하기에 캔버스 내부 오브젝트의 자식으로 만들어줌
    }
    else {
        timerins=Instantiate(timer, new Vector3(-400,830,10), Quaternion.identity); //상대쪽 위치
        timerins.transform.SetParent(this.transform.parent.transform,false);
    }
    time=0; //시간 초기화
}

timer.gif

Unity UI의 fill image 기능을 사용하여 시계바늘이 회전하여 지나간 자리는 빨간색으로 채워주는 타이머를 구현하여 각 턴의 제한시간을 볼 수 있게 하였습니다. (녹화 프로그램 상의 문제로 빨간색이 깨져나옴) 기획 쪽의 의견으로 타이머의 위치를 자신의 턴일 때는 자신 캐릭터 옆에, 상대 턴일땐 상대 캐릭터 옆에 생성시키도록 하였습니다.

void dolmove(Image img) { //돌 5개가 모이면 가운데 돌로 돌들이 이동하는 애니메이션
    Vector3 tmp=img.transform.position;
    Sequence seq=DOTween.Sequence();
    seq.Join(img.transform.DOMove(charging.center,0.75f));
    seq.Join(img.transform.DOScale(new Vector3(0,0,0),3f));
    seq.Join(img.DOFade(0, 2f).SetEase(Ease.InQuad));
    seq.Append(img.transform.DOMove(tmp,0));
    seq.Join(img.transform.DOScale(new Vector3(1,1,1),0));
}


if(PhotonNetwork.IsMasterClient)  // 검은 돌이 오목을 완성한 경우. 내가 MasterClient이면 내가 검은 돌을 두는 사람이므로 내가 공격에 성공한 것임 → 상대방 HP를 깎음
    {
        StartCoroutine(enemyshoot()); //충돌 시 폭발하는 파티클 투사체를 상대 캐릭터를 향해 발사함
        PlayerManager.enemyPlayerManager.GetDamaged();
    }
    else
    {
        StartCoroutine(myshoot()); //투사체를 내 캐릭터를 향해 발사함
        PlayerManager.myPlayerManager.GetDamaged();
    }

particle.gif

unity 엔진의 particle system을 사용하여 돌 5개(오목)가 발생 시 각 돌의 위치에서 가운데 돌을 향해 광선을 발사하는 효과, 그 이후 가운데 돌에서 캐릭터를 향해 투사체를 발사하여 캐릭터와 충돌 시 폭발하는 효과를 제작하였습니다. 그리고 Dotween 에셋을 사용하여 각 돌들이 가운데 돌을 향해 모이는 애니메이션을 제작했습니다.

    public void setmaster(float sliderval) {
        audiomixer.SetFloat("Master", Mathf.Log10(sliderval)*20);
        playerData.mastervol=sliderval;
        SavePlayerDataToJson();
    }

    public void setbgm(float sliderval) {
        audiomixer.SetFloat("BGM", Mathf.Log10(sliderval)*20);
        playerData.bgmvol=sliderval;
        SavePlayerDataToJson();
    }
    public void setsfx(float sliderval) {
        audiomixer.SetFloat("SFX", Mathf.Log10(sliderval)*20);
        playerData.sfxvol=sliderval;
        SavePlayerDataToJson();
    }

각 사운드의 볼륨을 설정 창의 슬라이더와 연결하여 json 파일의 유저 데이터에 저장하는 함수를 제작했습니다.

    public void draw() 
    {
        PlayerManager.myPlayerManager.character_img.GetComponent<SpriteRenderer>().sprite=PlayerManager.myPlayerManager.drawimg; //캐릭터 초상화를 화해제안 이미지로 교체
        PlayerManager.myPlayerManager.character_img.GetComponent<SpriteRenderer>().transform.localScale=new Vector3(0.15f,0.15f,0.15f);
        PlayerManager.myPlayerManager.drawready=true;
        this.gameObject.GetComponent<AudioSource>().Play(); //화해제안 효과음을 play
        PV.RPC("drawsyncro", RpcTarget.OthersBuffered);
        if(PlayerManager.myPlayerManager.drawready==true && PlayerManager.enemyPlayerManager.drawready==true) {
            GameManager.instance.draw();
            PV.RPC("drawstop", RpcTarget.AllBuffered); //양쪽 모두 화해 버튼을 눌렀을 시 게임을 종료하고 무승부 결과창을 띄움
        }    
    }

    [PunRPC] void drawsyncro() { //상대 클라이언트에 내 클라이언트에서 화해 버튼을 누른 결과를 동기화하는 함수
        this.gameObject.GetComponent<AudioSource>().Play();
        PlayerManager.enemyPlayerManager.character_img.GetComponent<SpriteRenderer>().sprite=PlayerManager.enemyPlayerManager.drawimg;
        PlayerManager.enemyPlayerManager.character_img.GetComponent<SpriteRenderer>().transform.localScale=new Vector3(0.15f,0.15f,0.15f);
        PlayerManager.enemyPlayerManager.drawready=true;
    }

draw.gif

마법학교의 학생들끼리 오목을 둔다는 설정에 따라 게임 도중 화해 버튼을 누르면 상대에게 캐릭터 초상화가 화해 이미지로 바뀌고 화해제안 효과음을 재생하는 기능을 구현하였습니다. 양쪽 다 화해 제안에 응할 시에는 게임은 무승부로 종료됩니다.

public class enemyshoot : MonoBehaviour {
	ParticleSystem ps;
	ParticleSystem.Particle[] m_Particles;
	public Vector3 target; //목표 좌표
	public float speed = 5f;
	int numParticlesAlive;
	void Start () {
		ps = GetComponent<ParticleSystem>();
		if (!GetComponent<Transform>()){
			GetComponent<Transform>();
		}
	}
	void Update () {
		m_Particles = new ParticleSystem.Particle[ps.main.maxParticles];
		numParticlesAlive = ps.GetParticles(m_Particles);
		float step = speed * Time.deltaTime;
		for (int i = 0; i < numParticlesAlive; i++) {
			m_Particles[i].position = Vector3.LerpUnclamped(m_Particles[i].position, target, step); // 선형보간을 이용해 목표 좌표로 입자들을 이동시킴
		}
		ps.SetParticles(m_Particles, numParticlesAlive);
	}
}

돌 5개가 모였을 때 특정 좌표값을 향해 입자가 이동하는 이 스크립트를 가진 particle 오브젝트가 instantiate 되도록 구성하였고 particle에는 player 태그를 가진 오브젝트에 collide 판정을 부여하여 플레이어 이미지와 격돌 시 폭발하도록 하였습니다.

[PunRPC] void cardsyncro(int[] indexs) {
        PlayerManager.enemyPlayerManager.cardDataBuffer=new List<CardData>(100); 
        for(int i=0; i<indexs.Length; i++) {
            CardData item = PlayerManager.enemyPlayerManager.cardDataSO.items[indexs[i]];
            PlayerManager.enemyPlayerManager.cardDataBuffer.Add(item); // 상대 클라이언트에서 보이는 나의 손패를 실제 내 클라이언트에서의 나의 손패와 동기화시킴
        }
        PlayerManager.enemyPlayerManager.AddFiveCard();
    }

playermanager 스크립트의 start 시에 이 cardsyncro 함수를 포톤 네트워크의 othersbuffered로 상대 클라이언트에서 발동시켜 자신의 손패에 있는 카드들과 상대 클라이언트에서 보이는 나의 손패에 있는 카드가 순서와 내용이 같도록 동기화하였습니다. 이는 후술할 cardflip 함수를 구현하는데 있어 필수적인 과정이었습니다.

IEnumerator cardflip(int num) {
        Sequence seq=DOTween.Sequence();
        GameObject card=enemyPlayerManager.myCardsGameObj[num];
        Card cardscript=card.GetComponent<Card>();
        seq.Join(card.transform.DOMove(card.transform.position-new Vector3(0,3,0),0.75f).SetEase(Ease.OutQuad)); //카드가 아래로 이동
        seq.Append(card.transform.DORotate(new Vector3(0,180,0),0.5f));
        seq.Append(card.transform.DOScale(new Vector3(0.3f,0.3f,0),0.1f));
        yield return new WaitForSeconds(0.96f);
        card.GetComponent<SpriteRenderer>().flipX=true;
        card.GetComponent<SpriteRenderer>().sprite=cardscript.cardFront;
        // 카드가 뒤집히면서 90도 가량 뒤집어졌을 때 스프라이트를 바꿔주어 마치 양면 카드가 뒤집힌듯한 효과를 준다. unity에서는 양면 오브젝트의 구현이 힘들기에 필요한 조치였다.
    }

    IEnumerator delay(int index) {
        yield return new WaitForSeconds(3f);
        Destroy(myCardsGameObj[index]); //3초간의 딜레이를 주고 카드를 파괴
        myCardsGameObj.RemoveAt(index);
        myCards.RemoveAt(index);
        for(int i = 0;i<myCards.Count;i++)
        {
            myCards[i].myHandIndex = i;
        }
        CardAlignment();
    }

cardflip.gif

unity engine의 dotween 기능을 사용하여 상대방이 카드를 사용시에 카드가 아래로 내려와 뒤집혀서 무슨 카드를 사용하였는지 보여주고 카드가 사라지는 효과를 제작하였습니다. coroutine을 이용하여 각 애니메이션 효과와 파괴 등의 사이에 적절한 delay를 부과하였습니다.

이외에 여러 버그 수정, pannel 상황에 따른 배경 수정, 카드 추가시에 화면 밖에서 지정된 위치로 날아오는 효과, 인게임 오브젝트들 사이의 레이어 조절 등등 잡다한 여러 구현을 맡았습니다.

여담

기획도 프로그래밍도 모두 처음인 프로젝트라 좌충우돌한 프로젝트였지만 다행히 모두 노력해주신 덕분에 기간은 연장됬지만 제대로 출시할 수 있었습니다. 평소부터 게임은 종합예술이라 생각했었는데 그만큼 종합적으로 힘들다는 것을 여실히 느낄 수 있었고 내가 게임을 하며 보았던 구현, 버그들이 어째서 그렇게 됬었는지 이제는 조금이나마 이해할 수 있었습니다.