왜 퐁이었나
그때까지 만든 것은 전부 오프라인 싱글플레이였다. 화면 안에서 일어나는 일은 전부 내 컴퓨터 한 대가 계산했고, 그 결과가 곧 정답이었다. 유저 두 명의 화면이 서로 같은 상태를 보고 있어야 하는 구조는 한 번도 다뤄 본 적이 없었다.
그 기초를 잡으려고 고른 소재가 퐁이다. 패들 두 개와 공 하나. 게임 로직이 이보다 단순할 수 없으니, 문제가 생기면 그건 거의 확실히 네트워크 쪽 문제다. 게임이 복잡했다면 "이게 게임 버그인가 동기화 버그인가"를 가리는 데만 시간을 다 썼을 것이다.
| 항목 | 내용 |
|---|---|
| 엔진 | Unity 2021.3.45f2 |
| 역할 | 1인 개발 (100%) |
| 네트워크 | Photon PUN2 |
| 인증 | Firebase Authentication |
| 결과 | 완성 |

리소스는 일부러 만들지 않았다. 유니티 기본 스프라이트와 사각형·원 오브젝트로 끝냈다. 이 프로젝트에서 배우려던 것은 화면이 아니라 연결이었다.
1. 게임에 들어가기 전 — Firebase 인증
먼저 붙인 것은 로그인이다. 이메일과 비밀번호로 계정을 만들고 로그인하면, 그 유저 정보를 로비 UI(PlayerNameText)에 표시한다.
Firebase는 앱 종속성을 먼저 확인하고 초기화해야 한다. 초기화가 끝나기 전에 로그인 버튼을 누를 수 있으면 안 되므로, 준비 상태와 진행 중 상태를 각각 플래그로 들고 있다가 버튼 자체를 잠근다.
public void SignIn()
{
if(!IsFirebaseReady || IsSignInOnProgress || User != null) return;
IsSignInOnProgress = true;
signInButton.interactable = false;
firebaseAuth.SignInWithEmailAndPasswordAsync(emailField.text, passwordField.text).ContinueWithOnMainThread(task => {
IsSignInOnProgress = false;
signInButton.interactable = true;
if (task.IsFaulted) {
Debug.LogError(task.Exception);
} else if (!task.IsCanceled) {
User = task.Result;
SceneManager.LoadScene("Lobby");
}
});
}
SceneManager.LoadScene이나 UI 갱신을 하려면 메인 스레드로 되돌려 줘야 한다. ContinueWith 대신 ContinueWithOnMainThread를 쓰는 이유가 그것이다.2. 로비 — 방이 있으면 들어가고, 없으면 만든다
매칭 로직은 단순하게 잡았다. 접속 상태면 랜덤 룸에 조인하고, 아직 마스터 서버에 붙지 않았으면 먼저 연결한다. 그리고 들어갈 방이 없어서 실패한 경우를 방 생성 신호로 쓴다.
public void Connect()
{
if (PhotonNetwork.IsConnected) {
PhotonNetwork.JoinRandomRoom();
} else {
PhotonNetwork.ConnectUsingSettings();
}
}
public override void OnJoinRandomFailed(short returnCode, string message)
{
PhotonNetwork.CreateRoom(null, new RoomOptions { MaxPlayers = 2 });
}
public override void OnJoinedRoom()
{
PhotonNetwork.LoadLevel("Main");
}
"방 목록을 받아서 → 빈 방을 고르고 → 없으면 만든다"가 아니라 실패 콜백을 분기점으로 쓰는 구조다. 방 목록을 클라이언트가 들고 판단하면 그 사이에 다른 유저가 그 방을 채울 수 있다. 조인 시도를 서버에 맡기고 실패했을 때만 만드는 쪽이 경쟁 상태에 덜 취약하다.
씬 이동에 SceneManager.LoadScene이 아니라 PhotonNetwork.LoadLevel을 쓴 것도 이유가 있다. 같은 방에 있는 클라이언트들이 서로 다른 씬에 서 있는 구간을 없애기 위해서다.
3. 소유권을 나누는 지점
여기가 이 프로젝트의 핵심이다. 방에 들어오면 각 클라이언트는 자기 플레이어 프리팹만 인스턴스화한다. 그런데 공은 다르다. 공은 어느 한쪽의 것이 아니다.
그래서 공은 마스터 클라이언트만 스폰하도록 했다.
private void Start()
{
playerScores = new[] { 0, 0 };
SpawnPlayer();
if (PhotonNetwork.IsMasterClient)
{
SpawnBall();
}
}
PhotonView로 상대에게 알린다. 하지만 공은 아무도 조작하지 않는다. 두 클라이언트가 각자 공을 스폰하면 공이 두 개가 되고, 각자 물리를 돌리면 소수점 오차와 프레임 타이밍 차이 때문에 시간이 갈수록 두 화면의 공 위치가 벌어진다. 주인 없는 오브젝트는 주인을 정해 줘야 한다.물리도 마스터가 단독으로 돌린다
스폰만 마스터가 하는 게 아니라 충돌 검사와 반사 연산 자체를 마스터에서만 수행하게 했다. 마스터가 아닌 클라이언트는 FixedUpdate 첫 줄에서 그냥 돌아간다.
private void FixedUpdate()
{
// 마스터 클라이언트가 아니면 물리 연산 무시
if(!IsMasterClientLocal || PhotonNetwork.PlayerList.Length < 2) return;
var distance = speed * Time.deltaTime;
var hit = Physics2D.Raycast(transform.position, direction, distance);
if(hit.collider != null)
{
var goalPost = hit.collider.GetComponent<Goalpost>();
if(goalPost != null)
{
// 점수 판정 처리
}
// 반사각 계산 및 랜덤 튕김 요소 부여
direction = Vector2.Reflect(direction, hit.normal);
direction += Random.insideUnitCircle * randomRefectionIntensity;
}
transform.position = (Vector2)transform.position + direction * distance;
}
이 코드에서 눈여겨볼 지점이 몇 개 있다.
- 콜라이더 충돌이 아니라 Raycast로 미리 본다. 이번 프레임에 이동할 거리(
distance)만큼 진행 방향으로 광선을 쏘고, 거기에 뭔가 걸리면 그때 반사한다. 공이 빠르면 한 프레임에 벽을 뚫고 지나가는 터널링이 생기는데, 이동 거리만큼 미리 검사하면 그 문제를 피할 수 있다. Vector2.Reflect(direction, hit.normal)한 줄이 입사각·반사각 계산 전부다. 맞은 면의 법선 벡터만 있으면 튕겨 나가는 방향이 나온다.Random.insideUnitCircle로 흔들어 준다. 완전한 물리 반사만 쓰면 공이 같은 궤도를 무한히 왕복해서 랠리가 끝나지 않는다. 반사 방향에 작은 무작위 성분을 더해 궤도가 조금씩 달라지게 했다.PlayerList.Length < 2체크. 상대가 아직 안 들어왔는데 공이 굴러가면 혼자 실점한다. 두 명이 찰 때까지 공을 멈춰 둔다.
4. 점수는 RPC로 뿌린다
득점 판정은 이미 마스터가 물리를 단독으로 돌리고 있으니 자연히 마스터의 몫이 된다. 문제는 그 결과를 양쪽 화면에 똑같이 띄우는 것이다.
점수판은 [PunRPC]로 모든 클라이언트에게 브로드캐스트한다.
public void AddScore(int playerNumber, int score)
{
photonView.RPC("RPCUpdateScoreText", RpcTarget.All, playerScores[0].ToString(), playerScores[1].ToString());
}
[PunRPC]
private void RPCUpdateScoreText(string player1ScoreText, string player2ScoreText)
{
scoreText.text = $"{player1ScoreText} : {player2ScoreText}";
}
RpcTarget.All이므로 호출한 본인도 포함해서 실행된다. 즉 마스터도 자기 점수를 직접 올려서 표시하는 게 아니라, 자기가 보낸 RPC를 받아서 표시한다. 마스터와 다른 클라이언트가 화면을 갱신하는 경로가 하나로 통일된다.
여기서 얻은 것
정리하면 이 프로젝트에서 실제로 손에 익은 것은 세 가지다.
| 배운 것 | 구체적으로 |
|---|---|
| 로비 시스템 | 마스터 서버 접속 → 랜덤 조인 → 실패 시 방 생성 → 동기 씬 로드 |
| 인스턴스 소유권 분리 | 플레이어는 각자, 공은 마스터만. 물리 연산 주체를 하나로 고정 |
| RPC 통신 | RpcTarget.All 브로드캐스트로 모든 클라이언트의 표시 경로 통일 |
그리고 Firebase 인증을 붙여 본 경험이 예상 밖으로 남았다. 로그인한 유저가 누구인지 아는 순간부터 "이 유저의 데이터를 어디에 어떻게 둘 것인가" 라는 질문이 생긴다. 이번에는 로비에 이름을 띄우는 데서 끝났지만, 멀티플레이어를 다루려면 결국 마주쳐야 하는 구조다.
네트워크를 처음 다룬다면 소재를 최대한 단순하게 잡는 것을 권한다. 퐁 정도의 게임에서도 "이건 누가 계산하지"라는 질문은 계속 나온다. 그 질문에 답하는 연습이 이 프로젝트의 전부였다.