2024. 8. 29. 16:22ㆍApplication & 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으로 넘어가 보자.
먼저 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 변수를 바꿔 주는 모습이다.
이상으로 뜯어 볼 만한 코드는 다 뜯어 보았다. 이제 위를 참고하여 행복하게 게임을 만들어 보도록 하자!
'Application & Note > Unity Engine' 카테고리의 다른 글
[그대를 위한 파르페] 개발 시작 (헬게이트 오픈) (0) | 2024.09.03 |
---|---|
[Unity] Photon Fusion (6) - Network Rigidbody, Collision (0) | 2024.08.28 |
[Unity] Photon Fusion (5) - RPC (0) | 2024.08.28 |
[Unity] Photon Fusion (4) - Network Property, State Authority (1) | 2024.08.28 |
[Unity] Photon Fusion을 이용한 멀티게임 개발(3) (0) | 2024.08.28 |