4주차에는 내가 게임 룰 파트를 맡았다. 다만 이 시기에 여행 일정이 겹쳐서, PM님께서 타이머 동기화 로직과 게임의 전체적인 분기를 미리 잡아주셨다. 나는 단계별로 나뉘어진 게임 흐름을 더 구체적으로 관리하고 직업 배정 시스템을 구현하였다.
구현을 시작하기 전, 유니티에서 많이 사용하는 FSM(유한 상태 머신) 패턴을 적용해 보려 했다. 관련 자료를 찾아보며 공부하고 프로젝트에 적용하려 했으나, 게임 규칙과 플레이어의 상태를 하나의 FSM으로 섞으려다 보니 상태 전이 조건이 복잡해지고 관리해야 할 상태가 너무 많아졌다.
참고했던 https://dodobug.tistory.com/16 해당 블로그의 방식은 혼자 작업하거나 프로젝트 완료 후 리팩토링할 때, 혹은 초기 기획 단계부터 팀원들과 협의하여 진행할 때 적합해 보였다.
하지만 현재는 게임의 전체적인 루프 제어는 내가 맡고, 상태별 세부 로직은 다른 팀원들에게 분배된 상황이었다. 내가 상태 패턴의 틀을 만들어 버리면 다른 팀원들이 그 구조에 맞춰 로직을 다시 작성해야 하므로, 배보다 배꼽이 더 큰 상황이 될 것 같아 FSM 패턴 적용은 보류하였다ㅠㅠ
하지만 FSM의 장점을 포기할 수 없었던 나는 함수 분리형 FSM 구조를 대신 적용해 보기로 하였다^^
GameState 그래프

Update() 함수 안에는 상태에 따라 필요한 함수들만 호출하도록 하여, 메인 로직이 복잡해지는 것을 방지했다. 다만 이 방식으로 하면 방장과 일반 플레이어들이 따로 실행해야할 로직을 구분할 수 없게 된다. 이는 호출된 각 함수 내부에서 if (IsMasterClient) 등을 통해 방장/일반 플레이어 로직을 분리하였다.
void Update()
{
switch (currentState)
{
case GameState.Playing_OnLight:
case GameState.Playing_OffLight:
UpdatePlayLogic();
break;
case GameState.Voting:
UpdateVoteLogic();
break;
case GameState.Result:
break;
}
UpdateGlobalTimer();
}
PM께서 작성해주신 코드 구조를 리팩토링해보니, 역시 다른 사람의 코드를 읽고 이해하는 것은 정말 쉬운 일이 아닌 것 같다. 코드의 작동 방식은 유지하되 위치와 함수 구조만 수정하는 데에도 꼬박 하루가 걸렸다.
그래도 수정 후에 코드의 흐름이 한 눈에 보이니 마음이 너무 편안해졌다. 코드를 작성할 때부터 가독성을 고려하고 구조를 깔끔하게 짜는 것이 얼마나 중요한지 다시 한번 느꼈다.
public void AssignJobs()
{
if (!PhotonNetwork.IsMasterClient) return;
Player[] allPlayers = PhotonNetwork.PlayerList;
int killerIndex = Random.Range(0, allPlayers.Length);
for (int i = 0; i < allPlayers.Length; i++)
{
HashTable props = new HashTable();
if (i == killerIndex)
{
props.Add("Job", "Killer");
Debug.Log($"[직업배정] 킬러: {allPlayers[i].NickName}");
}
else
{
props.Add("Job", "Survivor");
}
allPlayers[i].SetCustomProperties(props);
}
}
처음에는 해당 코드를 Start()에서 방장만 실행하도록 작성했다. 하지만 플레이어들이 모두 접속하지 않은 상태에서 AssignJobs()가 실행되면, 늦게 들어온 사람은 킬러가 될 기회조차 없겠다는 문제가 있었다.
따라서 모든 플레이어가 로딩을 마쳤는지 확인하는 출석 체크 시스템을 도입하기로 했다.
private int loadedPlayerCnt = 0;
void Start()
{
// ...
photonView.RPC("RPC_ReportLoadingComplete", RpcTarget.MasterClient);
}
[PunRPC]
public void RPC_ReportLoadingComplete()
{
// 방장만 인원 체크
if (!PhotonNetwork.IsMasterClient) return;
loadedPlayerCnt++;
Debug.Log($"[로딩체크] {loadedPlayerCnt} / {PhotonNetwork.PlayerList.Length} 명 로딩 완료");
if (loadedPlayerCnt == PhotonNetwork.PlayerList.Length)
{
Debug.Log("[로딩체크] 모든 플레이어 로딩 완료");
AssignJobs();
}
}
추가로 모든 플레이어의 로딩이 완료된 후 게임 로직을 실행하기 위해 isGameStart 변수를 추가했다. 이 변수가 true가 되면 Update() 로직이 실행되도록 변경하였다.
public bool isGameStart = false;
void Start()
{
// ...
if (PhotonNetwork.IsConnectedAndReady)
{
photonView.RPC("RPC_ReportLoadingComplete", RpcTarget.MasterClient);
}
}
void Update()
{
if (isGameStart == false) return;
// ...
}
[PunRPC]
public void RPC_ReportLoadingComplete()
{
// 방장만 인원 체크
if (!PhotonNetwork.IsMasterClient) return;
loadedPlayerCnt++;
Debug.Log($"[로딩체크] {loadedPlayerCnt} / {PhotonNetwork.PlayerList.Length} 명 로딩 완료");
if (loadedPlayerCnt == PhotonNetwork.PlayerList.Length)
{
Debug.Log("[로딩체크] 모든 플레이어 로딩 완료");
AssignJobs();
photonView.RPC("RPC_StartGame", RpcTarget.All); // 게임 시작 신호 방송
}
}
[PunRPC]
void RPC_StartGame()
{
isGameStart = true; // 각 플레이어들의 Update문 실행 시작
Debug.Log("게임 로직 가동 시작");
}
사실 출석체크 시스템은 게임의 상태만 제어하고 있기 때문에 플레이어들이 움직이지 못하게 막지 않으면 아무 의미가 없다. 그래서 PlayerController.cs의 Update()문에서 GameStateManager.instance.isGameStart가 false이면 실행되지 않도록 추가로 조치해주었다.
void Update()
{
// 1.내 캐릭터 아니면 조종X
if (!photonView.IsMine) return;
// 2.게임 상태 체크 + 게임 시작 하였는지 체크
if (GameStateManager.instance.isGameStart == false || GameStateManager.instance.currentState == GameState.Voting)
{
moveInput = Vector2.zero;
UpdateAnimation(Vector3.zero);
return;
}
float x = Input.GetAxisRaw("Horizontal");
float y = Input.GetAxisRaw("Vertical");
moveInput = new Vector2(x,y).normalized;
UpdateAnimation(moveInput);
}
//물리적인 이동 처리 (벽에 부딪혔을 때 떨리는 현상 방지)
void FixedUpdate()
{
if (!photonView.IsMine) return;
// 게임 시작 전 or 투표 상태이면 물리 이동 정지
if (GameStateManager.instance.isGameStart == false || GameStateManager.instance.currentState == GameState.Voting)
{
rb.linearVelocity = Vector2.zero;
return;
}
Vector2 nextPos = rb.position + (moveInput * moveSpeed * Time.fixedDeltaTime);
rb.MovePosition(nextPos);
}
ReadyManager.cs : 일반 플레이어에게 Start Button이 숨김 처리가 안 되는 현상Start(), OnJoinedRoom() 둘 중 어떤게 먼저 실행될지 모르기 때문에 두 군데 모두 방장임을 확인하여 StartButton을 보여줄지 말지 결정하는 코드를 중복하여 넣었다. 그리고 방장이 바뀌었을 때도 실행해줘야 한다.
중복되므로 해당 코드를 함수화하였다.
void UpdateStartButtonState()
{
if (PhotonNetwork.IsMasterClient)
{
// 방장이면 시작 버튼은 보이지만 비활성화 상태
startButton.gameObject.SetActive(true);
startButton.interactable = false;
}
else startButton.gameObject.SetActive(false); // 일반 플레이어는 숨김처리
}