개발 낙서장

[Unity2D] 1:1 대전 게임 본문

Unity/Devlog

[Unity2D] 1:1 대전 게임

권승준 2023. 10. 4. 13:20

유니티를 기반으로 개발한 2D 게임이고 포톤 서버를 이용하고 파이어베이스를 연동한 실시간 1:1 대전 게임이다

회원가입, 로그인, 유저 정보, 채팅 시스템, 무기와 업그레이드 선택 등의 기능이 구현돼 있다.

https://github.com/SeungJun-Kwon/Unity2DShootingGame

로그인

회원가입과 로그인은 FirebaseAuth와 FirebaseFirestore에서 검증해 통과하면 성공하는 방식으로 구현했다.

둘 다 방식이 비슷하므로 로그인을 기준으로 설명하면

  1. ID와 PW가 잘 입력이 됐는지
  2. FirebaseAuth의 SignInWithEmailAndPasswordAsync가 잘 진행이 됐는지
  3. 해당 유저의 정보가 FirebaseFirestore에서 로드가 되는지

3가지 검증 과정을 거친 이후에 로그인이 성공된다.

    public async Task<bool> SignIn(string email, string pw)
    {
        bool result = false;

        await _auth.SignInWithEmailAndPasswordAsync(email, pw).ContinueWith(task =>
        {
            if (task.IsCanceled)
            {
                Debug.Log("로그인 취소");
                return;
            }
            else if (task.IsFaulted)
            {
                // 실패 이유 => 이메일이 비정상, 비밀번호가 너무 간단, 이미 가입된 이메일 등등
                Debug.Log("로그인 실패 : " + task.Exception);
                return;
            }

            _user = task.Result;

            Debug.Log("로그인 성공");

            result = true;
        });

        await Task.Delay(1500);

        return result;
    }

FirebaseAuth를 사용하기 위해 구현한 FirebaseAuthManager의 SignIn 메소드인데 이 메소드를 비동기로 선언한 이유는

대기 시간을 걸어놓지 않으면 로그인이 끝나기도 전에 해당 유저의 정보를 FirebaseFirestore에서 긁어오려고 해서 null이 발생하기 때문에 대기를 걸어놓기 위해 비동기 메소드로 선언했다.

로비 화면

로그인에 성공하면 로비 화면으로 넘어오게 된다. 로비에서는 내 정보를 확인, 수정할 수 있고 방을 만들거나 입장할 수 있다.

  • 유저 정보

유저 정보는 이름, 프로필 사진, 자기소개, 승패 횟수를 FirebaseFirestore에서 Save, Load 할 수 있도록 구현했다.

    public async void LoadUserInfo(string nickName, bool isMine = true)
    {
        _saveButton.gameObject.SetActive(isMine);
        _userIntro.readOnly = !isMine;
        _profileImageButton.enabled = isMine;

        _curInfo = await FirebaseFirestoreManager.Instance.LoadUserInfoByNickname(nickName);

        if(_curInfo == null)
        {
            Debug.Log("유저 정보 로드 실패");
            gameObject.SetActive(false);
            return;
        }

        _userProfile.sprite = ProfileSpriteManager.Instance.GetSprite(_curInfo.Profile);
        _userName.text = _curInfo.Name;
        _userWin.text = _curInfo.Win.ToString();
        _userLose.text = _curInfo.Lose.ToString();
        _userIntro.text = _curInfo.Intro;
    }

유저 정보를 확인하는 UI인 UserInfoUIManager에서 LoadUserInfo 메소드이다.

내 정보 혹은 다른 사람 정보를 클릭하면 nickName과 isMine이라는 파라미터를 받아 해당 유저의 정보를 확인할 수 있도록 해준다.

isMine의 값에 따라 수정 가능 여부를 설정해 주었다.

  • 방 리스트

로비 화면에서 방을 만들거나 입장할 수 있는데 입장할 수 있는 방 리스트도 표시된다.

  1. PhotonNetworkManager에서 MonoBehaviourPunCallbacks의 콜백 함수인 OnRoomListUpdate를 통해 현재 입장 가능한 방들의 정보를 Dictionary에 저장한다.
  2. 방 리스트가 업데이트될 때마다 로비 화면에서 방 정보들을 받아 방의 이름, 방에 있는 플레이어 수, 맵 정보들을 새로 표시해 준다.

방을 만들 때는 방의 이름, 맵 정보, 방의 비밀번호 등이 필요하기 때문에 RoomOption의 CustomProperties를 활용했고 CustomRoomPropertiesForLobby에 해당 정보들을 담아 로비에서도 커스텀 프로퍼티를 확인할 수 있게 했다.

    public void RenewalRoomList()
    {
        Dictionary<string, RoomInfo> roomDic = PhotonNetworkManager.Instance._roomDic;
        List<RoomInfo> roomList = new List<RoomInfo>();
        RoomUI tmp;

        foreach (var room in roomDic)
        {
            if(room.Value.IsOpen || room.Value.PlayerCount != 2)
                roomList.Add(room.Value);
        }

        if(_rooms.Count == 0)
        {
            foreach(var room in roomList)
            {
                tmp = Instantiate(_roomPrefab, _contentPanel);
                tmp.SetRoomInfo(room);
                _rooms.Add(tmp);
            }
        }
        else if(_rooms.Count <= roomList.Count)
        {
            int count = 0;

            for (; count < _rooms.Count; count++)
            {
                _rooms[count].gameObject.SetActive(true);
                _rooms[count].SetRoomInfo(roomList[count]);
            }

            for(; count < roomList.Count; count++)
            {
                tmp = Instantiate(_roomPrefab, _contentPanel);
                tmp.SetRoomInfo(roomList[count]);
                _rooms.Add(tmp);
            }
        }
        else
        {
            int count = 0;

            for(; count < roomList.Count; count++)
            {
                _rooms[count].gameObject.SetActive(true);
                _rooms[count].SetRoomInfo(roomList[count]);
            }

            for (; count < _rooms.Count; count++)
                _rooms[count].gameObject.SetActive(false);
        }
    }

로비 화면을 관리하는 LobbyUIManager에 있는 메소드이다. PhotonNetworkManager에서 OnRoomListUpdate가 실행될 때마다 이 메소드도 같이 실행된다.

현재 방의 리스트들을 받아 입장 가능한 방들(아직 대기 중이거나 방이 꽉 차지 않았을 경우)에 한해서 표시해 준다.

  • 방 내부

방에 입장하면 내 정보와 상대방 정보를 확인할 수 있고 서로 채팅이 가능하며 모든 플레이어가 준비가 완료되면 시작할 수 있다.

Photon의 Player에 커스텀 프로퍼티로 1 플레이어인지 2 플레이어인지, 해당 플레이어가 호스트인지 아닌지를 저장하도록 했다.

여기서부터 유저 간 소통이 가능해야 하므로 여러 변수들을 동기화해 주기 위해 포톤의 RPC 기능을 적극 활용했다.

인게임

게임의 전체적인 흐름은 9판 5선 승제이고 각자 원하는 무기를 선택한 이후 라운드에서 패배 혹은 비길 때마다 더 강해질 수 있는 능력을 선택하며 진행된다.

대충 이런 흐름이다. 처음에 무기를 고르고 이후 라운드를 진행하면서 업그레이드를 고르고 최종적으로 5승을 먼저 챙긴 사람이 게임을 이기게 되는 방식이다.

게임 내에서도 가볍게 채팅이 가능하며
이렇게 지형을 이용한 야비한 플레이도 가능하다...

  • 플레이어

실시간으로 가장 많이 동기화가 이루어지는 부분이라 상당히 힘들었다.

  • PlayerController 객체
  • 플레이어 상태 패턴을 구현한 IState 인터페이스
  • PlayerManager

먼저 PlayerController에는 화면에 보이는 플레이어의 변화, 플레이어 조작, 플레이어 상태 관리 등의 기능이 담겨있다.
거의 대부분의 변수와 메소드들을 실시간으로 동기화해주어야 하기 때문에 PhotonView와 RPC를 적극적으로 활용했다.

플레이어 상태 패턴은 IState라는 인터페이스를 만들어 OnEnter, OnExit, OnUpdate 등 메소드를 구현해 PlayerController에서 트리거를 실행하도록 했다.

PlayerManager에는 플레이어의 인게임적인 변수를 다루는 클래스이다. Hp, 이동속도, 현재 무기, 현재 업그레이드 등등.

  • 멀티 점프

업그레이드 중 멀티 점프 업그레이드가 있는데 이 부분에서 고민을 많이 하게 됐다.

처음에는 doubleJump라는 bool 형식의 변수를 만들어 더블 점프를 했는지 안 했는지를 체크하고자 했는데 버그도 많이 생기고 만약 트리플 점프 혹은 그 이상의 멀티 점프를 구현하고자 한다면 tripleJump, quadraJump.... 완전 하드코딩이 돼버린다.

그래서 고민하다가 최대 가능한 점프 횟수를 설정하고 남은 점프의 횟수를 점프를 할 때마다 빼면 되겠다는 생각이 들었다.

    protected virtual void Update()
    {
        if (photonView.IsMine && Available)
        {
            for (int i = 0; i < _iStateArr.Count; i++)
                _iStateArr[i].OnUpdate();

            if (Input.GetButtonDown("Jump"))
            {
                // 1. 땅에 있는 상태
                // 2. 공중이라면 더블 점프 업그레이드가 존재하며 더블 점프를 사용하지 않았을 때
                if (IsGrounded())
                    photonView.RPC(nameof(EnterState), RpcTarget.All, State.JUMP);
                else if (!IsGrounded() && _playerManager._jumpLeft > 0)
                    photonView.RPC(nameof(ForceEnterState), RpcTarget.All, State.JUMP);
            }

            if (Input.GetButtonDown("Attack") && !FindState(State.ATTACK))
                photonView.RPC(nameof(EnterState), RpcTarget.All, State.ATTACK);
        }
    }

    public bool IsGrounded()
    {
        return (_rigidbody.velocity.y <= 0.001f && _rigidbody.velocity.y >= -0.001f) && 
            Physics2D.OverlapBox(new Vector2(transform.position.x, transform.position.y - _spriteRenderer.bounds.size.y / 2), new Vector2(_spriteRenderer.bounds.size.x, 0.1f), 0f, LayerMask.GetMask("Ground"));
    }

IsGround 체크는 먼저 Rigidbody의 y축 속도가 0에 완전 근삿값인지, 그리고 발 밑에 땅이 있는지를 검증했다.

그래서 점프를 했을 때 IsGrounded가 true이면 그냥 점프를 하고 false이면 공중인 상태이므로 점프 횟수가 남았는지를 체크해 아직 남아있다면 멀티 점프가 가능한 것이므로 강제로 Jump State에 진입하여 한 번 더 점프를 하도록 했다.

  • 공격
    public void OnEnter()
    {
        if (_playerController.photonView.IsMine)
        {
            _playerController.PlayAnimation(State.ATTACK);
            _count = 0;
            _bulletGO = null;
            _bulletPos = Vector2.zero;
            _dir = Vector2.zero;

            if (_spriteSize == Vector2.zero)
                _spriteSize = _playerController._playerManager.CurWeapon._bullet.bounds.size;

            _attackDuration = 100f / _playerController._playerManager._curStat._attackSpeed;

            if (_playerController._gunPart.localPosition == (Vector3)_playerController._gunRightPos)
                _dir = Vector2.right;
            else
                _dir = Vector2.left;

            _bulletPos = new Vector2(_playerController._gunPart.position.x, _playerController._gunPart.position.y);

            for (int i = 0; i <= _playerController._playerManager._curStat._multiShot; i++)
            {
                Vector2 multiPos;
                if (i % 2 == 0)
                    multiPos = new Vector2(_bulletPos.x, _bulletPos.y - (_spriteSize.y * 1.5f * (i / 2)));
                else
                    multiPos = new Vector2(_bulletPos.x, _bulletPos.y + (_spriteSize.y * 1.5f * ((i / 2) + i % 2)));

                PhotonNetwork.Instantiate("Prefabs/Players/Bullet", multiPos, Quaternion.identity).TryGetComponent(out _bulletGO);
                _bulletGO.photonView.RPC("SetWeapon", RpcTarget.All, _playerController._playerManager.CurWeapon._name, _dir);
                _bulletGO._increaseDamage = _playerController._playerManager._curStat._damage;
                _bulletGO.Shoot();                
            }

            if (_playerController._playerManager.CurWeapon._shootEffect != null || _playerController._playerManager.CurWeapon._shootEffectAnim != null)
            {
                PhotonNetwork.Instantiate("Prefabs/Effects/ShootEffect", _bulletPos, Quaternion.identity).TryGetComponent(out ShootEffect shootEffect);
                shootEffect.photonView.RPC("SetEffect", RpcTarget.All, _playerController._playerManager.CurWeapon._name, _dir == Vector2.right ? false : true);
            }

            GameManager.Instance.StartCoroutine(GameManager.Instance.ShakeCMVCamera(1f, .1f));
        }
    }

PlayerAttack 상태에 진입하면 자동으로 실행되는 부분이다. _attackDuration을 통해 공격 속도를 계산하여 공격 딜레이를 설정했고 현재 플레이어의 방향에 따라 총알을 생성하고 발사하며 만약 슈팅 이펙트가 존재하면 이펙트 또한 발생하게 했다.

BulletGO에서는 무기 정보를 받아 sprite를 갱신하고 무기 방식(노말, 레이저)에 따라서 공격 방식 또한 달라지게 된다.
(레이저 방식인 레일 건은 다른 노말 무기처럼 총알이 발사되는 것이 아니라 지형을 관통하는 레이저를 일정 시간 생성한다)

무기 방식을 새로 추가하게 된다면 추가 기능을 구현해야겠지만 어려운 부분이라는 생각은 전혀 안 들고 그냥 다른 여러 무기를 추가하고자 한다면 sprite와 애니메이션, 능력치들만 설정해 주면 깔끔하게 추가가 된다.

  • 타이머

타이머에서 계속 버그가 발생하여 가장 고생을 많이 한 부분이다. 이 게임은 인게임에서 모든 과정이 시간제한에 의해 진행되기에

타이머 시작 -> 진행 -> 완료 혹은 타이머 종료로 인한 완료

3단계가 버그나 에러 없이 깔끔하게 진행돼야 한다. 특히나 실시간 대전 게임이기 때문에.

그래서 인게임 진행을 총괄하는 GameManager 클래스를 만들어 e_timeOver라는 UnityEvent를 선언해 타이머가 종료되면 해당 이벤트에 구독돼 있는 리스너들을 실행하는 방식으로 진행하고자 했다.

처음에는 MasterClient에서 타이머를 실행하는 메소드를 RPC로 호출해 다른 클라이언트도 타이머가 시작되게 했고
타이머 변수 값은 MasterClient에서만 변하게 하여 클라이언트 간 동기화를 일치시켰다.
이후 타이머가 종료되면 타임오버 이벤트가 실행되게 했다.

나름 MasterClient로 기준을 잡고 동기화가 되는 듯 보이지만 실제로는 기준도 없고 각자 클라이언트에서 따로 돌아가고
타이머가 종료되면 기존에 있던 리스너들은 삭제하고 새로 리스너를 달아서 시작해야 하는데 클라이언트 간 동기화가 제대로 되지 않으니 꼬여서 다음에 등록될 리스너를 먼저 등록해 버리고 타임 오버 이벤트가 발생해 완전 게임 방식이 이상해져 버리는 결과가 발생했다.

그래서 고민을 많이 해봤는데 생각해 보니 일반적인 온라인 게임에서는 서버가 따로 존재하는데 타이머 같이 게임에서 중요한 부분은 서버에서 관리하고 이것을 각 클라이언트에 뿌려주기만 하는 방식이겠다는 생각이 들었다.
서버 한 곳에서 동기화를 진행하기 때문에 꼬일 일도 없고 타임오버 이벤트 같은 트리거도 서버 한 곳에서 뿌려주기 때문에 게임이 뒤죽박죽 진행될 가능성도 엄청나게 떨어질 것이라고 생각했다.

근데 난 서버를 구현할 줄 모르기 때문에 포톤에 있는 기능을 적극 활용했다.
MasterClient를 메인 서버라고 생각하고 게임 진행과 관련된 모든 변수, 메소드들은 전부 MasterClient에서만 관리하게 했다.
다른 클라이언트가 받아야 할 변수나 실행해야 할 메소드가 있다면 RPC를 통해 MasterClient에서 다른 클라이언트에 뿌려주는 방식으로 동기화를 진행했다.

    [PunRPC]
    public void SetTimer(float time) => _timer = time;

    // 타이머 시작 메서드. MasterClient에서만 시작함
    public void StartTimer(float time)
    {
        if (_isTimerRunning || !PhotonNetwork.IsMasterClient)
            return;

        _timer = time;
        photonView.RPC(nameof(SetTimerActiveRPC), RpcTarget.All, true, _timer);
        photonView.RPC(nameof(SetTimer), RpcTarget.All, _timer);

        _isTimerRunning = true;
    }

    private void UpdateTimer()
    {
        if (!_isTimerRunning || !PhotonNetwork.IsMasterClient)
            return;

        _timer -= Time.deltaTime;
        photonView.RPC(nameof(SetTimer), RpcTarget.All, _timer);

        // 타이머 종료 시 이벤트 호출
        if (_timer <= 0f)
            StopTimer();
    }

    public void StopTimer()
    {
        if (!_isTimerRunning || !PhotonNetwork.IsMasterClient)
            return;

        _isTimerRunning = false;
        _timer = 0f;
        e_TimerOver.Invoke();
        photonView.RPC(nameof(SetTimerActiveRPC), RpcTarget.All, false, 0f);
        photonView.RPC(nameof(SetTimer), RpcTarget.All, _timer);
    }

    [PunRPC]
    public void SetTimerActiveRPC(bool active, float time) => GameUIController.Instance.SetTimerActive(active, time);

타이머가 진행되는 방식이다. MasterClient에서 StartTimer를 하면 타이머 이미지가 나타나면서 UpdateTimer를 통해 타이머가 줄어드는 것을 눈으로 확인할 수 있다.

    [PunRPC]
    public void RoundStart()
    {
        GameUIController.Instance._messageText.gameObject.SetActive(false);

        _cmvcam.m_Lens.OrthographicSize = _curMap._lensOrthoSize;

        foreach (var p in _players)
        {
            p.Init();
        }

        if (PhotonNetwork.IsMasterClient)
        {
            StartTimer(_roundTime);
            _upgradeCount = 0;
            e_TimerOver.AddListener(() => photonView.RPC(nameof(RoundFinishEffectCorRPC), RpcTarget.All));
        }            
    }

타이머 이벤트는 게임 시작, 라운드 시작, 라운드 종료, 무기 및 업그레이드 선택 등등 여러 시점에서 등록되고 해제되지만
대표적으로 RoundStart에서는 라운드가 시작되고 타이머가 종료됐을 경우 라운드가 종료돼야 하기 때문에 라운드가 종료되는 메소드를 리스너로 등록해 놓는다.
이렇게 하면 MasterClient에서만 StartTimer를 실행하기 때문에 타이머가 종료될 때도 MasterClient에서 이벤트 처리를 하게 돼서 동기화 문제없이 깔끔하게 진행이 됐다.

  • 게임 종료
    int _gameRound = 1;
    public int GameRound
    {
        get { return _gameRound; }
        set
        {
            _gameRound = value;

            _me.Available = false;
            _me.photonView.RPC(nameof(_me.SetPlayerColor), RpcTarget.All, 1f, 1f, 1f, 0f);

            _other.Available = false;
            _other.photonView.RPC(nameof(_other.SetPlayerColor), RpcTarget.All, 1f, 1f, 1f, 0f);

            switch (_curState)
            {
                case GameState.WIN:
                    if ((bool)_me._playerManager._player.CustomProperties["Player1"] && _playerPointDic[_me] < GameUIController.Instance._player1Point.Count)
                        GameUIController.Instance._player1Point[_playerPointDic[_me]].color = Color.red;
                    else if((bool)_me._playerManager._player.CustomProperties["Player2"] && _playerPointDic[_me] < GameUIController.Instance._player2Point.Count)
                        GameUIController.Instance._player2Point[_playerPointDic[_me]].color = Color.red;

                    if(_playerPointDic[_me] < _gamePoint)
                        _playerPointDic[_me]++;

                    if (_playerPointDic[_me] == _gamePoint)
                    {
                        if (PhotonNetwork.IsMasterClient)
                            photonView.RPC(nameof(GameFinish), RpcTarget.All);

                        break;
                    }

                    SelectUpgrade(_me._playerManager);
                    break;
                case GameState.LOSE:
                    if ((bool)_other._playerManager._player.CustomProperties["Player1"] && _playerPointDic[_other] < GameUIController.Instance._player1Point.Count)
                        GameUIController.Instance._player1Point[_playerPointDic[_other]].color = Color.red;
                    else if((bool)_other._playerManager._player.CustomProperties["Player2"] && _playerPointDic[_other] < GameUIController.Instance._player2Point.Count)
                        GameUIController.Instance._player2Point[_playerPointDic[_other]].color = Color.red;

                    if (_playerPointDic[_other] < _gamePoint)
                        _playerPointDic[_other]++;

                    if (_playerPointDic[_other] == _gamePoint)
                    {
                        if (PhotonNetwork.IsMasterClient)
                            photonView.RPC(nameof(GameFinish), RpcTarget.All);

                        break;
                    }

                    SelectUpgrade(_me._playerManager);
                    break;
                case GameState.DRAW:
                    if ((bool)_me._playerManager._player.CustomProperties["Player1"])
                    {
                        if(_playerPointDic[_me] < GameUIController.Instance._player1Point.Count)
                            GameUIController.Instance._player1Point[_playerPointDic[_me]].color = Color.red;

                        if(_playerPointDic[_other] < GameUIController.Instance._player2Point.Count)
                            GameUIController.Instance._player2Point[_playerPointDic[_other]].color = Color.red;
                    }
                    else
                    {
                        if(_playerPointDic[_other] < GameUIController.Instance._player1Point.Count)
                            GameUIController.Instance._player1Point[_playerPointDic[_other]].color = Color.red;

                        if(_playerPointDic[_me] < GameUIController.Instance._player2Point.Count)
                            GameUIController.Instance._player2Point[_playerPointDic[_me]].color = Color.red;
                    }

                    if (_playerPointDic[_me] < _gamePoint)
                        _playerPointDic[_me]++;

                    if (_playerPointDic[_other] < _gamePoint)
                        _playerPointDic[_other]++;

                    if (_playerPointDic[_me] == _gamePoint || _playerPointDic[_other] == _gamePoint)
                    {
                        if (PhotonNetwork.IsMasterClient)
                            photonView.RPC(nameof(GameFinish), RpcTarget.All);

                        break;
                    }

                    SelectUpgrade(_me._playerManager);
                    break;
            }
        }
    }

라운드가 넘어가면 자동으로 실행되게 하기 위해 프로퍼티로 구현했다. _playerPointDic이라는 딕셔너리를 통해 해당 플레이어가 몇 점인지 알아내서 5점일 경우 게임이 끝나도록 했다.

다른 게임들과 마찬가지로 게임이 끝나면 결과창이 보이게 된다.

    public async void EnableGameFinishUI(string user1, string user2, bool isDraw)
    {
        UserInfo userInfo1, userInfo2;

        GameUIController.Instance._playerUI.gameObject.SetActive(false);
        AbilitySelectManager.Instance.gameObject.SetActive(false);
        GameUIController.Instance._timerImage.gameObject.SetActive(false);

        userInfo1 = await FirebaseFirestoreManager.Instance.LoadUserInfoByNickname(user1);
        userInfo2 = await FirebaseFirestoreManager.Instance.LoadUserInfoByNickname(user2);

        if (!isDraw)
        {
            userInfo1.Win += 1;
            userInfo2.Lose += 1;
        }

        StartCoroutine(FadeInGameFinishUICor());

        if (userInfo1 != null)
        {
            _user1Image.sprite = ProfileSpriteManager.Instance.GetSprite(userInfo1.Profile);
            _user1Name.text = userInfo1.Name;
            _user1Score.text = $"W : {userInfo1.Win} / L : {userInfo1.Lose}";
        }

        if (userInfo2 != null)
        {
            _user2Image.sprite = ProfileSpriteManager.Instance.GetSprite(userInfo2.Profile);
            _user2Name.text = userInfo2.Name;
            _user2Score.text = $"W : {userInfo2.Win} / L : {userInfo2.Lose}";
        }

        if(userInfo1.Name == PhotonNetwork.LocalPlayer.NickName)
            FirebaseFirestoreManager.Instance.UpdateUserInfo(FirebaseAuthManager.Instance._user, userInfo1);
        else
            FirebaseFirestoreManager.Instance.UpdateUserInfo(FirebaseAuthManager.Instance._user, userInfo2);

        Invoke(nameof(OutRoom), 5f);
    }

해당 함수는 GameFinishUIController에 있는 함수이고 파라미터로 유저들의 이름과 게임이 5:5로 비겼는지(이럴 경우는 거의 0%긴 하지만)를 받는다.

유저 정보를 FirebaseFirestore에서 로드하고 로드에 성공했을 경우 승패 정보와 간단한 프로필을 띄워준다.
이후 자기 자신의 정보를 FirebaseFirestore에 저장한다.

FirebaseFirestore에서 Load 하고 Update 하는 데에 약간의 시간이 걸리기 때문에 해당 동작을 하는 모든 함수는 비동기로 실행되도록 했다.

개발 후기

싱글 플레이를 개발할 때와는 차원이 다를 정도로 신경 써야 할 부분이 많았다.

이걸 한 번 추가해 볼까? 해서 추가하면 저기서 버그가 발생하고 어떻게 디버깅을 하면 다른 곳에서 에러가 발생하고 그렇게 에러까지 잡았다 싶으면 여기서 제대로 동작이 안되고 미쳐버릴 노릇이었다

그래도 간단하게나마 유저 정보를 DB로 관리하고 포톤을 통해 네트워크 게임을 개발하면서 배운 점도 엄청 많았다.

그리고 싱글 게임을 개발할 때와는 새로운 재미가 있었다. 싱글 플레이 게임은 내가 구현한 것이 제대로 동작하는지를 확인하는 재미가 있었는데 멀티 플레이 게임은 내가 구현하고자 한 것이 나뿐만 아니라 다른 사람도 제대로 동작할 때 쾌감이 있었고 신기하기도 했다.

'Unity > Devlog' 카테고리의 다른 글

[Unity2D] 인벤토리 시스템  (0) 2023.03.24
[Unity2D] 포톤 서버로 멀티 구현  (0) 2023.02.20
[Unity 2D] 유닛 확장  (0) 2023.01.23
[Unity 2D] 캐릭터 움직임  (0) 2023.01.23
[Unity 3D] NPC 퀘스트 팝업  (0) 2023.01.16
Comments