[Unity] Photon Fusion (7) - Fusion Starter (Shared) 뜯어보기

2024. 8. 29. 16:22Application & Note/Unity Engine

Photon Fusion의 기술 문서에 들어가보면, Photon사에서 정성들여 만들어 놓은 기술 Demo들이 존재한다. 오늘은 그 중 하나인 Fusion Starter(Shared Mode Version)의 소스코드를 씹고 뜯고 맛보고자 한다.

 

일단 아래와 같이 MainMenu Scene의 UIMainMenu Script를 뜯어보자.

using UnityEngine;
using UnityEngine.SceneManagement;

namespace Starter.MainMenu
{
	public class UIMainMenu : MonoBehaviour
	{
		public void LoadScene(int index)
		{
			SceneManager.LoadScene(index);
		}

		public void QuitGame()
		{
			Application.Quit();

			#if UNITY_EDITOR
				UnityEditor.EditorApplication.ExitPlaymode();
			#endif
		}

		private void OnEnable()
		{
			// Ensure the cursor is visible when coming back from the game
			Cursor.lockState = CursorLockMode.None;
			Cursor.visible = true;
		}
	}
}

LoadScene 함수에 index를 넘겨서 SceneManager를 통해 Scene을 불러오는 방식을 관찰할 수 있다. (혹은 PlayerPrefs를 이용해서 다음 신의 이름을 저장한 뒤에 Parameter로 넘겨도 된다.)

 

이 Fusion Starter(Shared Mode) 에는 3개의 미니 게임이 존재하는데, 그중 SHOOTER Scene을 뜯어보고자 한다. 유니티 에디터를 적절하게 조작하여 SHOOTER Scene으로 넘어가 보자.

 

Scene에 나타난 화면이다.

 

먼저 UI Script 부터 뜯어 봐 주자.

 

using System.Collections.Generic;
using System.Threading.Tasks;
using Fusion;
using TMPro;
using UnityEngine;
using UnityEngine.SceneManagement;

namespace Starter
{
	/// <summary>
	/// Shows in-game menu, handles player connecting/disconnecting to the network game and cursor locking.
	/// </summary>
	public class UIGameMenu : MonoBehaviour
	{
		[Header("Start Game Setup")]
		[Tooltip("Specifies which game mode player should join - e.g. Platformer, ThirdPersonCharacter")]
		public string GameModeIdentifier;
		public NetworkRunner RunnerPrefab;
		public int MaxPlayerCount = 8;

		[Header("Debug")]
		[Tooltip("For debug purposes it is possible to force single-player game (starts faster)")]
		public bool ForceSinglePlayer;

		[Header("UI Setup")]
		public CanvasGroup PanelGroup;
		public TMP_InputField RoomText;
		public TMP_InputField NicknameText;
		public TextMeshProUGUI StatusText;
		public GameObject StartGroup;
		public GameObject DisconnectGroup;

		private NetworkRunner _runnerInstance;
		private static string _shutdownStatus;

		public async void StartGame() //주목해야 하는 부분은 여기다.
		{
			await Disconnect();

			PlayerPrefs.SetString("PlayerName", NicknameText.text);

			_runnerInstance = Instantiate(RunnerPrefab);

			// Add listener for shutdowns so we can handle unexpected shutdowns
			var events = _runnerInstance.GetComponent<NetworkEvents>();
			events.OnShutdown.AddListener(OnShutdown);

			var sceneInfo = new NetworkSceneInfo();
			sceneInfo.AddSceneRef(SceneRef.FromIndex(SceneManager.GetActiveScene().buildIndex));

			var startArguments = new StartGameArgs()
			{
				GameMode = Application.isEditor && ForceSinglePlayer ? GameMode.Single : GameMode.Shared,
				SessionName = RoomText.text,
				PlayerCount = MaxPlayerCount,
				// We need to specify a session property for matchmaking to decide where the player wants to join.
				// Otherwise players from Platformer scene could connect to ThirdPersonCharacter game etc.
				SessionProperties = new Dictionary<string, SessionProperty> {["GameMode"] = GameModeIdentifier},
				Scene = sceneInfo,
			};

			StatusText.text = startArguments.GameMode == GameMode.Single ? "Starting single-player..." : "Connecting...";

			var startTask = _runnerInstance.StartGame(startArguments);
			await startTask;

			if (startTask.Result.Ok)
			{
				StatusText.text = "";
				PanelGroup.gameObject.SetActive(false);
			}
			else
			{
				StatusText.text = $"Connection Failed: {startTask.Result.ShutdownReason}";
			}
		}

		public async void DisconnectClicked()
		{
			await Disconnect();
		}

		public async void BackToMenu()
		{
			await Disconnect();

			SceneManager.LoadScene(0);
		}

		public void TogglePanelVisibility()
		{
			if (PanelGroup.gameObject.activeSelf && _runnerInstance == null)
				return; // Panel cannot be hidden if the game is not running

			PanelGroup.gameObject.SetActive(!PanelGroup.gameObject.activeSelf);
		}

		private void OnEnable()
		{
			var nickname = PlayerPrefs.GetString("PlayerName");
			if (string.IsNullOrEmpty(nickname))
			{
				nickname = "Player" + Random.Range(10000, 100000);
			}

			NicknameText.text = nickname;

			// Try to load previous shutdown status
			StatusText.text = _shutdownStatus != null ? _shutdownStatus : string.Empty;
			_shutdownStatus = null;
		}

		private void Update()
		{
			// Enter/Esc key is used for locking/unlocking cursor in game view.
			if (Input.GetKeyDown(KeyCode.Return) || Input.GetKeyDown(KeyCode.Escape))
			{
				TogglePanelVisibility();
			}

			if (PanelGroup.gameObject.activeSelf)
			{
				StartGroup.SetActive(_runnerInstance == null);
				DisconnectGroup.SetActive(_runnerInstance != null);
				RoomText.interactable = _runnerInstance == null;
				NicknameText.interactable = _runnerInstance == null;

				Cursor.lockState = CursorLockMode.None;
				Cursor.visible = true;
			}
			else
			{
				Cursor.lockState = CursorLockMode.Locked;
				Cursor.visible = false;
			}
		}

		public async Task Disconnect()
		{
			if (_runnerInstance == null)
				return;

			StatusText.text = "Disconnecting...";
			PanelGroup.interactable = false;

			// Remove shutdown listener since we are disconnecting deliberately
			var events = _runnerInstance.GetComponent<NetworkEvents>();
			events.OnShutdown.RemoveListener(OnShutdown);

			await _runnerInstance.Shutdown();
			_runnerInstance = null;

			// Reset of scene network objects is needed, reload the whole scene
			SceneManager.LoadScene(SceneManager.GetActiveScene().buildIndex);
		}

		private void OnShutdown(NetworkRunner runner, ShutdownReason reason)
		{
			// Unexpected shutdown happened (e.g. Host disconnected)

			// Save status into static variable, it will be used in OnEnable after scene load
			_shutdownStatus = $"Shutdown: {reason}";
			Debug.LogWarning(_shutdownStatus);

			// Reset of scene network objects is needed, reload the whole scene
			SceneManager.LoadScene(SceneManager.GetActiveScene().buildIndex);
		}
	}
}

 

다 필요 없고 StartGame(), Disconnect() 부분만 먼저 봐주도록 하자.

 

Disconnect()

- 140번 줄에서는 PhotonFusion에서 핵심적인 Network Runner가 달린 Object가 없으면 Early Termination(이미 Disconnected 된 상황)을 다루고 있는 모습을 볼 수 있다.

- 147번 줄에서는 Network Events Component에서 OnShutDown이라는 Listenr를 치우고 있는데, 자세히는 안 들어 가도 될 것 같아 보인다.

- 150번 줄에서는 _runnerInstace를 비동기 처리로 종료하고 있는 모습을 보인다.

- 마지막으로 154번 줄에서는 메인 화면으로 돌아가는 Scene을 로딩하고 있는 모습을 보인다.

- 결론적으로, Network Runner와 관련된 동작을 수행하고 있는 모습이다. 이를 통해 Disconnect를 구현하고 있다.

 

 

StartGame()

- 38번 줄에서 비동기 작업으로 Disconnect()를 호출해 미연의 사태(중복 연결)을 방지하는 모습이다.

- 40번 줄에서 PlayerPrefs를 이용해 플레이어 닉네임을 저장하고 있다.

- 42번 줄에서 미리 만들어 놓은 Runner Prefab을 생성해 강령술을 진행하고 있다.

- 45번 ~ 46번 줄에서 Listner를 추가하고 있다. 해당 부분은 기술 문서를 참고해서 추후 추가하겠습니다.

- 51~59번 줄에서 startArgument를 설정하고 64번 줄에서 StartGame() Fucntion에 Parameter로 넘겨서 Task를 받아오고 있다. C#의 Task에 대해 궁금하다면 ChatGPT에게 물어 보도록 하자.

- 67~75번은 startTask의 결과에 따라서 UI를 치우거나 에러를 띄우거나를 하고 있는 모습을 볼 수 있다.

 

 

게임을 실행 했을때는 위와 같은 모습이 나타난다. 대충 총으로 치킨을 도축하는 내용이다. 이제 게임 내부의 스크립트들을 까보자.

 

Chicken.cs

- _startPosition, _speed, _maxTravelDistance와 같이 Network를 통해 조작되는 Var에 대해서는 Networked Property를 붙여 놓은 모습을 볼 수 있다.

 

- NetworkTranform.Teleport()가 생소 할 수 있는데, 진짜 말그대로 저 Tranfrom대로 설정하는 Event를 Network에 뿌리는 것이라고 생각하면 편하다.

 

- Health Component에서 isAlive가 False이거나 너무 멀리 나갔을 경우 자폭하는 모습을 관람할 수 있다.

- transform.Translate()에 대해서는 유니티 공식 문서를 참고하도록 하자.

 

- 또한 닭과 부딪히면 자폭하는 기능 또한 관람할 수 있다.

 

Health.cs

using System;
using Fusion;
using UnityEngine;

namespace Starter.Shooter
{
	/// <summary>
	/// A common component that represents entity health.
	/// It is used for both players and chickens.
	/// </summary>
	public class Health : NetworkBehaviour
	{
		[Header("Setup")]
		public int InitialHealth = 3;
		public float DeathTime;

		[Header("References")]
		public Transform ScalingRoot;
		public GameObject VisualRoot;
		public GameObject DeathRoot;

		public Action<Health> Killed;

		public bool IsAlive => CurrentHealth > 0;
		public bool IsFinished => _networkHealth <= 0 && _deathCooldown.Expired(Runner);
		public int CurrentHealth => HasStateAuthority ? _networkHealth : _localHealth;

		[Networked]
		private int _networkHealth { get; set; }
		[Networked]
		private TickTimer _deathCooldown { get; set; }

		private int _lastVisibleHealth;
		private int _localHealth;
		private int _localDataExpirationTick;

		public void TakeHit(int damage, bool reportKill = false)
		{
			if (IsAlive == false)
				return;

			RPC_TakeHit(damage, reportKill);

			if (HasStateAuthority == false)
			{
				// To have responsive hit reactions on all clients we trust
				// local health value for some time after the health change
				_localHealth =  Mathf.Max(0, _localHealth - damage);
				_localDataExpirationTick = GetLocalDataExpirationTick();
			}
		}

		public void Revive()
		{
			_networkHealth = InitialHealth;
			_deathCooldown = default;
		}

		public override void Spawned()
		{
			if (HasStateAuthority)
			{
				// Set initial health
				Revive();
			}

			_localHealth = _networkHealth;
			_lastVisibleHealth = _networkHealth;
		}

		public override void Despawned(NetworkRunner runner, bool hasState)
		{
			Killed = null;
		}

		public override void Render()
		{
			if (Object.LastReceiveTick >= _localDataExpirationTick)
			{
				// Local health data expired, just use network health from now on
				_localHealth = _networkHealth;
			}

			VisualRoot.SetActive(IsAlive && IsAliveInterpolated());
			DeathRoot.SetActive(IsAlive == false);

			// Check if hit should be shown
			if (_lastVisibleHealth > CurrentHealth)
			{
				// Show hit reaction by simple scale (but not for local player).
				// Scaling root scale is lerped back to one in the Player script.
				if (HasStateAuthority == false && ScalingRoot != null)
				{
					ScalingRoot.localScale = new Vector3(0.85f, 1.15f, 0.85f);
				}
			}

			_lastVisibleHealth = CurrentHealth;
		}

		[Rpc(RpcSources.All, RpcTargets.StateAuthority)]
		private void RPC_TakeHit(int damage, bool reportKill = false, RpcInfo info = default)
		{
			if (IsAlive == false)
				return;

			_networkHealth -= damage;

			if (IsAlive == false)
			{
				// Entity died, let's start death cooldown
				_networkHealth = 0;
				_deathCooldown = TickTimer.CreateFromSeconds(Runner,  DeathTime);

				if (reportKill)
				{
					// We are using targeted RPC to send kill confirmation
					// only to the killer client
					RPC_KilledBy(info.Source);
				}
			}
		}

		[Rpc(RpcSources.StateAuthority, RpcTargets.All)]
		private void RPC_KilledBy([RpcTarget] PlayerRef playerRef)
		{
			Killed?.Invoke(this);
		}

		private int GetLocalDataExpirationTick()
		{
			// How much time it takes to receive response from the server
			float expirationTime = (float)Runner.GetPlayerRtt(Runner.LocalPlayer);

			// Additional safety 200 ms
			expirationTime += 0.2f;

			int expirationTicks = Mathf.CeilToInt(expirationTime * Runner.TickRate);
			//Debug.Log($"Expiration time {expirationTime}, ticks {expirationTicks}");

			return Runner.Tick + expirationTicks;
		}

		private bool IsAliveInterpolated()
		{
			// We use interpolated value when checking if object should be made visible in Render.
			// This helps with showing player visual at the correct position right away after respawn
			// (= player won't be visible before KCC teleport that is interpolated as well).
			var interpolator = new NetworkBehaviourBufferInterpolator(this);
			return interpolator.Int(nameof(_networkHealth)) > 0;
		}
	}
}

좀 길다. 그러므로 다 필요 없고 RPC를 날리는 부분만 관찰해보자.

 

- 104번에서는 Case Handling을 하는 모습을 볼 수 있다.

- 107번 줄에서 데미지 만큼 Health를 날리는 모습을 볼 수 있다.

- 115번 줄에서 reportKill이 True이면 RPC_killedBy를 날리는 모습을 볼 수 있는데, Parameter로 RpcInfo가 들어가는 모습을 볼 수 있다. RPC info는 말 그대로 RPC에 관한 정보를 포함 하는데, 날린 사람이나 받는 사람의 정보도 포함이다.

 

Gamemanager.cs

using UnityEngine;
using Fusion;

namespace Starter.Shooter
{
	/// <summary>
	/// Handles player connections (spawning of Player instances).
	/// </summary>
	public sealed class GameManager : NetworkBehaviour
	{
		public Player PlayerPrefab;

		[Networked]
		public PlayerRef BestHunter { get; set; }
		public Player LocalPlayer { get; private set; }

		private SpawnPoint[] _spawnPoints;

		public Vector3 GetSpawnPosition()
		{
			var spawnPoint = _spawnPoints[Random.Range(0, _spawnPoints.Length)];
			var randomPositionOffset = Random.insideUnitCircle * spawnPoint.Radius;
			return spawnPoint.transform.position + new Vector3(randomPositionOffset.x, 0f, randomPositionOffset.y);
		}

		public override void Spawned()
		{
			_spawnPoints = FindObjectsOfType<SpawnPoint>();

			LocalPlayer = Runner.Spawn(PlayerPrefab, GetSpawnPosition(), Quaternion.identity, Runner.LocalPlayer);
			Runner.SetPlayerObject(Runner.LocalPlayer, LocalPlayer.Object);
		}

		public override void FixedUpdateNetwork()
		{
			BestHunter = PlayerRef.None;
			int bestHunterKills = 0;

			foreach (var playerRef in Runner.ActivePlayers)
			{
				var playerObject = Runner.GetPlayerObject(playerRef);
				var player = playerObject != null ? playerObject.GetComponent<Player>() : null;

				if (player == null)
					continue;

				// Calculate the best hunter
				if (player.Health.IsAlive && player.ChickenKills > bestHunterKills)
				{
					bestHunterKills = player.ChickenKills;
					BestHunter = player.Object.StateAuthority;
				}
			}
		}

		public override void Despawned(NetworkRunner runner, bool hasState)
		{
			// Clear the reference because UI can try to access it even after despawn
			LocalPlayer = null;
		}
	}
}

딱히 코드를 읽는데 지장이 갈 만한 부분은 없으므로, 넘어가 주도록 하자.

 

Player.cs

총으로 치킨의 목숨을 날리는 코드만 관찰해 보도록 하자. Camera의 Transform을 받아와서 Raycast를 진행해 주고, Health Component를 받아와 준 뒤에 Component 안의 killed라는 bool 변수를 바꿔 주는 모습이다.

 

이상으로 뜯어 볼 만한 코드는 다 뜯어 보았다. 이제 위를 참고하여 행복하게 게임을 만들어 보도록 하자!

 

Ref : Fusion 2 Fusion Starter | Photon Engine