만들던 게임을 Fusion 2를 사용해서 멀티플레이가 가능한 게임으로 만들어 보겠습니다.
Fusion 2 가 아예 처음이기 때문에 photon 사이트의 튜토리얼을 따라하면서 코드를 수정해보겠습니다.

우선, Setup Networking in the Scene 으로 Prototype Network Start 와 Prototype Runner 를 추가해줍니다.

그 후에, 멀티플레이에 필요한 오브젝트들에 NetworkObject, NetworkTransform, CharacterController 를 적절히 부착해줍니다.

public class BoxController : NetworkBehaviour
NetworkBehaviour 를 상속받습니다.
내부적으로, NetworkBehavior -> SimulationBehavior -> Behavior -> MonoBehavior 를 상속받기 때문에 일반적으로 에러는 일어나지 않습니다.
그 후에, Update 을 수정해야 합니다.
private KeyCode _pressedKeyCode = KeyCode.None;
private void Update()
{
foreach (KeyCode key in arrowKeys)
{
if (Input.GetKey(key))
{
_pressedKeyCode = key; return;
}
}
}
private bool isSynced = false;
private bool isScaleSynced = false;
public override void FixedUpdateNetwork()
{
if (HasStateAuthority == false) return;
if (!isScaleSynced)
{
transform.localScale = Vector3.one;
isScaleSynced = true;
}
if (!isJumping)
{
if (!isSynced)
{
transform.position = jumpTarget;
transform.rotation = targetRotation;
isSynced = true;
}
if (_pressedKeyCode != KeyCode.None)
{
isSynced = false;
GetKeyInput(_pressedKeyCode);
}
}
_pressedKeyCode = KeyCode.None;
}
Update에서 바로 함수를 호출하는 것이 아니라, bool 변수를 조작해서 FixedUpdateNetwork 함수 내부에서 호출하도록 합니다.
특히, transform 의 position, rotation, scale 은 코루틴 내에서 변경해도 동기화가 정확히 되지 않을 수 있습니다. ( 참고자료 )
따라서, transform 도 bool 변수를 통해서 FixedUpdateNetwork() 함수 내에서 동기화할 수 있도록 합니다.
[Serializable]
public struct NetworkGridInfo : INetworkStruct{
[Networked] public Vector2Int Pos { get; set; }
[Networked] public int Height { get; set; }
[Networked] public GridState State { get; set; }
[Networked] public ColorSet colorset { get; set; }
public NetworkGridInfo(Vector2Int pos, int height, int colorIdx, GridState state)
{
this.Pos = pos;
this.Height = height;
if (state < 0) this.State = 0;
else this.State = state;
colorset = new ColorSet(ColorConstants.COLORARR[colorIdx]);
}
}
public class MapGrid : NetworkBehaviour, IAfterSpawned
{
[SerializeField, Networked]
public NetworkGridInfo NetworkedGridInfo { get; set; }
// Process Grid According to GridState
public void InitMapGrid(NetworkGridInfo info)
{
NetworkedGridInfo = new NetworkGridInfo(info.Pos, info.Height, info.colorset.GetColorIdx(), info.State);
RPC_UpdateGridVisuals();
}
[Rpc(RpcSources.All, RpcTargets.All)]
public void RPC_UpdateGridVisuals()
{
transform.localScale = Vector3.one * Constant.GRID_SIZE;
transform.position = new Vector3(transform.position.x, NetworkedGridInfo.Height * Constant.GRID_SIZE - transform.localScale.y / 2 - Constant.BOX_SIZE / 2, transform.position.z);
if (GameManagerEx.Instance.IsColorBlind)
{
GetComponent<Renderer>().material.mainTexture = Managers.Resource.GetDiceTexture(NetworkedGridInfo.colorset.GetColorIdx());
}
else
{
GetComponent<Renderer>().material.color = NetworkedGridInfo.colorset.GetColor();
}
}
public void SetGridInfo(NetworkGridInfo info)
{
NetworkedGridInfo = info;
RPC_UpdateGridVisuals();
}
// ...
}
위에는 각 그리드의 정보를 저장하는 NetworkGridInfo 입니다.
Custom DataType을 네트워크상에 정보를 동기화시키기 위해서는 INetworkStruct를 상속해야 합니다.
아래에는 각 그리드 객체를 조작하는 MapGrid 입니다.
그리드 정보를 받은 이후나 정보가 변경된 이후에, RPC 호출을 사용하여 모든 클라이언트가 변경사항을 볼 수 있도록 했습니다.
// ...
[UnitySerializeField, Networked, Capacity(50)]
public NetworkDictionary<Vector2Int, MapGrid> NetworkedMapGrids => default;
[Networked]
public int NetworkedCurMapWidth { get; set; }
// ...
// 각 클라이언트가 처음 접속했을 때 정보 시각화
public override void Spawned()
{
UpdateMapGrids();
}
public void UpdateMapGrids()
{
for (int i = 0; i < NetworkedCurMapWidth; i++)
{
for (int j = 0; j < NetworkedCurMapWidth; j++)
{
var tmpGrid = NetworkedMapGrids[new Vector2Int(i, j)];
if (tmpGrid != null)
{
tmpGrid.RPC_UpdateGridVisuals();
}
}
}
}
// ...
public void GenerateMap(GameType type, int n, NetworkRunner runner)
{
// ...
NetworkGridInfo grid;
NetworkedMapGrids.Clear();
for (int i = 0; i < mapArrs.Count; i++)
{
grid = mapArrs[i];
var sp = runner.Spawn(gridPrefab, new Vector3(grid.Pos.x, 0, grid.Pos.y) * Constant.GRID_SIZE, Quaternion.identity,
inputAuthority: null,
(runner, NO) => NO.GetComponent<MapGrid>().InitMapGrid(grid));
NetworkedMapGrids.Set(new Vector2Int(grid.Pos.y, grid.Pos.x), sp.GetComponent<MapGrid>());
}
NetworkedCurMapWidth = mapResource.width;
return;
}
맨 위의 변수 부분에 모든 클라이언트가 공유해야할 정보 앞에 [Networked] 어트리뷰트를 통해 동기화 합니다.
UpdateMapGrids 는 각 클라이언트가 처음 접속했을 때 호출되게 함으로써, SharedModeMasterClient 가 조작한 정보를 들어오자마자 볼 수 있도록 하기 위함입니다.
GenerateMap 은 이전에 Instantiate으로 생성하던 프리팹들을 runner.Spawn 함수를 통해 생성하도록 했습니다. 반대로, Destroy 로 삭제하던 오브젝트들도 Despawn 을 통해 없애도록 했습니다.
public class PlayerSpawner : SimulationBehaviour, IPlayerJoined{
public void PlayerJoined(PlayerRef player)
{
if (player == Runner.LocalPlayer)
MapRestart();
}
public GameObject PlayerPrefab;
public NetworkObject currentPlayer;
public int idx;
public void MapRestart()
{
GameManagerEx.Instance.spawner = this;
if (currentPlayer != null)
Runner.Despawn(currentPlayer);
if (GameManagerEx.Instance.CurGameType== GameType.MULTI && Runner.IsSharedModeMasterClient || GameManagerEx.Instance.CurGameType!= GameType.MULTI)
CreateMap();
MapGenerator.Instance.SetStageName(GameManagerEx.Instance.CurGameType, GameManagerEx.Instance.CurLv);
CameraController.Instance.SetQuaterView(Managers.Resource.GetCamPos(GameManagerEx.Instance.CurGameType, GameManagerEx.Instance.CurLv));
PlayerSpawn();
}
public void PlayerSpawn()
{
currentPlayer = Runner.Spawn(PlayerPrefab, new Vector3(0, 0, 0), Quaternion.identity);
currentPlayer.GetComponent<BoxController>().SetBoxController(new Vector2Int(0, 0), 0);
}
public void CreateMap()
{
MapGenerator.Instance.GenerateMap(GameManagerEx.Instance.CurGameType, GameManagerEx.Instance.CurLv, Runner);
}
}
PlayerJoined 콜백 함수를 통해 플레이어가 들어오면 맵을 만들거나, 맵이 만들어져 있다면 플레이어만 스폰하는 PlayerSpawner 클래스입니다.
여기까지 수정하고 실행해보겠습니다.
Photon 을 처음 사용해봐서 그런지, 오류가 많이 발생해서 구현하는데 시간이 꽤 걸렸습니다.
발생한 오류에 대해서는 다른 글로 정리해서 올리도록 하겠습니다.


https://doc.photonengine.com/ko-kr/fusion/current/tutorials/shared-mode-basics/overview
https://doc.photonengine.com/fusion/current/concepts-and-patterns/networked-controller-code