- 기존에는 Resources 폴더 산하에 Prefab, Sprites, Sound 등의 폴더를 생성하여 계층구조를 통해 에셋을 관리하였다.
~> Addressable을 통해 불필요한 에셋의 로드를 방지할 수 있다.
- [ Window ] - [ PackageManager ] 에서 Packages를 Unity Registry 로 바꾼 뒤 'Addressables' 검색 후 Install 한다.
~> 설치 후 [ Window ] - [ Asset Management ] - [ Groups ] - [ Create Addressables Settings ] 에서 초기 세팅을 한다.
~> [ New ] - [ Blank (no schema) ] 를 통해 새로운 그룹을 만들 수 있다. (그룹 단위로 묶어 관리 가능)
~> 변경된 사항이 있다면 [ Build ] - [ New Build ] - [ Default Build Script ] 를 해줘야 빌드시 오류가 발생하지 않는다.
~> 사용하고자 하는 에셋의 [ Inspector ] 창에서 [ Addressable ] 를 체크하여 추가할 수 있다.
~> 기본적으로 Address 값은 에셋의 경로로 설정 되지만 사용자가 임의로 수정할 수 있다. (Address 값을 통해 로드)
~> 에셋의 경로를 바꿔도 Path는 자동으로 수정된다.
- Addressable은 비동기 호출이다.
~> 기존에 사용하던 Resources.Load() 는 로드가 끝나야지만 다음 코드로 넘어간다. (동기 호출)
~> Addressable은 당장 실행되는 것이 아니므로 콜백 함수를 사용한다. (로딩이 완료될 경우 실행하도록)
~> Release를 해줘야 메모리에서 사라진다.
Addressable 사용 예제
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.AddressableAssets;
using UnityEngine.ResourceManagement.AsyncOperations;
public class TestAddressable : MonoBehaviour
{
void Start()
{
AsyncOperationHandle<GameObject> obj = Addressables.LoadAssetAsync<GameObject>("KeyName");
obj.Completed += (val) =>
{
GameObject go = val.Result;
Debug.Log(go.name);
Addressables.Release(obj);
};
}
}
# 스테이지와 인벤토리
- 첨부파일의 AlicePang 프로젝트를 통해 찾고자 하는 기능이 어디에, 어떤식으로 구현되어 있는지 직접 찾고 확인해보기
- 가챠 시스템에서 아이템을 뽑을때 대부분 아이템이 바로 등장하는 것이 아닌 다양한 효과가 발생한 뒤 등장한다.
~> Animation도 상태를 통해 관리하는 것이 좋다.
상태를 통한 Animation 관리 예제
void OnClickTouchPanel()
{
Debug.Log("ClickTouchPanel");
switch(_animationSequence)
{
case AnimationSequence.StartGacha:
state.Complete -= MotionToWaitForOpenIdle;
WaitForOpenAnimation();
break;
case AnimationSequence.WaitForOpenIdle:
OpenAnimation();
break;
case AnimationSequence.Open:
state.Complete -= MotionToAfterOpen;
AfterOpenAnimation();
break;
case AnimationSequence.AfterOpenIdle:
WaitForShowCardAnimation();
break;
case AnimationSequence.WaitForShowCardIdle:
ShowCardAnimation();
break;
case AnimationSequence.ShowCard:
ShowCardAnimation();
break;
case AnimationSequence.EndGacha:
break;
}
}
# 전투 코드 분석
- 첨부파일의 AlicePang 프로젝트를 통해 찾고자 하는 기능이 어디에, 어떤식으로 구현되어 있는지 직접 찾고 확인해보기
- Polygon Collider 2D는 2D 물리 시스템과 상호작용하는 Collider 2D 컴포넌트이다.
~> 모양은 라인 세그먼트의 자유형 가장자리로, 스프라이트 모양이나 다른 모양에 맞게 조정할 수 있다.
- [ Server/Cloud Settings ] 의 [ App Id PUN ] 에 붙여넣는다.
[ Photon 기능 함수 ]
Photon 기능 함수
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
using Photon.Pun; // MonoBehaviourPunCallbacks를 상속 받기 위해 추가
using Photon.Realtime; // MonoBehaviourPunCallbacks를 상속 받기 위해 추가
public class NetworkManager : MonoBehaviourPunCallbacks // MonoBehaviour이 아닌 MonoBehaviourPunCallbacks를 상속 받는다.
{
public Text StatusText;
public InputField roomInput, NickNameInput;
void Awake() => Screen.SetResolution(960, 540, false);
void Update() => StatusText.text = PhotonNetwork.NetworkClientState.ToString(); // PhotonNetwork.NetworkClientState.ToString()는 현재 상태를 문자열로 반환
public void Connect() => PhotonNetwork.ConnectUsingSettings(); // Photon Online Server에 접속
public override void OnConnectedToMaster() // Call back 함수
{
print("서버접속완료");
PhotonNetwork.LocalPlayer.NickName = NickNameInput.text;
}
public void Disconnect() => PhotonNetwork.Disconnect(); // Photon Online Server와의 연결 끊기
public override void OnDisconnected(DisconnectCause cause) => print("연결끊김"); // Call back 함수
public void JoinLobby() => PhotonNetwork.JoinLobby(); // 로비에 접속
public override void OnJoinedLobby() => print("로비접속완료"); // Call back 함수
public void CreateRoom() => PhotonNetwork.CreateRoom(roomInput.text, new RoomOptions { MaxPlayers = 2 }); // 방 생성 (방 이름, 최대 플레이어 수, 비공개 지정 가능)
public override void OnCreatedRoom() => print("방만들기완료"); // Call back 함수
public void JoinRoom() => PhotonNetwork.JoinRoom(roomInput.text); // 방 참가 (방 이름으로 입장 가능)
public override void OnJoinedRoom() => print("방참가완료"); // Call back 함수
public void JoinOrCreateRoom() => PhotonNetwork.JoinOrCreateRoom(roomInput.text, new RoomOptions { MaxPlayers = 2 }, null); // 방 참가, 방이 없으면 생성후 참가
public void JoinRandomRoom() => PhotonNetwork.JoinRandomRoom(); // 방 랜덤 참가
public void LeaveRoom() => PhotonNetwork.LeaveRoom(); // 방 떠나기
public override void OnCreateRoomFailed(short returnCode, string message) => print("방만들기실패"); // Call back 함수
public override void OnJoinRoomFailed(short returnCode, string message) => print("방참가실패"); // Call back 함수
public override void OnJoinRandomFailed(short returnCode, string message) => print("방랜덤참가실패"); // Call back 함수
// [ Script Component ] - [ 오른쪽 마우스 ] - [ 정보 ] 를 통해 실행 가능
[ContextMenu("정보")]
void Info()
{
if (PhotonNetwork.InRoom)
{
print("현재 방 이름 : " + PhotonNetwork.CurrentRoom.Name);
print("현재 방 인원수 : " + PhotonNetwork.CurrentRoom.PlayerCount);
print("현재 방 최대인원수 : " + PhotonNetwork.CurrentRoom.MaxPlayers);
string playerStr = "방에 있는 플레이어 목록 : ";
for (int i = 0; i < PhotonNetwork.PlayerList.Length; i++) playerStr += PhotonNetwork.PlayerList[i].NickName + ", ";
print(playerStr);
}
else
{
print("접속한 인원 수 : " + PhotonNetwork.CountOfPlayers);
print("방 개수 : " + PhotonNetwork.CountOfRooms);
print("모든 방에 있는 인원 수 : " + PhotonNetwork.CountOfPlayersInRooms);
print("로비에 있는지? : " + PhotonNetwork.InLobby);
print("연결됐는지? : " + PhotonNetwork.IsConnected);
}
}
}
[ RPC란? ]
- Player의 움직임을 동기화하기 위해서는 "Photon View"와 "Photon Transform View" Script를 Component로 추가해준다.
~> "Photon Transform View" Script Component 의 Synchronize Options 에서 무엇을 동기화할 것인지 선택할 수 있다.
(Position, Rotation, Scale 에 관한 동기화 선택이 가능하다.)
~> "Photon View" Script Component 의 Observed Components에 "Photon Transform View" Script Component 를
드래그 앤 드롭으로 연결한다. (이는 "Photon Transform View"Script Component 를 관찰하여 동기화 한다는 것)
~> 동기화는 "Photon View" Script Component 의 Controlled locally가 true인 경우에만 가능하다.
(Controlled locally는 PhotonView PV; PV.IsMine 으로 확인이 가능하다.)
- Position, Rotation, Scale 를 제외한 나머지는 어떻게 동기화 시킬까?
~> RPC 함수를 통해 동기화 시켜야 한다.
RPC 함수 선언 및 사용 예제
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using Photon.Pun;
using Photon.Realtime;
public class PlayerScript : MonoBehaviourPunCallbacks
{
public PhotonView PV;
public SpriteRenderer SR;
void Update()
{
if (PV.IsMine)
{
float axis = Input.GetAxisRaw("Horizontal");
transform.Translate(new Vector3(axis * Time.deltaTime * 7, 0, 0));
// RPC 함수 호출 (실행하고자 하는 함수 이름, 타겟, 인자)
// RPCTarget.All은 그 즉시 호출되어 사라지지만, AllBuffered는 재접속될때 호출된다.
if (axis != 0) PV.RPC("FlipXRPC", RpcTarget.AllBuffered, axis);
}
}
// RPC 함수 선언
[PunRPC]
void FlipXRPC(float axis)
{
SR.flipX = axis == -1;
}
}
~> "Photon Animator View" Script Component 의 Synchronize Layer Weights 와 Synchronize Parameters 선택지 중
"Disabled" 는 "사용 안 함", "Discrete" 는 "On/Off시 호출", "Continuous" 는 "수시로 호출" 에 해당한다.
~> "Photon View" Script Component 의 Observed Components에 "Photon AnimatorView"Script Component 를
드래그 앤 드롭으로 연결한다. (이는 "Photon AnimatorView"Script Component 를 관찰하여 동기화 한다는 것)
[ 변수 동기화 ]
- 변수를 동기화하기 위해서는 MonoBehaviourPunCallbacks 뿐만이 아닌 IPunObservable 도 상속 받는다.
~> IPunObservable 를 상속 받는 경우 OnPhotonSerializeView 인터페이스 구현이 필수다.
~> 동기화는 PhotonView를 반드시 거쳐가야 하기 때문에 위의 Script를 "Photon View" Script Component 의 Observed
Components에 드래그 앤 드롭으로 연결한다.
변수 동기화 예제
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using Photon.Pun;
using Photon.Realtime;
using UnityEngine.UI;
public class PlayerScript : MonoBehaviourPunCallbacks, IPunObservable // IPunObservable을 상속 받는다.
{
// ...
public Text txt;
// ...
[ContextMenu("더하기")]
public void Plus() => txt.text = (int.Parse(txt.text) + 1).ToString();
// OnPhotonSerializeView 인터페이스 구현
public void OnPhotonSerializeView(PhotonStream stream, PhotonMessageInfo info)
{
if (stream.IsWriting) stream.SendNext(txt.text); // Server에 보낼때
else txt.text = (string)stream.ReceiveNext(); // Server로부터 받을때
}
}
~> 즉, 패킷 직렬화란 메모리 상에 존재하는 데이터를 패킷에 차곡차곡 쌓은 뒤 이를 하나의 바이트 배열로 만드는 것이다.
- 역직렬화란 특정 포맷 상태의 데이터를 다시 객체로 변환하는 것을 뜻한다.
- Session은 추후에 다양하게 존재할 수 있기 때문에 Session의 이름을 정확하게 지어주는 것이 중요하다.
(예를 들어 분산서버인 경우 각 다른 부분을 관리하는 서버의 대리자 역할을 하는 Session이 여러개 존재한다.)
- 우선 Serialization 의 흐름만 이해한 뒤 추후에 자동화 할 예정이다.
DummyClient에 ServerSession Class 생성 후 DummyClient의 Program Class 와 내용 분리
using ServerCore;
using System;
using System.Collections.Generic;
using System.Net;
using System.Net.Sockets;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
namespace DummyClient
{
class Packet
{
public ushort size; // ushort는 2Byte
public ushort packetId; // ushort는 2Byte
}
class PlayerInfoReq : Packet // Client에서 Server로 Player의 정보를 알고 싶다고 요청하는 것
{
public long playerId;
}
class PlayerInfoOk : Packet // Server에서 Client로 요청에 대한 답변을 전달하는 것
{
public int hp;
public int attack;
}
public enum PacketID
{
PlayerInfoReq = 1,
PlayerInfoOk = 2,
}
class ServerSession : Session
{
public override void OnConnected(EndPoint endPoint)
{
Console.WriteLine($"OnConnected : {endPoint}");
PlayerInfoReq packet = new PlayerInfoReq() { packetId = (ushort)PacketID.PlayerInfoReq, playerId = 1001 };
for (int i = 0; i < 5; i++)
{
ArraySegment<byte> openSegment = SendBufferHelper.Open(4096);
// 아래 부분 추가 (2번의 단계를 거쳐야 하는 것을 TryWriteBytes를 통해 1번의 단계를 거치도록 수정)
bool success = true;
ushort count = 0; // 지금까지 몇 Byte를 Buffer에 밀어 넣었는가?
count += 2;
success &= BitConverter.TryWriteBytes(new Span<byte>(openSegment.Array, openSegment.Offset + count, openSegment.Count - count), packet.packetId);
count += 2;
success &= BitConverter.TryWriteBytes(new Span<byte>(openSegment.Array, openSegment.Offset + count, openSegment.Count - count), packet.playerId);
count += 8;
success &= BitConverter.TryWriteBytes(new Span<byte>(openSegment.Array, openSegment.Offset, openSegment.Count), count); // size는 모든 작업이 끝난 뒤 초기화
// 아래 부분 삭제
//byte[] size = BitConverter.GetBytes(packet.size);
//byte[] packetId = BitConverter.GetBytes(packet.packetId);
//byte[] playerId = BitConverter.GetBytes(packet.playerId);
//Array.Copy(size, 0, openSegment.Array, openSegment.Offset + count, 2);
//count += 2;
//Array.Copy(packetId, 0, openSegment.Array, openSegment.Offset + count, 2);
//count += 2;
//Array.Copy(playerId, 0, openSegment.Array, openSegment.Offset + count, 8);
//count += 8;
ArraySegment<byte> sendBuff = SendBufferHelper.Close(count);
if (success) // success시 Send
Send(sendBuff);
}
}
public override void OnDisconnected(EndPoint endPoint)
{
Console.WriteLine($"OnDisconnected : {endPoint}");
}
public override int OnRecv(ArraySegment<byte> buffer)
{
string recvData = Encoding.UTF8.GetString(buffer.Array, buffer.Offset, buffer.Count);
Console.WriteLine($"[From Server] {recvData}");
return buffer.Count;
}
public override void OnSend(int numOfBytes)
{
Console.WriteLine($"Transferred bytes : {numOfBytes}");
}
}
}
수정된 DummyClient의 Program Class
using System;
using System.Collections.Generic;
using System.Net;
using System.Net.Sockets;
using System.Text;
using System.Threading;
using ServerCore; // Servercore 라이브러리 참조
namespace DummyClient
{
class Program
{
static void Main(string[] args)
{
// DNS (Domain Name System) : Domain을 IP 네트워크에서 찾아갈 수 있는 IP로 변환해 준다.
string host = Dns.GetHostName(); // Local Computer의 host 이름을 반환
IPHostEntry ipHost = Dns.GetHostEntry(host);
IPAddress ipAddr = ipHost.AddressList[0]; // ip 주소를 배열로 반환 (예를 들어 Google과 같이 Traffic이 어마무시한 사이트는 여러개의 ip 주소를 가질 수 있기 때문)
IPEndPoint endPoint = new IPEndPoint(ipAddr, 7777); // ip 주소와 port 번호를 매개변수로 입력
Connector connector = new Connector();
connector.Connect(endPoint, () => { return new ServerSession(); });
while (true)
{
try
{
}
catch (Exception e)
{
Console.WriteLine(e.ToString());
}
Thread.Sleep(100);
}
}
}
}
Server에 ClientSession Class 생성 후 Server의 Program Class 와 내용 분리
using ServerCore;
using System;
using System.Collections.Generic;
using System.Net;
using System.Net.Sockets;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
namespace Server
{
class Packet // 패킷 설계시 최대한 Size를 압축하여 보내는 것이 중요하다.
{
// Packet이 완전체로 왔는지? 잘려서 왔는지? 구분할 수 있어야 한다.
public ushort size; // ushort는 2Byte
public ushort packetId; // ushort는 2Byte
}
class PlayerInfoReq : Packet // Client에서 Server로 Player의 정보를 알고 싶다고 요청하는 것
{
public long playerId;
}
class PlayerInfoOk : Packet // Server에서 Client로 요청에 대한 답변을 전달하는 것
{
public int hp;
public int attack;
}
public enum PacketID
{
PlayerInfoReq = 1,
PlayerInfoOk = 2,
}
class ClientSession : PacketSession
{
public override void OnConnected(EndPoint endPoint)
{
Console.WriteLine($"OnConnected : {endPoint}");
Packet packet = new Packet() { size = 100, packetId = 10 };
ArraySegment<byte> openSegment = SendBufferHelper.Open(4096);
byte[] buffer = BitConverter.GetBytes(packet.size);
byte[] buffer2 = BitConverter.GetBytes(packet.packetId);
// 어느 배열의? 어디서부터? 어느 배열의? 어디로? 얼마만큼?
Array.Copy(buffer, 0, openSegment.Array, openSegment.Offset, buffer.Length);
Array.Copy(buffer2, 0, openSegment.Array, buffer.Length, buffer2.Length);
ArraySegment<byte> sendBuff = SendBufferHelper.Close(buffer.Length + buffer2.Length);
Send(sendBuff);
Thread.Sleep(5000);
Disconnect();
}
// OnRecvPacket 코드 수정
public override void OnRecvPacket(ArraySegment<byte> buffer)
{
ushort count = 0; // 지금까지 몇 Byte를 Buffer에 밀어 넣었는가?
ushort size = BitConverter.ToUInt16(buffer.Array, buffer.Offset + count); // ToUInt16은 Byte 배열을 ushort로 뽑아달라는 것
count += 2;
ushort id = BitConverter.ToUInt16(buffer.Array, buffer.Offset + count); // ToUInt16은 Byte 배열을 ushort로 뽑아달라는 것
count += 2;
switch ((PacketID)id)
{
case PacketID.PlayerInfoReq:
{
long playerId = BitConverter.ToInt64(buffer.Array, buffer.Offset + count); // ToUInt16은 Byte 배열을 long으로 뽑아달라는 것
count += 8;
Console.WriteLine($"PlayerInfoReq: {playerId}");
}
break;
}
Console.WriteLine($"RecvPacketID: {id}, Size: {size}");
}
public override void OnDisconnected(EndPoint endPoint)
{
Console.WriteLine($"OnDisconnected : {endPoint}");
}
public override void OnSend(int numOfBytes)
{
Console.WriteLine($"Transferred bytes : {numOfBytes}");
}
}
}
수정된 Server의 Program Class
using System;
using System.Text;
using System.Net;
using System.Net.Sockets;
using System.Threading;
using System.Threading.Tasks;
using ServerCore;
namespace Server
{
class Program
{
static Listener _listener = new Listener();
static void Main(string[] args)
{
string host = Dns.GetHostName();
IPHostEntry ipHost = Dns.GetHostEntry(host);
IPAddress ipAddr = ipHost.AddressList[0];
IPEndPoint endPoint = new IPEndPoint(ipAddr, 7777);
// 손님을 입장시킨다.
_listener.Init(endPoint, () => { return new ClientSession(); });
Console.WriteLine("Listening...");
while (true)
{
}
}
}
}
# Serialization #2
- 자동화 하기에 앞서 인터페이스를 통한 코드 수정을 할 예정이다.
ServerSession Class 수정
using ServerCore;
using System;
using System.Collections.Generic;
using System.Net;
using System.Net.Sockets;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
namespace DummyClient
{
public abstract class Packet
{
public ushort size;
public ushort packetId;
// 최상위 Class인 Packet에 인터페이스 생성
public abstract ArraySegment<byte> Write();
public abstract void Read(ArraySegment<byte> s);
}
class PlayerInfoReq : Packet // Client에서 Server로 Player의 정보를 알고 싶다고 요청하는 것
{
public long playerId;
// 생성자
public PlayerInfoReq()
{
this.packetId = (ushort)PacketID.PlayerInfoReq;
}
// 인터페이스 구현
public override ArraySegment<byte> Write()
{
ArraySegment<byte> openSegment = SendBufferHelper.Open(4096);
bool success = true;
ushort count = 0; // 지금까지 몇 Byte를 Buffer에 밀어 넣었는가?
count += 2;
success &= BitConverter.TryWriteBytes(new Span<byte>(openSegment.Array, openSegment.Offset + count, openSegment.Count - count), this.packetId);
count += 2;
success &= BitConverter.TryWriteBytes(new Span<byte>(openSegment.Array, openSegment.Offset + count, openSegment.Count - count), this.playerId);
count += 8;
success &= BitConverter.TryWriteBytes(new Span<byte>(openSegment.Array, openSegment.Offset, openSegment.Count), count); // size는 모든 작업이 끝난 뒤 초기화
if (success == false)
return null;
return SendBufferHelper.Close(count);
}
// 인터페이스 구현
public override void Read(ArraySegment<byte> s)
{
ushort count = 0;
//ushort size = BitConverter.ToUInt16(s.Array, s.Offset + count); ~> 사용할 일이 없어 필요 X
count += 2;
//ushort id = BitConverter.ToUInt16(s.Array, s.Offset + count); ~> Read를 실행했다는 것은 이미 패킷 분해 후 id에 대한 정보를 얻은 뒤이므로 필요 X
count += 2;
// 수정 부분 (Client가 악의적으로 잘못된 Packet Size를 보낸 경우를 방지하기 위함)
this.playerId = BitConverter.ToInt64(new ReadOnlySpan<byte>(s.Array, s.Offset + count, s.Count - count));
count += 8;
}
}
// PlayerInfoOk는 추후에 구현 예정
//class PlayerInfoOk : Packet // Server에서 Client로 요청에 대한 답변을 전달하는 것
//{
// public int hp;
// public int attack;
//}
// ...
class ServerSession : Session
{
public override void OnConnected(EndPoint endPoint)
{
Console.WriteLine($"OnConnected : {endPoint}");
PlayerInfoReq packet = new PlayerInfoReq() { playerId = 1001 };
for (int i = 0; i < 5; i++)
{
ArraySegment<byte> s = packet.Write(); // 직렬화
if (s != null) // success시 Send
Send(s);
}
}
// ...
}
}
ClientSession Class 수정
using ServerCore;
using System;
using System.Collections.Generic;
using System.Net;
using System.Net.Sockets;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
namespace Server
{
public abstract class Packet
{
public ushort size;
public ushort packetId;
// 최상위 Class인 Packet에 인터페이스 생성
public abstract ArraySegment<byte> Write();
public abstract void Read(ArraySegment<byte> s);
}
class PlayerInfoReq : Packet // Client에서 Server로 Player의 정보를 알고 싶다고 요청하는 것
{
public long playerId;
// 생성자
public PlayerInfoReq()
{
this.packetId = (ushort)PacketID.PlayerInfoReq;
}
// 인터페이스 구현
public override ArraySegment<byte> Write()
{
ArraySegment<byte> openSegment = SendBufferHelper.Open(4096);
bool success = true;
ushort count = 0; // 지금까지 몇 Byte를 Buffer에 밀어 넣었는가?
count += 2;
success &= BitConverter.TryWriteBytes(new Span<byte>(openSegment.Array, openSegment.Offset + count, openSegment.Count - count), this.packetId);
count += 2;
success &= BitConverter.TryWriteBytes(new Span<byte>(openSegment.Array, openSegment.Offset + count, openSegment.Count - count), this.playerId);
count += 8;
success &= BitConverter.TryWriteBytes(new Span<byte>(openSegment.Array, openSegment.Offset, openSegment.Count), count); // size는 모든 작업이 끝난 뒤 초기화
if (success == false)
return null;
return SendBufferHelper.Close(count);
}
// 인터페이스 구현
public override void Read(ArraySegment<byte> s)
{
ushort count = 0;
//ushort size = BitConverter.ToUInt16(s.Array, s.Offset + count); ~> 사용할 일이 없어 필요 X
count += 2;
//ushort id = BitConverter.ToUInt16(s.Array, s.Offset + count); ~> Read를 실행했다는 것은 이미 패킷 분해 후 id에 대한 정보를 얻은 뒤이므로 필요 X
count += 2;
// 수정 부분 (Client가 악의적으로 잘못된 Packet Size를 보낸 경우를 방지하기 위함)
this.playerId = BitConverter.ToInt64(new ReadOnlySpan<byte>(s.Array, s.Offset + count, s.Count - count));
count += 8;
}
}
// PlayerInfoOk는 추후에 구현 예정
//class PlayerInfoOk : Packet // Server에서 Client로 요청에 대한 답변을 전달하는 것
//{
// public int hp;
// public int attack;
//}
// ...
class ClientSession : PacketSession
{
// ...
public override void OnRecvPacket(ArraySegment<byte> buffer)
{
// 패킷을 분해하여 id 에 대한 정보를 얻은 뒤
ushort count = 0;
ushort size = BitConverter.ToUInt16(buffer.Array, buffer.Offset + count);
count += 2;
ushort id = BitConverter.ToUInt16(buffer.Array, buffer.Offset + count);
count += 2;
// 해당 id 에 맞는 코드를 실행
switch ((PacketID)id)
{
case PacketID.PlayerInfoReq:
{
PlayerInfoReq p = new PlayerInfoReq();
p.Read(buffer); // 역직렬화
Console.WriteLine($"PlayerInfoReq: {p.playerId}");
}
break;
}
Console.WriteLine($"RecvPacketID: {id}, Size: {size}");
}
// ...
}
}
# UTF-8 vs UTF-16
- 컴퓨터가 세상에 처음 등장할 당시에는 영어와 몇가지 특수문자만을 사용하였고, 이를 저장하기 위해 1Byte로 충분했다.
~> ASCII 코드 (1Byte) 등장
- 그러나 인터넷 시대 도입 후 언어의 다양성으로 인하여 1Byte 만으로는 모든 나라의 언어를 표현할 수 없다.
~> UNICODE (2Byte) 등장
- Encoding은 컴퓨터에서 문자와 기호를 표현하기 위해 문자를 이진 데이터로 변환하는 과정이며 문자와 이진 데이터 간의
Mapping 규칙을 정의하는 방법이다.
~> Variable-Width Encoding (가변 너비 인코딩) : UTF-8, UTF-16
~> Fixed-Length Encoding (고정 길이 인코딩) : UTF-32
- UTF-8
~> 영문 : 1Byte
~> 한글 : 3Byte
- UTF-16
~> BMP X : 2Byte
~> BMP O : 4Byte
~> 영문 : 2Byte
~> 한글 : 2Byte
# Serialization #3
- 데이터의 길이가 가변적인 String은 어떻게 처리해야 할까?
String 처리를 위한 ServerSession Class 와 ClientSession Class 수정 (아래 코드는 ServerSession Class지만 ClientSession Class도 동일한 코드로 수정)
using ServerCore;
using System;
using System.Collections.Generic;
using System.Net;
using System.Net.Sockets;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
namespace DummyClient
{
public abstract class Packet
{
public ushort size;
public ushort packetId;
public abstract ArraySegment<byte> Write();
public abstract void Read(ArraySegment<byte> openSegment);
}
class PlayerInfoReq : Packet // Client에서 Server로 Player의 정보를 알고 싶다고 요청하는 것
{
public long playerId;
public string name; // 가변 길이의 멤버 변수는 어떻게 처리?
// 생성자
public PlayerInfoReq()
{
this.packetId = (ushort)PacketID.PlayerInfoReq;
}
public override ArraySegment<byte> Write()
{
ArraySegment<byte> openSegment = SendBufferHelper.Open(4096);
bool success = true;
ushort count = 0;
Span<byte> span = new Span<byte>(openSegment.Array, openSegment.Offset, openSegment.Count);
count += sizeof(ushort);
success &= BitConverter.TryWriteBytes(span.Slice(count, span.Length - count), this.packetId); // Slice는 실질적으로 Span에 변화를 주지 X
count += sizeof(ushort);
success &= BitConverter.TryWriteBytes(span.Slice(count, span.Length - count), this.playerId); // Slice는 실질적으로 Span에 변화를 주지 X
count += sizeof(long);
// string 처리 #1 (Buffer에 2Byte인 string len을 먼저 삽입 후 string data 삽입)
//ushort nameLen = (ushort)Encoding.Unicode.GetByteCount(this.name); // GetByteCount()는 UTF-16 기준의 byte 배열 크기를 반환
//success &= BitConverter.TryWriteBytes(span.Slice(count, span.Length - count), nameLen); // Slice는 실질적으로 Span에 변화를 주지 X
//count += sizeof(ushort);
//Array.Copy(Encoding.Unicode.GetBytes(this.name), 0, openSegment.Array, count, nameLen); // GetBytes()는 string을 받아 Byte 배열로 변환
//count += nameLen;
// string 처리 #2 (Buffer에 2Byte인 string len을 위한 공간을 남겨둔 채로 string data를 먼저 삽입 후 string len 삽입)
ushort nameLen = (ushort)Encoding.Unicode.GetBytes(this.name, 0, name.Length, openSegment.Array, openSegment.Offset + count + sizeof(ushort)); // Buffer에 string data를 삽입함과 동시에 string len을 반환
success &= BitConverter.TryWriteBytes(span.Slice(count, span.Length - count), nameLen);
count += sizeof(ushort);
count += nameLen;
success &= BitConverter.TryWriteBytes(span, count); // size는 모든 작업이 끝난 뒤 초기화
if (success == false)
return null;
return SendBufferHelper.Close(count);
}
public override void Read(ArraySegment<byte> openSegment)
{
ushort count = 0;
ReadOnlySpan<byte> span = new ReadOnlySpan<byte>(openSegment.Array, openSegment.Offset, openSegment.Count);
count += sizeof(ushort);
count += sizeof(ushort);
this.playerId = BitConverter.ToInt64(span.Slice(count, span.Length - count)); // Slice는 실질적으로 Span에 변화를 주지 X
count += sizeof(long);
// string 처리
ushort nameLen = BitConverter.ToUInt16(span.Slice(count, span.Length - count));
count += sizeof(ushort);
this.name = Encoding.Unicode.GetString(span.Slice(count, nameLen)); // GetString()는 Byte 배열을 받아 string으로 변환
count += nameLen;
}
}
// ...
}
# Serialization #4
- 데이터의 길이가 가변적인 List는 어떻게 처리해야 할까?
List 처리를 위한 ServerSession Class 와 ClientSession Class 수정 (아래 코드는 ServerSession Class지만 ClientSession Class도 동일한 코드로 수정)
using ServerCore;
using System;
using System.Collections.Generic;
using System.Net;
using System.Net.Sockets;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
namespace DummyClient
{
public abstract class Packet
{
public ushort size;
public ushort packetId;
public abstract ArraySegment<byte> Write();
public abstract void Read(ArraySegment<byte> openSegment);
}
class PlayerInfoReq : Packet // Client에서 Server로 Player의 정보를 알고 싶다고 요청하는 것
{
public long playerId;
public string name;
public struct SkillInfo
{
public int id;
public short level;
public float duration;
public bool Write(Span<byte> span, ref ushort count) // span은 전체 Byte 배열을, count는 실시간으로 현재 어느 곳을 작업하는지
{
bool success = true;
success &= BitConverter.TryWriteBytes(span.Slice(count, span.Length - count), id);
count += sizeof(int);
success &= BitConverter.TryWriteBytes(span.Slice(count, span.Length - count), level);
count += sizeof(short);
success &= BitConverter.TryWriteBytes(span.Slice(count, span.Length - count), duration);
count += sizeof(float);
return success;
}
public void Read(ReadOnlySpan<byte> span, ref ushort count)
{
id = BitConverter.ToInt32(span.Slice(count, span.Length - count));
count += sizeof(int);
level = BitConverter.ToInt16(span.Slice(count, span.Length - count));
count += sizeof(short);
duration = BitConverter.ToSingle(span.Slice(count, span.Length - count));
count += sizeof(float);
}
}
public List<SkillInfo> skills = new List<SkillInfo>(); // 가변 길이의 멤버 변수는 어떻게 처리?
// 생성자
public PlayerInfoReq()
{
this.packetId = (ushort)PacketID.PlayerInfoReq;
}
public override ArraySegment<byte> Write()
{
ArraySegment<byte> openSegment = SendBufferHelper.Open(4096);
bool success = true;
ushort count = 0;
Span<byte> span = new Span<byte>(openSegment.Array, openSegment.Offset, openSegment.Count);
count += sizeof(ushort);
success &= BitConverter.TryWriteBytes(span.Slice(count, span.Length - count), this.packetId);
count += sizeof(ushort);
success &= BitConverter.TryWriteBytes(span.Slice(count, span.Length - count), this.playerId);
count += sizeof(long);
// string 처리
ushort nameLen = (ushort)Encoding.Unicode.GetBytes(this.name, 0, name.Length, openSegment.Array, openSegment.Offset + count + sizeof(ushort)); // Buffer에 string data를 삽입함과 동시에 string len을 반환
success &= BitConverter.TryWriteBytes(span.Slice(count, span.Length - count), nameLen);
count += sizeof(ushort);
count += nameLen;
// list 처리
success &= BitConverter.TryWriteBytes(span.Slice(count, span.Length - count), (ushort)skills.Count);
count += sizeof(ushort);
foreach (SkillInfo skill in skills)
success &= skill.Write(span, ref count);
success &= BitConverter.TryWriteBytes(span, count); // size는 모든 작업이 끝난 뒤 초기화
if (success == false)
return null;
return SendBufferHelper.Close(count);
}
public override void Read(ArraySegment<byte> openSegment)
{
ushort count = 0;
ReadOnlySpan<byte> span = new ReadOnlySpan<byte>(openSegment.Array, openSegment.Offset, openSegment.Count);
count += sizeof(ushort);
count += sizeof(ushort);
this.playerId = BitConverter.ToInt64(span.Slice(count, span.Length - count));
count += sizeof(long);
// string 처리
ushort nameLen = BitConverter.ToUInt16(span.Slice(count, span.Length - count));
count += sizeof(ushort);
this.name = Encoding.Unicode.GetString(span.Slice(count, nameLen));
count += nameLen;
// list 처리
skills.Clear();
ushort skillLen = BitConverter.ToUInt16(span.Slice(count, span.Length - count));
count += sizeof(ushort);
for (int i = 0; i < skillLen; i++)
{
SkillInfo skill = new SkillInfo();
skill.Read(span, ref count);
skills.Add(skill);
}
}
}
// ...
}
# Packet Generator #1
-[ 솔루션 ] - [ 오른쪽 마우스 ] - [ 추가 ] - [ 새 솔루션 폴더 ] 를 통해 폴더를 추가할 수 있다.
- [ 프로젝트 ] - [ 오른쪽 마우스 ] - [ 추가 ] - [ 새 항목 ] 의 [ C# 항목 ] - [ 데이터 ] 에서 XML 파일을 추가할 수 있다.
~> 생성된 XML 파일은 [프로젝트 ] - [ 오른쪽 마우스 ] - [ 파일 탐색기에서 폴더 열기 ] - [ bin ] - [ Debug ] - [ net ] 안에
위치하도록 한다. (즉, 해당 프로젝트의 실행파일이 있는 곳에 위치하도록)
- 패킷의 정의를 어떤 방식으로 할지 결정해야 한다. (JSON, XML, 자체 정의 IDL)
~> XML이 JSON에 비해 Hierarchy가 잘 보인다는 장점을 가지므로 XML을 사용할 것이다.
~> XML에서 정보는 시작 Tag와 끝 Tag 사이에 담겨지며, Tag는 쌍을 이룬다.
(Tag 사이에 삽입할 정보가 없는 경우 시작 Tag 끝에 /를 추가하여 끝 Tag 생략 가능)
-Parsing은 컴퓨터 과학 및 프로그래밍에서 특정 형식으로 구성된 데이터를 분석하고 그 의미를 이해하는 과정을 의미
- Parsing은 주로 텍스트 기반 데이터를 해석하거나, 프로그래밍 언어의 소스 코드를 이해하거나, 문서를 구조화하고
내용을 추출하는 데 사용
# Packet Generator #2
- 지난시간 제외한 List 부분을 자동화하기 위한 Template을 만들고, Packet Generator가 잘 실행되는지 확인하고자 한다.
~> Packet Generator의 결과를 "GenPackets.cs" 파일에 저장하였다.
~> "GenPackets.cs" 파일은 [프로젝트 ] - [ 오른쪽 마우스 ]- [ 파일 탐색기에서 폴더 열기 ] - [ bin ] - [ Debug ] - [ net ]
에서 확인할 수 있다.
PacketFormat Class 수정
using System;
using System.Collections.Generic;
using System.Text;
namespace PacketGenerator
{
class PacketFormat
{
// ...
// {0} 리스트 이름 [대문자]
// {1} 리스트 이름 [소문자]
// {2} 멤버 변수들
// {3} 멤버 변수 Read
// {4} 멤버 변수 Write
public static string memberListFormat =
@"
public struct {0}
{{
{2}
public void Read(ReadOnlySpan<byte> span, ref ushort count)
{{
{3}
}}
public bool Write(Span<byte> span, ref ushort count)
{{
bool success = true;
{4}
return success;
}}
}}
public List<{0}> {1}s = new List<{0}>();
";
// ...
// {0} 리스트 이름 [대문자]
// {1} 리스트 이름 [소문자]
public static string readListFormat =
@"
this.{1}s.Clear();
ushort {1}Len = BitConverter.ToUInt16(span.Slice(count, span.Length - count));
count += sizeof(ushort);
for (int i = 0; i < {1}Len; i++)
{{
{0} {1} = new {0}();
{1}.Read(span, ref count);
{1}s.Add({1});
}}
";
// ...
// {0} 리스트 이름 [대문자]
// {1} 리스트 이름 [소문자]
public static string writeListFormat =
@"
success &= BitConverter.TryWriteBytes(span.Slice(count, span.Length - count), (ushort)this.{1}s.Count);
count += sizeof(ushort);
foreach ({0} {1} in this.{1}s)
success &= {1}.Write(span, ref count);
";
}
}
PacketGenerator 코드 수정
using System.Xml;
namespace PacketGenerator
{
internal class Program
{
static string genPackets; // 실시간으로 만들어지는 패킷
static void Main(string[] args)
{
XmlReaderSettings settings = new XmlReaderSettings()
{
IgnoreComments= true, // 주석을 무시
IgnoreWhitespace = true // 공백을 무시
};
// XML 파싱
using (XmlReader reader = XmlReader.Create("PDL.xml", settings))
{
reader.MoveToContent(); // header를 건너뛰고 내용 부분으로 이동
while (reader.Read()) // 한줄씩 읽어나간다.
{
if (reader.Depth == 1 && reader.NodeType == XmlNodeType.Element) // Element는 시작 부분, EndElement는 끝 부분
ParsePacket(reader);
// Console.WriteLine(reader.Name + " " + reader["name"]); // Name은 Type을 반환, []는 Attribute를 반환
}
}
File.WriteAllText("GenPackets.cs", genPackets); // genPackets의 내용을 통해 GenPackets.cs 파일 생성
}
public static void ParsePacket(XmlReader reader)
{
if (reader.NodeType == XmlNodeType.EndElement)
return;
if (reader.Name.ToLower() != "packet")
{
Console.WriteLine("Invalid packet node");
return;
}
string packetName = reader["name"];
if (string.IsNullOrEmpty(packetName) )
{
Console.WriteLine("Packet without name");
return;
}
Tuple<string, string, string> tuple = ParseMembers(reader);
genPackets += string.Format(PacketFormat.packetFormat,
packetName, tuple.Item1, tuple.Item2, tuple.Item3);
}
// 멤버 변수들, 멤버 변수 Read, 멤버 변수 Write 에 관한 코드를 알맞게 제작한 뒤 이를 string으로 반환
public static Tuple<string, string, string> ParseMembers(XmlReader reader)
{
string packetName = reader["name"];
string memberCode = "";
string readCode = "";
string writeCode = "";
int depth = reader.Depth + 1;
while(reader.Read())
{
if (reader.Depth != depth)
break;
string memberName = reader["name"];
if (string.IsNullOrEmpty(memberName) )
{
Console.WriteLine("Member without name");
return null;
}
if (string.IsNullOrEmpty(memberCode) == false) // 이미 내용이 존재하는 경우
memberCode += Environment.NewLine; // Enter를 치는 것과 같은 동작
if(string.IsNullOrEmpty(readCode) == false) // 이미 내용이 존재하는 경우
readCode += Environment.NewLine; // Enter를 치는 것과 같은 동작
if(string.IsNullOrEmpty(writeCode) == false) // 이미 내용이 존재하는 경우
writeCode += Environment.NewLine; // Enter를 치는 것과 같은 동작
string memberType = reader.Name.ToLower();
switch (memberType)
{
case "bool":
case "short":
case "ushort":
case "int":
case "long":
case "float":
case "double":
memberCode += string.Format(PacketFormat.memberFormat, memberType, memberName);
readCode += string.Format(PacketFormat.readFormat, memberName, ToMemberType(memberType), memberType);
writeCode += string.Format(PacketFormat.writeFormat, memberName, memberType);
break;
case "string":
memberCode += string.Format(PacketFormat.memberFormat, memberType, memberName);
readCode += string.Format(PacketFormat.readStringFormat, memberName);
writeCode += string.Format(PacketFormat.writeStringFormat, memberName);
break;
case "list":
Tuple<string, string, string> tuple = ParseList(reader);
memberCode += tuple.Item1;
readCode += tuple.Item2;
writeCode += tuple.Item3;
break;
default:
break;
}
}
// 가독성을 위해 Text를 정렬
memberCode = memberCode.Replace("\n", "\n\t"); // Enter가 입력된 곳은 Enter 입력 후 Tab 까지 입력되도록 수정
readCode = readCode.Replace("\n", "\n\t\t"); // Enter가 입력된 곳은 Enter 입력 후 Tab Tab 까지 입력되도록 수정
writeCode = writeCode.Replace("\n", "\n\t\t"); // Enter가 입력된 곳은 Enter 입력 후 Tab Tab 까지 입력되도록 수정
return new Tuple<string, string, string>(memberCode, readCode, writeCode);
}
public static Tuple<string, string, string> ParseList(XmlReader reader)
{
string listName = reader["name"];
if (string.IsNullOrEmpty(listName))
{
Console.WriteLine("List without name");
return null;
}
// memberListFormat의 {2}, {3}, {4}는 순서대로 멤버 변수들, 멤버 변수 Read, 멤버 변수 Write 이므로 ParseMembers() 함수 사용
Tuple<string, string, string> tuple = ParseMembers(reader);
string memberCode = string.Format(PacketFormat.memberListFormat,
FirstCharToUpper(listName), FirstCharToLower(listName),
tuple.Item1, tuple.Item2, tuple.Item3);
string readCode = string.Format(PacketFormat.readListFormat,
FirstCharToUpper(listName), FirstCharToLower(listName));
string writeCode = string.Format(PacketFormat.writeListFormat,
FirstCharToUpper(listName), FirstCharToLower(listName));
return new Tuple <string, string, string> (memberCode, readCode, writeCode);
}
public static string FirstCharToUpper(string input)
{
if (string.IsNullOrEmpty(input))
return "";
return input[0].ToString().ToUpper() + input.Substring(1);
}
public static string FirstCharToLower(string input)
{
if (string.IsNullOrEmpty(input))
return "";
return input[0].ToString().ToLower() + input.Substring(1);
}
public static string ToMemberType(string memberType)
{
switch (memberType)
{
case "bool":
return "ToBoolean";
case "short":
return "ToInt16";
case "ushort":
return "ToUInt16";
case "int":
return "ToInt32";
case "long":
return "ToInt64";
case "float":
return "ToSingle";
case "double":
return "ToDouble";
default:
return "";
}
}
}
}
# Packet Generator #3
- using 과 enum 및 byte 부분을 자동화하기 위한 Template을 만들고, 패킷이 여러개 존재해도 자동화가 잘 되는지
using System;
using System.Collections.Generic;
using System.Text;
namespace PacketGenerator
{
class PacketFormat
{
// {0} 패킷 이름/번호 목록
// {1} 패킷 목록
public static string fileFormat =
@"
using System;
using System.Collections.Generic;
using System.Text;
using System.Net;
using ServerCore;
public enum PacketID
{{
{0}
}}
{1}
";
// {0} 패킷 이름
// {1} 패킷 번호
public static string packetEnumFormat =
@"{0} = {1},";
// ...
// {0} 변수 이름
// {1} 변수 형식
public static string readByteFormat =
@"
this.{0} = ({1})openSegment.Array[openSegment.Offset + count];
count += sizeof({1});
";
// ...
// {0} 변수 이름
// {1} 변수 형식
public static string writeByteFormat =
@"
openSegment.Array[openSegment.Offset + count] = (byte)this.{0};
count += sizeof({1});
";
// ...
}
}
PacketGenerator 코드 수정
using System.Xml;
namespace PacketGenerator
{
internal class Program
{
// ...
static void Main(string[] args)
{
// ...
static ushort packetId; // 몇개의 패킷을 처리하였는지
static string packetEnums; // Parsing 처리된 패킷의 이름과 번호
// fileFormat을 통해 using과 enum 추가
string fileText = string.Format(PacketFormat.fileFormat, packetEnums, genPackets);
File.WriteAllText("GenPackets.cs", fileText);
}
public static void ParsePacket(XmlReader reader)
{
// ...
Tuple<string, string, string> tuple = ParseMembers(reader);
genPackets += string.Format(PacketFormat.packetFormat,
packetName, tuple.Item1, tuple.Item2, tuple.Item3);
// 패킷 1개를 Parsing 할때마다 packetEnums에 Parsing 처리된 패킷의 이름과 번호를 저장
packetEnums += string.Format(PacketFormat.packetEnumFormat, packetName, ++packetId) + Environment.NewLine + "\t";
}
// 멤버 변수들, 멤버 변수 Read, 멤버 변수 Write 에 관한 코드를 알맞게 제작한 뒤 이를 string으로 반환
public static Tuple<string, string, string> ParseMembers(XmlReader reader)
{
string packetName = reader["name"];
string memberCode = "";
string readCode = "";
string writeCode = "";
int depth = reader.Depth + 1;
while(reader.Read())
{
// ...
if (string.IsNullOrEmpty(memberCode) == false) // 이미 내용이 존재하는 경우
memberCode += Environment.NewLine; // Enter를 치는 것과 같은 동작
if(string.IsNullOrEmpty(readCode) == false) // 이미 내용이 존재하는 경우
readCode += Environment.NewLine; // Enter를 치는 것과 같은 동작
if(string.IsNullOrEmpty(writeCode) == false) // 이미 내용이 존재하는 경우
writeCode += Environment.NewLine; // Enter를 치는 것과 같은 동작
string memberType = reader.Name.ToLower();
switch (memberType)
{
case "byte":
case "sbyte":
memberCode += string.Format(PacketFormat.memberFormat, memberType, memberName);
readCode += string.Format(PacketFormat.readByteFormat, memberName, memberType);
writeCode += string.Format(PacketFormat.writeByteFormat, memberName, memberType);
break;
case "bool":
case "short":
case "ushort":
case "int":
case "long":
case "float":
case "double":
memberCode += string.Format(PacketFormat.memberFormat, memberType, memberName);
readCode += string.Format(PacketFormat.readFormat, memberName, ToMemberType(memberType), memberType);
writeCode += string.Format(PacketFormat.writeFormat, memberName, memberType);
break;
case "string":
memberCode += string.Format(PacketFormat.memberFormat, memberType, memberName);
readCode += string.Format(PacketFormat.readStringFormat, memberName);
writeCode += string.Format(PacketFormat.writeStringFormat, memberName);
break;
case "list":
Tuple<string, string, string> tuple = ParseList(reader);
memberCode += tuple.Item1;
readCode += tuple.Item2;
writeCode += tuple.Item3;
break;
default:
break;
}
}
// ...
}
// ...
}
}
# Packet Generator #4
- Packet Generator의 결과가 저장된 "GenPackets.cs" 파일의 내용을 수동으로 ServerSession 과 ClientSession class에
추가하였는데 해당 부분을 자동화하기 위한 Template을 만들고자 한다.
- 출력 경로는 [프로젝트 ] - [ 오른쪽 마우스 ] - [ 속성 ] - [ 빌드 ] - [ 일반 ] 의 [ 출력 ] 에서 설정 가능하다.
~> 그 후 [프로젝트 ] - [ 오른쪽 마우스 ] -[ 파일 탐색기에서 폴더 열기 ] 에서
"프로젝트이름.csproj" 파일을 메모장으로 연 뒤 <PropertyGroup></PropertyGroup> 사이에
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath> 를 추가하고,
<BaseOutputPath>출력 경로</BaseOutputPath> 를 <OutputPath>출력 경로</OutputPath> 로 수정한다.
~> 모든 설정이 끝난 뒤 [ 프로젝트 ] - [ 오른쪽 마우스 ] - [ 빌드 ] 시 설정한 출력 경로에 실행파일이 생성된다.
- 배치 파일은 명령 인터프리터에 의해 실행되게끔 공안된 명령어들이 나열된 텍스트 파일이다.
~> .bat 또는 .cmd 형식의 확장자 파일을 직접 실행하거나 명령 프롬프트에서 배치 파일의 이름으로 실행할 수 있다.
~> 배치파일을 통해[프로젝트 ] - [ 오른쪽 마우스 ] -[ 파일 탐색기에서 폴더 열기 ] - [ bin ] 내의 프로젝트 실행파일을
대신 실행한 뒤 실행 파일의 결과에 저장된 내용을 자동으로 복사할 예정이다. (출력 경로를 /bin 으로 설정)
~> START는 응용 프로그램 실행 명령어로 실행하고자 하는 파일의 경로와 인자를 넘겨준다.
~> XCOPY는 파일 복사 명령어로 복사 대상과 복사 위치를 넘겨준다.
(/Y 옵션은 같은 파일이 있는 경우 무조건 덮어쓴다는 것이다.)
GenPackets.bat 배치 파일 생성 (배치 파일은 [ 솔루션 ] - [ Common ] - [ Packet ] 내에 존재)
(배치 파일 실행시 PDL.xml을 인자로 넘긴 Packet Generator가 실행되어 DummyClient/Packet 과 Server/Packet 산하의 GenPackets에 Packet Generator의 결과가 저장된 "GenPackets.cs" 파일의 내용이 자동으로 복사된다.)
using System.Xml;
namespace PacketGenerator
{
internal class Program
{
// ...
static void Main(string[] args)
{
string pdlPath = "../PDL.xml"; // bin 폴더가 아닌 PacketGenerator 폴더에 있는 PDL.xml 찾기 위한 것 (실행 파일이 위치한 곳 기준으로 이동)
// ...
if (args.Length >= 1) // 프로그램 실행시 인자로 무언가를 넘겨준 경우
pdlPath = args[0]; // pdlPath를 전달받은 인자로 초기화
using (XmlReader reader = XmlReader.Create(pdlPath, settings))
{
// ...
}
// ...
}
// ...
}
}
# Packet Generator #5
- 게임 규모가 커질수록 패킷의 종류는 상당히 많아진다.
~> 즉, ClientSession Class의 OnRecvPacket() 함수 안의 switch-case문 길이가 상당히 길어질 수 있다.
- switch-case문 및 OnRecvPacket() 함수 자동화를 위해 모든 패킷들이 base interface를 상속받도록 한다.
~> 모든 패킷에 대해 공통 인수로 넘길 수 있기 때문에 편리하다는 장점을 가진다.
PacketFormat Class 수정
using System;
using System.Collections.Generic;
using System.Text;
namespace PacketGenerator
{
class PacketFormat
{
// {0} 패킷 이름/번호 목록
// {1} 패킷 목록
public static string fileFormat =
@"
using System;
using System.Collections.Generic;
using System.Text;
using System.Net;
using ServerCore;
public enum PacketID
{{
{0}
}}
interface IPacket
{{
ushort Protocol {{ get; }}
void Read(ArraySegment<byte> openSegment);
ArraySegment<byte> Write();
}}
{1}
";
// ...
// {0} 패킷 이름
// {1} 멤버 변수들
// {2} 멤버 변수 Read
// {3} 멤버 변수 Write
public static string packetFormat =
@"
public class {0} : IPacket
{{
{1}
public ushort Protocol {{ get {{ return (ushort)PacketID.{0}; }} }}
public void Read(ArraySegment<byte> openSegment)
{{
ushort count = 0;
ReadOnlySpan<byte> span = new ReadOnlySpan<byte>(openSegment.Array, openSegment.Offset, openSegment.Count);
count += sizeof(ushort);
count += sizeof(ushort);
{2}
}}
public ArraySegment<byte> Write()
{{
ArraySegment<byte> openSegment = SendBufferHelper.Open(4096);
bool success = true;
ushort count = 0;
Span<byte> span = new Span<byte>(openSegment.Array, openSegment.Offset, openSegment.Count);
count += sizeof(ushort);
success &= BitConverter.TryWriteBytes(span.Slice(count, span.Length - count), (ushort)PacketID.{0});
count += sizeof(ushort);
{3}
success &= BitConverter.TryWriteBytes(span, count);
if (success == false)
return null;
return SendBufferHelper.Close(count);
}}
}}
";
// ...
}
}
DummyClient/Packet 과 Server/Packet 산하에 PacketHandler Class 생성
using ServerCore;
using System;
using System.Collections.Generic;
using System.Text;
namespace Server
{
class PacketHandler
{
// 해당 패킷이 전부 조립된 경우 무엇을 할까?
// PacketHandler는 자동화 없이 수동으로 추가
public static void PlayerInfoReqHandler(PacketSession session, IPacket packet)
{
PlayerInfoReq p = packet as PlayerInfoReq;
Console.WriteLine($"PlayerInfoReq: {p.playerId} {p.name}");
foreach (PlayerInfoReq.Skill skill in p.skills)
{
Console.WriteLine($"Skill({skill.id})({skill.level})({skill.duration})");
}
}
}
}
DummyClient/Packet 과 Server/Packet 산하에 PacketManager Class 생성
using ServerCore;
using System;
using System.Collections.Generic;
using System.Text;
namespace Server
{
class PacketManager
{
// PacketManager는 Singleton 패턴 사용
#region Singleton
static PacketManager _instance;
public static PacketManager Instance
{
get {
if (_instance == null)
_instance = new PacketManager();
return _instance;
}
}
#endregion
// 구분하기 위한 Protocol ID, 어떤 작업을 수행할지
Dictionary<ushort, Action<PacketSession, ArraySegment<byte>>> _onRecv = new Dictionary<ushort, Action<PacketSession, ArraySegment<byte>>>();
// 구분하기 위한 Protocol ID, 어떤 Handler를 호출할지
Dictionary<ushort, Action<PacketSession, IPacket>> _handler = new Dictionary<ushort, Action<PacketSession, IPacket>>();
public void Register() // 추후 자동화할 예정
{
_onRecv.Add((ushort)PacketID.PlayerInfoReq, MakePacket<PlayerInfoReq>);
_handler.Add((ushort)PacketID.PlayerInfoReq, PacketHandler.PlayerInfoReqHandler);
}
public void OnRecvPacket(PacketSession session, ArraySegment<byte> buffer)
{
// 패킷을 분해하여 id 에 대한 정보를 얻은 뒤
ushort count = 0;
ushort size = BitConverter.ToUInt16(buffer.Array, buffer.Offset + count);
count += 2;
ushort id = BitConverter.ToUInt16(buffer.Array, buffer.Offset + count);
count += 2;
// 이젠 switch-case문이 아닌 Dictionary에서 찾아 Invoke()
Action<PacketSession, ArraySegment<byte>> action = null;
if (_onRecv.TryGetValue(id, out action))
action.Invoke(session, buffer);
}
void MakePacket<T>(PacketSession session, ArraySegment<byte> buffer) where T : IPacket, new()
{
T packet = new T();
packet.Read(buffer); // 역직렬화
// Dictionary에서 찾아 Invoke()
Action<PacketSession, IPacket> action = null;
if (_handler.TryGetValue(packet.Protocol, out action))
action.Invoke(session, packet);
}
}
}
ClientSession Class 수정
using ServerCore;
using System;
using System.Collections.Generic;
using System.Net;
using System.Net.Sockets;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
namespace Server
{
class ClientSession : PacketSession
{
// ...
public override void OnRecvPacket(ArraySegment<byte> buffer)
{
PacketManager.Instance.OnRecvPacket(this, buffer);
}
// ...
}
}
Server 코드 수정
using System;
using System.Text;
using System.Net;
using System.Net.Sockets;
using System.Threading;
using System.Threading.Tasks;
using ServerCore;
namespace Server
{
class Program
{
// ...
static void Main(string[] args)
{
// MultiThread가 개입하지 않는 부분에서 실행
PacketManager.Instance.Register();
// ...
}
}
}
# Packet Generator #6
- PacketManager 를 자동화하기 위한 Template을 만들고자 한다.
~> 양방향 패킷은 거의 존재하지 않는다. (대부분 Client에서 Server 또는 Server에서 Client)
~> 그러나 분산 Server인 경우 Client와 Server만 소통하는 것이 아닌 Server 끼리도 소통한다.
~> 패킷 이름에 규칙을 설정하고 이를 통해 파일을 분리한다.
(패킷 이름 앞에 C_ 가 붙은 것은 Client에서 Server로, S_ 가 붙은 것은 Server에서 Client로)
(PacketManager 를 자동화할때 Server 쪽에 추가되는 PacketManager의 Register에는 C_가 붙은 것들만, DummyClient 쪽에 추가되는 PacketManager의 Register에는 S_가 붙은 것들만 등록한다.)
using System;
using System.Text;
using System.Net;
using System.Net.Sockets;
namespace ServerCore
{
// ...
public abstract class Session
{
// ...
void Clear() // _sendQueue 와 _pendingList 를 초기화하기 위한 함수 추가
{
lock (_lock)
{
_sendQueue.Clear();
_pendingList.Clear();
}
}
// ...
public void Disconnect()
{
// ...
Clear(); // _sendQueue 와 _pendingList 를 초기화
}
void RegisterSend()
{
if (_disconnected == 1) // 최소한의 예방책
return;
while (_sendQueue.Count > 0)
{
ArraySegment<byte> buff = _sendQueue.Dequeue();
_pendingList.Add(buff);
}
_sendArgs.BufferList = _pendingList;
// socket을 다루는 부분을 try-catch문으로 감싸준다. (MultiThread 환경을 위한 예방책)
// ~> 누군가는 위의 if문을 통과하여 아래 부분을 마저 실행하려고 하는 도중에 다른 Thread에서 socket을 disconnect시 문제가 발생하기 때문
try
{
bool pending = _socket.SendAsync(_sendArgs);
if (pending == false)
OnSendCompleted(null, _sendArgs);
}
catch (Exception e)
{
Console.WriteLine($"RegisterSend Failed {e}");
}
}
// ...
void RegisterRecv()
{
if (_disconnected == 1) // 최소한의 예방책
return;
_recvBuffer.Clean();
ArraySegment<byte> segment = _recvBuffer.WriteSegment;
_recvArgs.SetBuffer(segment.Array, segment.Offset, segment.Count);
// socket을 다루는 부분을 try-catch문으로 감싸준다. (MultiThread 환경을 위한 예방책)
// ~> 누군가는 위의 if문을 통과하여 아래 부분을 마저 실행하려고 하는 도중에 다른 Thread에서 socket을 disconnect시 문제가 발생하기 때문
try
{
bool pending = _socket.ReceiveAsync(_recvArgs);
if (pending == false)
OnRecvCompleted(null, _recvArgs);
}
catch(Exception e)
{
Console.WriteLine($"RegisterRecv Failed {e}");
}
}
// ...
}
}
Server에 GameRoom Class 생성
using System;
using System.Collections.Generic;
using System.Text;
namespace Server
{
class GameRoom
{
List<ClientSession> _sessions = new List<ClientSession>(); // GameRoom에 존재하는 session들
object _lock = new object(); // List나 Dictionary 등 대부분의 자료 구조들은 MultiThread 환경에서 잘 돌아간다는 보장이 없기 때문에 lock 생성
public void Broadcast(ClientSession session, string chat) // 현재 session이 접속중인 방에 존재하는 모두에게 chat을 뿌린다.
{
S_Chat packet = new S_Chat();
packet.playerId = session.SessionId;
packet.chat = chat;
ArraySegment<byte> segment = packet.Write();
lock(_lock)
{
foreach (ClientSession s in _sessions)
s.Send(segment);
}
}
public void Enter(ClientSession session) // 방 입장
{
lock (_lock)
{
_sessions.Add(session);
session.Room = this;
}
}
public void Leave(ClientSession session) // 방 퇴장
{
lock (_lock)
{
_sessions.Remove(session);
}
}
}
}
Server에 SessionManager Class 생성
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace Server
{
class SessionManager // SessionManager는 Engine 쪽에서 관리해도 되고, Content 쪽에서 관리해도 된다. (선택의 차이)
{
// SessionManager는 Singleton 패턴 사용
static SessionManager _session = new SessionManager();
public static SessionManager Instance { get { return _session; } }
int _sessionId = 0; // session을 구분하기 위한 Id
Dictionary<int, ClientSession> _sessions = new Dictionary<int, ClientSession>(); // 현재 존재하는 session들
object _lock = new object(); // List나 Dictionary 등 대부분의 자료 구조들은 MultiThread 환경에서 잘 돌아간다는 보장이 없기 때문에 lock 생성
public ClientSession Generate() // session 생성
{
lock (_lock)
{
int sessionId = ++_sessionId;
ClientSession session = new ClientSession();
session.SessionId = sessionId;
_sessions.Add(sessionId, session);
Console.WriteLine($"Connected : {sessionId}");
return session;
}
}
public ClientSession Find(int id) // sessionId를 통해 session을 찾는 함수
{
lock (_lock)
{
ClientSession session = null;
_sessions.TryGetValue(id, out session);
return session;
}
}
public void Remove(ClientSession session) // session 삭제
{
lock (_lock)
{
_sessions.Remove(session.SessionId);
}
}
}
}
Server 코드 수정
using System;
using System.Text;
using System.Net;
using System.Net.Sockets;
using System.Threading;
using System.Threading.Tasks;
using ServerCore;
namespace Server
{
class Program
{
static Listener _listener = new Listener();
public static GameRoom Room = new GameRoom(); // GameRoom 생성 (딴 곳에서도 접근이 가능하도록 public으로 생성)
static void Main(string[] args)
{
PacketManager.Instance.Register();
string host = Dns.GetHostName();
IPHostEntry ipHost = Dns.GetHostEntry(host);
IPAddress ipAddr = ipHost.AddressList[0];
IPEndPoint endPoint = new IPEndPoint(ipAddr, 7777);
// 손님을 입장시킨다.
_listener.Init(endPoint, () => { return SessionManager.Instance.Generate(); }); // new를 통해 Session을 생성하는 것이 아닌 SessionManager를 통해 생성하도록 수정
Console.WriteLine("Listening...");
while (true)
{
}
}
}
}
ClientSession 수정
using ServerCore;
using System;
using System.Collections.Generic;
using System.Net;
using System.Net.Sockets;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
namespace Server
{
class ClientSession : PacketSession
{
public int SessionId { get; set; } // Session 구분을 위해
public GameRoom Room { get; set; } // 현재 어떤 방에 위치하는지 알기 위해
public override void OnConnected(EndPoint endPoint)
{
Console.WriteLine($"OnConnected : {endPoint}");
Program.Room.Enter(this); // Client가 접속시 방에 입장시킨다. (Program 산하에 static으로 Room을 생성하였기 때문에 다음과 같이 호출)
Thread.Sleep(5000);
Disconnect();
}
// ...
public override void OnDisconnected(EndPoint endPoint)
{
SessionManager.Instance.Remove(this); // 내 자신을 (즉, session을) sessionManager를 통해 삭제 요청
if (Room != null) //
{
Room.Leave(this);
Room = null;
}
Console.WriteLine($"OnDisconnected : {endPoint}");
}
// ...
}
}
Server의 PacketHandler 수정
using Server;
using ServerCore;
using System;
using System.Collections.Generic;
using System.Text;
class PacketHandler
{
// 해당 패킷이 전부 조립된 경우 무엇을 할까?
// PacketHandler는 자동화 없이 수동으로 추가
public static void C_ChatHandler(PacketSession session, IPacket packet)
{
C_Chat chatPacket = packet as C_Chat;
ClientSession clientSession = session as ClientSession;
if (clientSession.Room == null)
return;
clientSession.Room.Broadcast(clientSession, chatPacket.chat); // 현재 clientSession이 접속중인 방에 존재하는 모두에게 채팅 메시지를 뿌린다.
}
}
# 채팅 테스트 #2
- 채팅 테스트를 위해Client 입장에서코드 추가 및 수정을 할 예정이다.
~> 현재 1명의 유저만 접속하고 있는 상황이므로, 이를 다수의 유저들이 접속하는 상황으로 가정하고 변경할 예정이다.
- 기존 Server의 Register 부분을 생성자를 통해 자동으로 생성하도록 변경할 예정이다.
ServerSession Class 수정
using ServerCore;
using System;
using System.Collections.Generic;
using System.Net;
using System.Net.Sockets;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
namespace DummyClient
{
class ServerSession : PacketSession // Session이 아닌 PacketSession 을 상속 받도록 수정
{
public override void OnConnected(EndPoint endPoint)
{
Console.WriteLine($"OnConnected : {endPoint}");
}
public override void OnDisconnected(EndPoint endPoint)
{
Console.WriteLine($"OnDisconnected : {endPoint}");
}
public override void OnRecvPacket(ArraySegment<byte> buffer) // PacketSession 을 상속 받으므로 OnRecv가 아닌 OnRecvPacket으로 수정 (반환값도 int가 아닌 void로)
{
PacketManager.Instance.OnRecvPacket(this, buffer);
}
public override void OnSend(int numOfBytes)
{
// Console.WriteLine($"Transferred bytes : {numOfBytes}"); ~> session이 많아지면 OnSend() 가 자주 호출되므로 일단은 출력되지 않도록 주석 처리
}
}
}
ServerCore의 Connector 수정
using System;
using System.Collections.Generic;
using System.Net;
using System.Net.Sockets;
using System.Text;
namespace ServerCore
{
public class Connector
{
Func<Session> _sessionFactory;
public void Connect(IPEndPoint endPoint, Func<Session> sessionFactory, int count = 1) // 다수의 Client 환경에서 Test 하고 싶을 수도 있기 때문에
{
for (int i = 0; i < count; i++) // 입력받은 매개변수 count 만큼 아래의 과정을 반복하도록 수정
{
// 휴대폰 설정
Socket socket = new Socket(endPoint.AddressFamily, SocketType.Stream, ProtocolType.Tcp);
_sessionFactory = sessionFactory;
SocketAsyncEventArgs args = new SocketAsyncEventArgs();
args.Completed += OnConnectCompleted;
args.RemoteEndPoint = endPoint;
args.UserToken = socket;
RegisterConnect(args);
}
}
// ...
}
}
DummyClient에 SessionManager Class 생성
using System;
using System.Collections.Generic;
using System.Text;
namespace DummyClient
{
class SessionManager
{
// SessionManager는 Singleton 패턴 사용
static SessionManager _session = new SessionManager();
public static SessionManager Instance { get { return _session; } }
List<ServerSession> _sessions = new List<ServerSession>(); // 현재 존재하는 session들
object _lock = new object(); // List나 Dictionary 등 대부분의 자료 구조들은 MultiThread 환경에서 잘 돌아간다는 보장이 없기 때문에 lock 생성
public void SendForEach() // Server쪽으로 채팅 패킷을 전송
{
foreach (ServerSession session in _sessions)
{
C_Chat chatPacket = new C_Chat();
chatPacket.chat = $"Hello Server!";
ArraySegment<byte> segment = chatPacket.Write();
session.Send(segment);
}
}
public ServerSession Generate() // session 생성
{
lock (_lock)
{
ServerSession session = new ServerSession();
_sessions.Add(session);
return session;
}
}
}
}
DummyClient 코드 수정
using System;
using System.Collections.Generic;
using System.Net;
using System.Net.Sockets;
using System.Text;
using System.Threading;
using ServerCore;
namespace DummyClient
{
class Program
{
static void Main(string[] args)
{
string host = Dns.GetHostName();
IPHostEntry ipHost = Dns.GetHostEntry(host);
IPAddress ipAddr = ipHost.AddressList[0];
IPEndPoint endPoint = new IPEndPoint(ipAddr, 7777);
Connector connector = new Connector();
connector.Connect(endPoint, () => { return SessionManager.Instance.Generate(); }, 10); // new를 통해 Session을 생성하는 것이 아닌 SessionManager를 통해 생성하도록 수정, 원하는 Client 수를 인자로 넘김
while (true)
{
try
{
SessionManager.Instance.SendForEach(); // 모든 Session들이 Server쪽으로 계속해서 채팅 패킷을 쏘도록
}
catch (Exception e)
{
Console.WriteLine(e.ToString());
}
Thread.Sleep(250); // 0.25초 휴식
}
}
}
}
DummyClient의 PacketHandler 수정
using DummyClient;
using ServerCore;
using System;
using System.Collections.Generic;
using System.Text;
class PacketHandler
{
// C_Chat을 Server에 보낸뒤, Server는 방에 있는 모든 애들에게 S_Chat으로 답장을 주는 부분을 다룬다.
public static void S_ChatHandler(PacketSession session, IPacket packet)
{
S_Chat chatPacket = packet as S_Chat;
ServerSession serverSession = session as ServerSession;
Console.WriteLine(chatPacket.chat);
}
}
PacketFormat Class 수정 (Main에서 PacketManager.Instance.Register(); 를 직접 입력하는 것이 아닌 생성자를 통해 자동으로 생성되도록 수정)
using System;
using System.Collections.Generic;
using System.Text;
namespace PacketGenerator
{
class PacketFormat
{
// {0} 패킷 등록
public static string managerFormat =
@"
using ServerCore;
using System;
using System.Collections.Generic;
class PacketManager
{{
#region Singleton
static PacketManager _instance = new PacketManager();
public static PacketManager Instance {{ get {{ return _instance; }} }}
#endregion
PacketManager()
{{
Register();
}}
Dictionary<ushort, Action<PacketSession, ArraySegment<byte>>> _onRecv = new Dictionary<ushort, Action<PacketSession, ArraySegment<byte>>>();
Dictionary<ushort, Action<PacketSession, IPacket>> _handler = new Dictionary<ushort, Action<PacketSession, IPacket>>();
public void Register()
{{
{0}
}}
public void OnRecvPacket(PacketSession session, ArraySegment<byte> buffer)
{{
ushort count = 0;
ushort size = BitConverter.ToUInt16(buffer.Array, buffer.Offset + count);
count += 2;
ushort id = BitConverter.ToUInt16(buffer.Array, buffer.Offset + count);
count += 2;
Action<PacketSession, ArraySegment<byte>> action = null;
if (_onRecv.TryGetValue(id, out action))
action.Invoke(session, buffer);
}}
void MakePacket<T>(PacketSession session, ArraySegment<byte> buffer) where T : IPacket, new()
{{
T packet = new T();
packet.Read(buffer);
Action<PacketSession, IPacket> action = null;
if (_handler.TryGetValue(packet.Protocol, out action))
action.Invoke(session, packet);
}}
}}
";
// ...
}
}
- 위의 결과로 GameRoom 안에 존재하는 10명의 유저들에게 채팅 패킷을 뿌려주고 있다는 것을 알 수 있다.
~> 그러나 위와 같은 방식을 MMORPG에 도입할 경우 속도가 상당히 느려질 수 있다.
(유저의 수가 증가할수록, 패킷을 뿌리는 양이 많아지므로)
- 위의 Thread들은 모두 Broadcast의 lock 부분에서 대기중이다.
~> 이는 당연한 결과이다. 왜냐하면 Thread.Sleep(250) 을 통해 0.25초에 1번 동작하도록 설정하였으므로 만약 100명의
유저가 있다고 가정하면 100 * 100 = 10,000번이므로 1초에 40,000번 동작하고 있기 때문이다. (250 * 4 = 1000 = 1초)
이에 lock 부분에 동시다발적으로 수많은 Thread들이 들어오지만 lock 때문에 1번에 1개의 Thread만 처리할 수 있다.
~> 따라서 위의 수많은 작업들이 밀리게 되면서 Thread를 관리하는 입장에서는 Thread를 보냈으나 일 처리가 완료되지
않았기 때문에 다시 Thread를 보내고 있는 상황이 발생한다. 이에 Thread가 계속해서 쌓이게 되는 것이다.
- 이러한 문제가 발생하는 이유는 Recv를 하자마자, lock을 통해 패킷을 전송하였기 때문이다.
~> 해결 방법으로는 하나의 Thread만 Queue에 쌓여있는 일감을 처리하고, 다른 Thread들의 일감은 Queue에 담아두는
것이다. (이를 Job 또는 Task라고 하며, 중요한 것은 패킷을 저장하고 하나의 Thread에서 처리하는 것)
# Command 패턴
- 다음 시간에 만들어볼 Job 또는 Task를 관리하는 Queue가 전형적인 Command 패턴의 예제이다.
- 손님을 대리하는 ClientSession이 직원인 Thread에게 주문을 한다.
- 만약 직원이 서빙, 요리, 계산을 전부 도맡아 할 경우 주문을 받자마자 주방에 달려가 요리를 바로 시작할 것이다.
~> 지금까지 구현된 코드가 위와 비슷하다.
- 만약 주방의 크기가 너무 작아 동시에 1명만 요리가 가능한 경우 주문을 받은 직원들은 주방 앞에서 자신의 요리 차례가
올때까지 계속해서 기다리게 된다.
- 모든 직원들이 자신의 요리 차례를 기다리고 있기 때문에 주문을 받을 직원이 부족한 경우 식당은 직원을 더 고용한다.
~> 지금까지 구현된 코드의 결과가 위와 비슷하다.
- 위와 같이 1명의 직원이 서빙, 요리, 계산을 모두 담당하는 것이 아닌 직원들 각각이 업무를 분담받도록 한다.
public interface Command
{
public void execute();
}
ComputerOnCommand Class
public class ComputerOnCommand implements Command
{
private Computer computer;
public ComputerOnCommand(Computer computer)
{
this.computer = computer;
}
@Override
public void execute()
{
computer.turnOn();
}
}
ComputerOffCommand Class
public class ComputerOffCommand implements Command
{
private Computer computer;
public ComputerOffCommand(Computer computer)
{
this.computer = computer;
}
@Override
public void execute()
{
computer.turnOff();
}
}
Computer Class
public class Computer
{
public void Computer() {}
public void turnOn()
{
System.out.println("컴퓨터 전원 켜짐");
}
public void turnOff()
{
System.out.println("컴퓨터 전원 꺼짐");
}
}
Button Class
public class Button
{
private Command command;
public Button(Command command)
{
this.command = command;
}
public void setCommand(Command command)
{
this.command = command;
}
public void pressButton()
{
this.command.execute();
}
}
Main 메소드
public static void main(String[] args)
{
Computer computer = new Computer(); //컴퓨터는 Receiver
//컴퓨터 객체 생성
ComputerOnCommand computerOnCmd = new ComputerOnCommand(computer);
ComputerOffCommand computerOffCmd = new ComputerOffCommand(computer);
Button btn = new Button(computerOnCmd); //버튼이 Invoker 역할
btn.pressButton();
btn.setCommand(computerOffCmd);
btn.pressButton();
}
# JobQueue #1
- 지난 시간 학습한 Comman 패턴을 활용하여 JobQueue를 구현할 예정이다.
~> Queue에 쌓인 일감을 처리하는 Thread는 Push시 _jobQueue에 처음으로 일감을 밀어 넣는 Thread가 담당한다.
ServerCore에 JobQueue Class 생성
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace ServerCore
{
public interface IJobQueue
{
void Push(Action job);
}
public class JobQueue : IJobQueue // IJobQueue를 상속 받는다.
{
Queue<Action> _jobQueue = new Queue<Action>(); // 일감 목록을 담는 곳
object _lock = new object(); // MultiThread 환경을 위한 lock 선언
bool _flush = false; // Queue에 쌓인 일감들을 본인이 처리할 것인지 아닌지
public void Push(Action job) // _jobQueue에 일감을 밀어 넣기 위한 함수
{
bool flush = false; // MultiThread 환경에서 단 1개의 Thread만 일감 처리를 담당하도록
lock (_lock)
{
_jobQueue.Enqueue(job);
if (_flush == false) // _flush가 false인 경우 Queue에 쌓인 일감들을 본인이 처리
flush = _flush = true;
}
if (flush)
Flush();
}
void Flush()
{
while (true)
{
Action action = Pop();
if (action == null)
return;
action.Invoke();
}
}
Action Pop() // 일감 처리를 위해 _jobQueue에서 일감을 꺼내기 위한 함수
{
lock (_lock)
{
if (_jobQueue.Count == 0)
{
_flush = false;
return null;
}
return _jobQueue.Dequeue();
}
}
}
}
GameRoom Class 수정
using ServerCore;
using System;
using System.Collections.Generic;
using System.Text;
namespace Server
{
class GameRoom : IJobQueue // IJobQueue를 상속 받는다.
{
// ...
JobQueue _jobQueue = new JobQueue();
public void Push(Action job)
{
_jobQueue.Push(job);
}
// ...
}
}
ClientSession Class 수정
using ServerCore;
using System;
using System.Collections.Generic;
using System.Net;
using System.Net.Sockets;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
namespace Server
{
class ClientSession : PacketSession
{
// ...
public override void OnConnected(EndPoint endPoint)
{
// ...
// 바로 실행하는 것이 아닌 일감을 _jobQueue에 담는 것
Program.Room.Push(() => Program.Room.Enter(this));
// Program.Room.Enter(this); // Client가 접속시 방에 입장시킨다. (Program 산하에 static으로 Room을 생성하였기 때문에 다음과 같이 호출)
// ...
}
// ...
public override void OnDisconnected(EndPoint endPoint)
{
// ...
if (Room != null)
{
// 실행 도중 Client 종료시 Null Crash 방지를 위한 것 & 바로 실행하는 것이 아닌 일감을 _jobQueue에 담는 것
GameRoom room = Room;
room.Push(() => room.Leave(this));
//Room.Leave(this);
Room = null;
}
// ...
}
// ...
}
}
PacketHandler Class 수정
using Server;
using ServerCore;
using System;
using System.Collections.Generic;
using System.Text;
class PacketHandler
{
public static void C_ChatHandler(PacketSession session, IPacket packet)
{
// ...
// 실행 도중 Client 종료시 Null Crash 방지를 위한 것 & 바로 실행하는 것이 아닌 일감을 _jobQueue에 담는 것
GameRoom room = clientSession.Room;
room.Push(() => room.Broadcast(clientSession, chatPacket.chat));
// clientSession.Room.Broadcast(clientSession, chatPacket.chat); // 현재 clientSession이 접속중인 방에 존재하는 모두에게 채팅 메시지를 뿌린다.
}
}
# JobQueue #2
- 지난 시간에 구현한 JobQueue와 똑같이 동작하지만 수동적으로 Task를 만들어주는 방법에 대해 알아볼 예정이다.
~> 람다식 개념이 등장한지 얼마 되지 않아 현업과 같은 실무에서는 수동적으로 일 처리가 필요한 함수들을 구현해서
처리하는 형식이 자주 사용된다.
~> 그러나 위와 같이 수동적인 방법은 일 처리가 필요한 함수를 모두 구현해야 한다는 단점이 존재한다.
Server에 TaskQueue Class 생성 (_queue에 쌓인 일감 처리는 지난 시간의 Flush() 와 같은 메소드에서 처리하도록)
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
namespace Server
{
interface ITask
{
void Execute();
}
// 일 처리가 필요한 함수에 맞춰 클래스를 생성
class BroadcastTask : ITask
{
// 함수 실행시 필요한 변수들을 선언
GameRoom _room;
ClientSession _session;
string _chat;
// 생성자를 통한 변수 초기화
BroadcastTask(GameRoom room, ClientSession session, string chat)
{
_room = room;
_session = session;
_chat = chat;
}
public void Execute()
{
_room.Broadcast(_session, _chat);
}
}
class TaskQueue
{
Queue<ITask> _queue = new Queue<ITask>();
}
}
부하 Test를 위한 Listener Class 수정
using System;
using System.Text;
using System.Net;
using System.Net.Sockets;
namespace ServerCore
{
public class Listener
{
Socket _listenSocket;
Func<Session> _sessionFactory;
// 문지기 수를 10명으로 증가, 최대 대기수를 100명으로 증원
public void Init(IPEndPoint endPoint, Func<Session> sessionFactory, int register = 10, int backlog = 100)
{
// 문지기 고용
_listenSocket = new Socket(endPoint.AddressFamily, SocketType.Stream, ProtocolType.Tcp);
_sessionFactory += sessionFactory;
// 문지기 교육
_listenSocket.Bind(endPoint);
// 영업 시작
// backlog : 최대 대기 수
_listenSocket.Listen(backlog);
for (int i = 0; i < register; i++)
{
SocketAsyncEventArgs args = new SocketAsyncEventArgs();
args.Completed += new EventHandler<SocketAsyncEventArgs>(OnAcceptCompleted);
RegisterAccept(args);
}
}
// ...
}
}
- 만약 Client 접속 인원을 500명으로 늘린 뒤에 실행할 경우 메모리가 계속해서 상승하는 것을 볼 수 있다.
~> 이는 수많은 작업들이 밀리게 되면서Thread를 관리하는 입장에서는 Thread를 보냈으나 일 처리가 완료되지 않았기
때문에 ThreadPool에서 새로운 Thread를 뽑아 보내고 있기 때문에 메모리가 계속해서 상승하는 것이다.
- 가장 심한 부하를 유발하는 곳은 Broadcast의 foreach문 내의 Send이다.
~> 왜냐하면 Thread.Sleep(250) 을 통해 0.25초에 1번 동작하도록 설정하였으므로500명의 유저가 있다고 가정하면
500 * 500 = 250,000번이므로 1초에 1,000,000번 동작하고 있기 때문이다.
~> 이는 N^2의 시간 복잡도를 가진다.
~> 해결 방법으로는 패킷 요청이 올때마다 바로 Send 하는 것이 아닌 패킷을 모은 뒤 추후에 한번에 보내는 것이다.
- 패킷을 모아 보내는 것은 Engine 쪽에서도 처리할 수 있고 Content 쪽에서도 처리할 수 있다.
~> 만약 Engine 쪽에서 처리하고자 할 경우 Session의 Send() 메소드에서 처리가 가능하다.
(어느정도 쌓아둔 뒤 일정 조건에 따라 보내는 구조로 변경)
~> 그러나 이번 시간에는 Content 쪽에서 패킷 모아 보내기를 구현할 예정이다.
~> 패킷이 불규칙하게 전송될 경우 RecvBuffer의 크기를 늘려주면 된다.
Session Class 수정
using System;
using System.Text;
using System.Net;
using System.Net.Sockets;
namespace ServerCore
{
// ...
public abstract class Session
{
// ...
// List<ArraySegment<byte>> 를 매개변수로 받아 List에 담긴 패킷들을 전부 처리하는 Send 메소드 추가
public void Send(List<ArraySegment<byte>> sendBuffList)
{
if (sendBuffList.Count == 0)
return;
lock (_lock)
{
foreach (ArraySegment<byte> sendBuff in sendBuffList)
_sendQueue.Enqueue(sendBuff);
if (_pendingList.Count == 0)
RegisterSend();
}
}
// 기존의 Send 메소드
public void Send(ArraySegment<byte> sendBuff)
{
lock (_lock)
{
_sendQueue.Enqueue(sendBuff);
if (_pendingList.Count == 0)
RegisterSend();
}
}
// ...
}
}
GameRoom Class 수정
using ServerCore;
using System;
using System.Collections.Generic;
using System.Text;
namespace Server
{
class GameRoom : IJobQueue
{
// ...
List<ArraySegment<byte>> _pendingList = new List<ArraySegment<byte>>(); // 패킷을 모으기 위한 List
// ...
public void Flush() // 모아둔 패킷을 처리
{
foreach (ClientSession s in _sessions)
s.Send(_pendingList);
Console.WriteLine($"Flushed {_pendingList.Count} items");
_pendingList.Clear();
}
// ..
}
}
Server 코드 수정
using System;
using System.Text;
using System.Net;
using System.Net.Sockets;
using System.Threading;
using System.Threading.Tasks;
using ServerCore;
namespace Server
{
class Program
{
// ...
static void Main(string[] args)
{
// ...
while (true)
{
Room.Push(() => Room.Flush());
Thread.Sleep(250);
}
}
}
}
# JobTimer
- GameRoom 뿐만이 아닌 다양한 Room들이 추가될 경우 각각의 룸들은 서로 다른 대기시간을 가지며 실행된다.
~> 첫번째로는 Tick을 이용하는 방법이 있다.
~> 두번째로는 PriorityQueue를 이용하는 방법이 있다.
Tick을 통해 처리하기 위한 Server 코드 수정
using System;
using System.Text;
using System.Net;
using System.Net.Sockets;
using System.Threading;
using System.Threading.Tasks;
using ServerCore;
namespace Server
{
class Program
{
// ...
static void Main(string[] args)
{
// ...
int roomTick = 0;
// room이 추가될때마다 Tick 변수도 추가된다.
while (true)
{
int now = System.Environment.TickCount;
if (roomTick < now) // 그러나 이와 같은 방법은 불필요한 if문 체크가 반복된다.
{
Room.Push(() => Room.Flush());
roomTick = now + 250;
}
// room이 추가될때마다 if문도 추가된다.
}
}
}
}
ServerCore에 PriorityQueue Class 생성
using System;
using System.Collections.Generic;
using System.Text;
namespace ServerCore
{
public class PriorityQueue<T> where T : IComparable<T>
{
List<T> _heap = new List<T>();
public int Count { get { return _heap.Count; } }
// 0 (logN)
public void Push(T data)
{
// 힙의 맨 끝에 새로운 데이터를 삽입한다.
_heap.Add(data);
int now = _heap.Count - 1;
// 도장깨기를 시작
while (now > 0)
{
// 도장깨기를 시도
int next = (now - 1) / 2;
if (_heap[now].CompareTo(_heap[next]) < 0) // 대상값이 비교값과 같은 경우 0, 작은 경우 -1, 큰 경우 1 ( 대상값.CompareTo(비교값) )
break; // 실패
// 두 값을 교체한다.
T temp = _heap[now];
_heap[now] = _heap[next];
_heap[next] = temp;
// 검사 위치를 이동한다.
now = next;
}
}
// 0 (logN)
public T Pop()
{
// 반환할 데이터를 따로 저장
T ret = _heap[0];
// 마지막 데이터를 루트로 이동한다.
int lastIndex = _heap.Count - 1;
_heap[0] = _heap[lastIndex];
_heap.RemoveAt(lastIndex);
lastIndex--;
// 역으로 내려가는 도장깨기 시작
int now = 0;
while (true)
{
int left = 2 * now + 1;
int right = 2 * now + 2;
int next = now;
// 왼쪽값이 현재값보다 크면, 왼쪽으로 이동
if (left <= lastIndex && _heap[next].CompareTo(_heap[left]) < 0)
next = left;
// 오른쪽값이 현재값(왼쪽 이동 포함)보다 크면, 오른쪽으로 이동
if (right <= lastIndex && _heap[next].CompareTo(_heap[right]) < 0)
next = right;
// 왼쪽/오른쪽 모두 현재값보다 작으면 종료
if (next == now)
break;
// 두 값을 교체한다.
T temp = _heap[now];
_heap[now] = _heap[next];
_heap[next] = temp;
// 검사 위치를 이동한다.
now = next;
}
return ret;
}
public T Peek() // 일감 내용 확인을 위한 함수
{
if (_heap.Count == 0)
return default(T);
return _heap[0];
}
}
}
Server에 JobTimer Class 생성
using System;
using System.Collections.Generic;
using System.Text;
using ServerCore;
namespace Server
{
struct JobTimerElem : IComparable<JobTimerElem> // 하나의 일감 단위
{
public int execTick; // 실행 시간
public Action action; // 일감 (즉, Job)
// IComparable 인터페이스의 필수 구성 요소인 CompareTo()
public int CompareTo(JobTimerElem other)
{
// 실행 시간이 적을수록 먼저 튀어나오도록 (즉, 우선순위가 높은 항목)
return other.execTick - execTick;
}
}
class JobTimer // Job을 예약할 수 있는 시스템
{
// 우선순위 큐는 대소관계 연산 처리 속도가 상당히 빠르다.
PriorityQueue<JobTimerElem> _pq = new PriorityQueue<JobTimerElem>();
object _lock = new object(); // MultiThread 환경을 위한 lock 선언
public static JobTimer Instance { get; } = new JobTimer();
public void Push(Action action, int tickAfter = 0) // 예약하고자 하는 일감, 몇 Tick 후에 실행해야하는지 (입력 받지 못한 경우 바로 실행하도록 0으로 설정)
{
JobTimerElem job;
job.execTick = System.Environment.TickCount + tickAfter;
job.action = action;
lock (_lock)
{
_pq.Push(job);
}
}
public void Flush() // 일감 처리
{
while (true)
{
int now = System.Environment.TickCount;
JobTimerElem job;
lock (_lock)
{
if (_pq.Count == 0) // 일감이 없는 경우
break; // while문을 나간다는 의미
job = _pq.Peek(); // 꺼내지 않고 일감 내용만 확인하는 것
if (job.execTick > now) // 현재 시간보다 실행 시간이 많이 남은 경우
break;
_pq.Pop();
}
// 일감을 실행시킨다.
job.action.Invoke();
}
}
}
}
Server 코드 수정
using System;
using System.Text;
using System.Net;
using System.Net.Sockets;
using System.Threading;
using System.Threading.Tasks;
using ServerCore;
namespace Server
{
class Program
{
// ...
static void FlushRoom()
{
Room.Push(() => Room.Flush());
JobTimer.Instance.Push(FlushRoom, 250);
}
static void Main(string[] args)
{
// ...
JobTimer.Instance.Push(FlushRoom);
while (true)
{
JobTimer.Instance.Flush();
}
}
}
}
Unity Project에 NetworkManager Script 생성후 빈 객체에 Component로 추가
using DummyClient;
using ServerCore;
using System.Collections;
using System.Collections.Generic;
using System.Net;
using UnityEngine;
public class NetworkManager : MonoBehaviour
{
ServerSession _session = new ServerSession();
void Start()
{
// DNS (Domain Name System)
string host = Dns.GetHostName();
IPHostEntry ipHost = Dns.GetHostEntry(host);
IPAddress ipAddr = ipHost.AddressList[0];
IPEndPoint endPoint = new IPEndPoint(ipAddr, 7777);
Connector connector = new Connector();
connector.Connect(endPoint, () => { return _session; }, 1);
}
void Update()
{
}
}
통신이 원활하게 이루어지는지 확인하기 위해 Unity Project의 PacketHandler Script 수정
using DummyClient;
using ServerCore;
using System;
using System.Collections.Generic;
using System.Text;
using UnityEngine;
class PacketHandler
{
public static void S_ChatHandler(PacketSession session, IPacket packet)
{
S_Chat chatPacket = packet as S_Chat;
ServerSession serverSession = session as ServerSession;
if (chatPacket.playerId == 1)
Debug.Log(chatPacket.chat);
//if (chatPacket.playerId == 1)
//Console.WriteLine(chatPacket.chat);
}
}
- Test를 위해Server솔루션 프로젝트를 실행시켜 Server와 Client를 구동시킨 뒤, Unity의 Play 버튼을 누르면
Console 창에 Log가 뜨는 것을 확인할 수 있다.
# 유니티 연동 #2
- 이번 시간에는 단순히 Log만 띄우는 것이 아닌 실질적인 액션을 취하도록 만들 예정이다.
~> 우선 Unity에서 [ Hierarchy ] - [ 오른쪽 마우스 ] - [ 3D Object ] - [ Cylinder ] 를 통해 3D 객체 생성 후 이름을
Player로 설정해준다.
Unity Project의 PacketHandler Script 수정
using DummyClient;
using ServerCore;
using System;
using System.Collections.Generic;
using System.Text;
using UnityEngine;
class PacketHandler
{
public static void S_ChatHandler(PacketSession session, IPacket packet)
{
S_Chat chatPacket = packet as S_Chat;
ServerSession serverSession = session as ServerSession;
if (chatPacket.playerId == 1)
{
Debug.Log(chatPacket.chat);
// Unity 내의 객체를 찾는 Logic 추가
GameObject go = GameObject.Find("Player");
if (go == null)
Debug.Log("Player not found");
else
Debug.Log("Player found");
}
}
}
- 위의 추가한 Logic은 정상적으로 실행되지 않는다.
~> 기존의 우리가 작성한 Server 솔루션의 Logic은 비동기로 네트워크 통신을 하고 있다.
~> 따라서 Unity를 구동하는 Main Thread에서 네트워크 패킷을 실행하는 것이 아닌, Thread Pool에서 Thread를 꺼내와
실행하고 있는 것이 문제가 된다.
~> Unity는 다른 Thread에서 게임과 관련된 부분을 접근하여 실행하는 것을 원천적으로 차단해 두었기 때문에
정상적으로 실행되지 않는 것이다.
~> 해결 방법으로는 PacketHandler Class가 Main Thread에서 실행되도록 만들면 된다.
(S_ChatHandler에서 Logic을 처리하는 것이 아닌 Queue에 일감을 등록후, 처리하는 Logic을 구분 지어 사용)
Unity Project의 GenPacket Script 수정
using System;
using System.Collections.Generic;
using System.Text;
using System.Net;
using ServerCore;
// ...
public interface IPacket // 접근한정자 public 추가
{
ushort Protocol { get; }
void Read(ArraySegment<byte> segment);
ArraySegment<byte> Write();
}
// ...
Unity Project에 NetworkManager Script 생성
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
// Main Thread와 Background Thread (즉, 네트워크를 처리하는 애들끼리) 는 PacketQueue라는 통로를 통해 소통한다.
// ~> Background Thread는 Pakcet을 Push하여 밀어 넣고, Main Thread에서는 Packet을 Pop하여 처리한다.
public class PacketQueue // Component로 사용할 건 X ~> MonoBehaviour 상속 X
{
public static PacketQueue Instance { get; } = new PacketQueue();
Queue<IPacket> _packetQueue = new Queue<IPacket>();
object _lock = new object();
public void Push(IPacket packet)
{
lock (_lock)
{
_packetQueue.Enqueue(packet);
}
}
public IPacket Pop()
{
lock ( _lock)
{
if (_packetQueue.Count == 0)
return null;
return _packetQueue.Dequeue();
}
}
}
일감 등록을 위한 Unity Project의 ClientPacketManager Script 수정
using ServerCore;
using System;
using System.Collections.Generic;
class PacketManager
{
#region Singleton
static PacketManager _instance = new PacketManager();
public static PacketManager Instance { get { return _instance; } }
#endregion
PacketManager()
{
Register();
}
Dictionary<ushort, Func<PacketSession, ArraySegment<byte>, IPacket>> _makeFunc = new Dictionary<ushort, Func<PacketSession, ArraySegment<byte>, IPacket>>();
Dictionary<ushort, Action<PacketSession, IPacket>> _handler = new Dictionary<ushort, Action<PacketSession, IPacket>>();
public void Register()
{
_makeFunc.Add((ushort)PacketID.S_Chat, MakePacket<S_Chat>);
_handler.Add((ushort)PacketID.S_Chat, PacketHandler.S_ChatHandler);
}
// Action<PacketSession, IPacket> Type의 매개변수인 onRecvCallback 을 추가로 입력 받는다.
public void OnRecvPacket(PacketSession session, ArraySegment<byte> buffer, Action<PacketSession, IPacket> onRecvCallback = null)
{
ushort count = 0;
ushort size = BitConverter.ToUInt16(buffer.Array, buffer.Offset);
count += 2;
ushort id = BitConverter.ToUInt16(buffer.Array, buffer.Offset + count);
count += 2;
Func<PacketSession, ArraySegment<byte>, IPacket> func = null;
if (_makeFunc.TryGetValue(id, out func))
{
IPacket packet = func.Invoke(session, buffer);
if (onRecvCallback != null)
onRecvCallback.Invoke(session, packet);
else
HandlePacket(session, packet);
}
}
T MakePacket<T>(PacketSession session, ArraySegment<byte> buffer) where T : IPacket, new()
{
T pkt = new T();
pkt.Read(buffer);
return pkt;
}
public void HandlePacket(PacketSession session, IPacket packet)
{
Action<PacketSession, IPacket> action = null;
if (_handler.TryGetValue(packet.Protocol, out action))
action.Invoke(session, packet);
}
}
일감 등록을 위한 Unity Project의 ServerSession Script 수정
using System;
using System.Collections.Generic;
using System.Text;
using System.Net;
using ServerCore;
namespace DummyClient
{
class ServerSession : PacketSession
{
// ...
// Action<PacketSession, IPacket> Type의 매개변수인 onRecvCallback 을 추가로 입력
public override void OnRecvPacket(ArraySegment<byte> buffer)
{
PacketManager.Instance.OnRecvPacket(this, buffer, (s, p) => PacketQueue.Instance.Push(p));
}
// ...
}
}
일감 처리를 위한 Unity Project의 NetworkManager Script 수정
using DummyClient;
using ServerCore;
using System;
using System.Collections;
using System.Collections.Generic;
using System.Net;
using UnityEngine;
public class NetworkManager : MonoBehaviour
{
ServerSession _session = new ServerSession();
void Start()
{
// DNS (Domain Name System)
string host = Dns.GetHostName();
IPHostEntry ipHost = Dns.GetHostEntry(host);
IPAddress ipAddr = ipHost.AddressList[0];
IPEndPoint endPoint = new IPEndPoint(ipAddr, 7777);
Connector connector = new Connector();
connector.Connect(endPoint,
() => { return _session; }, 1);
StartCoroutine("CoSendPacket");
}
void Update()
{
// 일감 처리
IPacket packet = PacketQueue.Instance.Pop();
if (packet != null)
{
PacketManager.Instance.HandlePacket(_session, packet);
}
}
// 3초마다 패킷을 보내도록 (즉, DummyClient의 역할)
IEnumerator CoSendPacket()
{
while (true)
{
yield return new WaitForSeconds(3.0f);
C_Chat chatPacket = new C_Chat();
chatPacket.chat = "Hello Unity !";
ArraySegment<byte> segment = chatPacket.Write();
_session.Send(segment);
}
}
}
PacketFormat Class 수정
using System;
using System.Collections.Generic;
using System.Text;
namespace PacketGenerator
{
class PacketFormat
{
// {0} 패킷 등록
public static string managerFormat =
@"using ServerCore;
using System;
using System.Collections.Generic;
public class PacketManager
{{
#region Singleton
static PacketManager _instance = new PacketManager();
public static PacketManager Instance {{ get {{ return _instance; }} }}
#endregion
PacketManager()
{{
Register();
}}
Dictionary<ushort, Func<PacketSession, ArraySegment<byte>, IPacket>> _makeFunc = new Dictionary<ushort, Func<PacketSession, ArraySegment<byte>, IPacket>>();
Dictionary<ushort, Action<PacketSession, IPacket>> _handler = new Dictionary<ushort, Action<PacketSession, IPacket>>();
public void Register()
{{
{0}
}}
public void OnRecvPacket(PacketSession session, ArraySegment<byte> buffer, Action<PacketSession, IPacket> onRecvCallback = null)
{{
ushort count = 0;
ushort size = BitConverter.ToUInt16(buffer.Array, buffer.Offset);
count += 2;
ushort id = BitConverter.ToUInt16(buffer.Array, buffer.Offset + count);
count += 2;
Func<PacketSession, ArraySegment<byte>, IPacket> func = null;
if (_makeFunc.TryGetValue(id, out func))
{{
IPacket packet = func.Invoke(session, buffer);
if (onRecvCallback != null)
onRecvCallback.Invoke(session, packet);
else
HandlePacket(session, packet);
}}
}}
T MakePacket<T>(PacketSession session, ArraySegment<byte> buffer) where T : IPacket, new()
{{
T pkt = new T();
pkt.Read(buffer);
return pkt;
}}
public void HandlePacket(PacketSession session, IPacket packet)
{{
Action<PacketSession, IPacket> action = null;
if (_handler.TryGetValue(packet.Protocol, out action))
action.Invoke(session, packet);
}}
}}";
// {0} 패킷 이름
public static string managerRegisterFormat =
@" _makeFunc.Add((ushort)PacketID.{0}, MakePacket<{0}>);
_handler.Add((ushort)PacketID.{0}, PacketHandler.{0}Handler);";
// {0} 패킷 이름/번호 목록
// {1} 패킷 목록
public static string fileFormat =
@"using System;
using System.Collections.Generic;
using System.Text;
using System.Net;
using ServerCore;
public enum PacketID
{{
{0}
}}
public interface IPacket
{{
ushort Protocol {{ get; }}
void Read(ArraySegment<byte> segment);
ArraySegment<byte> Write();
}}
{1}
";
// ...
}
}
# 유니티 연동 #3
- 이번 시간과 다음 시간을 통해 Server에서의 Player 생성 및 움직임을 구현 예정이다.
~> 우선 Server Logic 수정 후, DummyClient Logic을 수정할 것이다.
using System;
using System.Collections.Generic;
using System.Text;
using System.Net.Sockets;
using System.Threading;
using System.Threading.Tasks;
using ServerCore;
using System.Net;
namespace Server
{
class ClientSession : PacketSession
{
public int SessionId { get; set; }
public GameRoom Room { get; set; }
public float PosX { get; set; } // x 좌표 값 저장을 위해 추가
public float PosY { get; set; } // y 좌표 값 저장을 위해 추가
public float PosZ { get; set; } // z 좌표 값 저장을 위해 추가
// ...
}
}
GameRoom Class 수정
using ServerCore;
using System;
using System.Collections.Generic;
using System.Text;
namespace Server
{
class GameRoom : IJobQueue
{
// ...
public void Broadcast(ArraySegment<byte> segment)
{
_pendingList.Add(segment);
}
public void Enter(ClientSession session)
{
// 플레이어 추가
_sessions.Add(session);
session.Room = this;
// 신입생한테 모든 플레이어 목록 전송
S_PlayerList players = new S_PlayerList();
foreach (ClientSession s in _sessions)
{
players.players.Add(new S_PlayerList.Player()
{
isSelf = (s == session),
playerId = s.SessionId,
posX = s.PosX,
posY = s.PosY,
posZ = s.PosZ
});
}
session.Send(players.Write());
// 신입생 입장을 모두에게 알린다
S_BroadcastEnterGame enter = new S_BroadcastEnterGame();
enter.playerId = session.SessionId;
enter.posX = 0;
enter.posY = 0;
enter.posZ = 0;
Broadcast(enter.Write());
}
public void Leave(ClientSession session)
{
// 플레이어 제거
_sessions.Remove(session);
// 플레이어 퇴장을 모두에게 알린다
S_BroadcastLeaveGame leave = new S_BroadcastLeaveGame();
leave.playerId = session.SessionId;
Broadcast(leave.Write());
}
public void Move(ClientSession session, C_Move packet)
{
// 좌표를 바꿔주고
session.PosX = packet.posX;
session.PosY = packet.posY;
session.PosZ = packet.posZ;
// 모두에게 알린다
S_BroadcastMove move = new S_BroadcastMove();
move.playerId = session.SessionId;
move.posX = session.PosX;
move.posY = session.PosY;
move.posZ = session.PosZ;
Broadcast(move.Write());
}
}
}
Server의 PacketHandler 수정
using Server;
using ServerCore;
using System;
using System.Collections.Generic;
using System.Text;
class PacketHandler
{
public static void C_LeaveGameHandler(PacketSession session, IPacket packet)
{
C_LeaveGame chatPacket = packet as C_LeaveGame;
ClientSession clientSession = session as ClientSession;
if (clientSession.Room == null)
return;
GameRoom room = clientSession.Room;
room.Push(() => room.Leave(clientSession));
}
public static void C_MoveHandler(PacketSession session, IPacket packet)
{
C_Move movePacket = packet as C_Move;
ClientSession clientSession = session as ClientSession;
if (clientSession.Room == null)
return;
GameRoom room = clientSession.Room;
room.Push(() => room.Move(clientSession, movePacket));
}
}
DummyClient의 PacketHandler 수정 (빌드가 통과할 수 있도록 함수만 만들어줄 뿐, 실질적인 작업은 유니티 내부에서 처리)
using DummyClient;
using ServerCore;
using System;
using System.Collections.Generic;
using System.Text;
class PacketHandler
{
public static void S_BroadcastEnterGameHandler(PacketSession session, IPacket packet)
{
S_BroadcastEnterGame pkt = packet as S_BroadcastEnterGame;
ServerSession serverSession = session as ServerSession;
}
public static void S_BroadcastLeaveGameHandler(PacketSession session, IPacket packet)
{
S_BroadcastLeaveGame pkt = packet as S_BroadcastLeaveGame;
ServerSession serverSession = session as ServerSession;
}
public static void S_PlayerListHandler(PacketSession session, IPacket packet)
{
S_PlayerList pkt = packet as S_PlayerList;
ServerSession serverSession = session as ServerSession;
}
public static void S_BroadcastMoveHandler(PacketSession session, IPacket packet)
{
S_BroadcastMove pkt = packet as S_BroadcastMove;
ServerSession serverSession = session as ServerSession;
}
}
DummyClient의 SessionManager 수정
using System;
using System.Collections.Generic;
using System.Text;
namespace DummyClient
{
class SessionManager
{
// ...
Random _rand = new Random();
public void SendForEach()
{
lock (_lock)
{
// 채팅 패킷이 아닌 이동 패킷을 보내도록 수정
foreach (ServerSession session in _sessions)
{
C_Move movePacket = new C_Move();
movePacket.posX = _rand.Next(-50, 50);
movePacket.posY = 0;
movePacket.posZ = _rand.Next(-50, 50);
session.Send(movePacket.Write());
}
}
}
// ...
}
}
# 유니티 연동 #4
- 지난 시간에 이어 Server에서의 Player 생성 및 움직임을 마저 구현할 예정이다.
Unity Project의 PacketQueue Script 수정
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class PacketQueue
{
// ...
public List<IPacket> PopAll()
{
List<IPacket> list = new List<IPacket>();
lock (_lock)
{
while (_packetQueue.Count > 0)
list.Add(_packetQueue.Dequeue());
}
return list;
}
}
Unity Project의 NetworkManager Script 수정
using DummyClient;
using ServerCore;
using System;
using System.Collections;
using System.Collections.Generic;
using System.Net;
using UnityEngine;
public class NetworkManager : MonoBehaviour
{
// ...
public void Send(ArraySegment<byte> sendBuff)
{
_session.Send(sendBuff);
}
// ...
void Update()
{
// 일감 처리
// 프레임마다 1개의 일감만을 처리하는 것이 아닌, 프레임마다 모든 일감하도록 수정
List<IPacket> list = PacketQueue.Instance.PopAll(); // 즉, Pop()이 아닌 PopAll()을 실행하도록 수정
foreach (IPacket packet in list)
PacketManager.Instance.HandlePacket(_session, packet);
}
}
Unity Project에 Player Script 생성
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class Player : MonoBehaviour
{
public int PlayerId { get; set; }
}
Unity Project에 MyPlayer Script 생성
using ServerCore;
using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class MyPlayer : Player
{
NetworkManager _network;
void Start()
{
StartCoroutine("CoSendPacket");
_network = GameObject.Find("NetworkManager").GetComponent<NetworkManager>();
}
void Update()
{
}
IEnumerator CoSendPacket()
{
while (true)
{
yield return new WaitForSeconds(0.25f);
C_Move movePacket = new C_Move();
movePacket.posX = UnityEngine.Random.Range(-50, 50);
movePacket.posY = 0;
movePacket.posZ = UnityEngine.Random.Range(-50, 50);
_network.Send(movePacket.Write());
}
}
}
Unity Project에 PlayerManager Script 생성
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class PlayerManager // 기생하는 것이 아닌 데이터만 들고 있도록 MonoBehaviour 상속 X
{
MyPlayer _myPlayer;
Dictionary<int, Player> _players = new Dictionary<int, Player>();
public static PlayerManager Instance { get; } = new PlayerManager();
public void Add(S_PlayerList packet)
{
Object obj = Resources.Load("Player");
foreach (S_PlayerList.Player p in packet.players)
{
GameObject go = Object.Instantiate(obj) as GameObject;
if (p.isSelf)
{
MyPlayer myPlayer = go.AddComponent<MyPlayer>();
myPlayer.PlayerId = p.playerId;
myPlayer.transform.position = new Vector3(p.posX, p.posY, p.posZ);
_myPlayer = myPlayer;
}
else
{
Player player = go.AddComponent<Player>();
player.PlayerId = p.playerId;
player.transform.position = new Vector3(p.posX, p.posY, p.posZ);
_players.Add(p.playerId, player);
}
}
}
public void Move(S_BroadcastMove packet)
{
if (_myPlayer.PlayerId == packet.playerId)
{
_myPlayer.transform.position = new Vector3(packet.posX, packet.posY, packet.posZ);
}
else
{
Player player = null;
if (_players.TryGetValue(packet.playerId, out player))
{
player.transform.position = new Vector3(packet.posX, packet.posY, packet.posZ);
}
}
}
public void EnterGame(S_BroadcastEnterGame packet)
{
if (packet.playerId == _myPlayer.PlayerId)
return;
Object obj = Resources.Load("Player");
GameObject go = Object.Instantiate(obj) as GameObject;
Player player = go.AddComponent<Player>();
player.transform.position = new Vector3(packet.posX, packet.posY, packet.posZ);
_players.Add(packet.playerId, player);
}
public void LeaveGame(S_BroadcastLeaveGame packet)
{
if (_myPlayer.PlayerId == packet.playerId)
{
GameObject.Destroy(_myPlayer.gameObject);
_myPlayer = null;
}
else
{
Player player = null;
if (_players.TryGetValue(packet.playerId, out player))
{
GameObject.Destroy(player.gameObject);
_players.Remove(packet.playerId);
}
}
}
}
Unity Project의 PacketHandler Script 수정
using DummyClient;
using ServerCore;
using System;
using System.Collections.Generic;
using System.Text;
using UnityEngine;
class PacketHandler
{
public static void S_BroadcastEnterGameHandler(PacketSession session, IPacket packet)
{
S_BroadcastEnterGame pkt = packet as S_BroadcastEnterGame;
ServerSession serverSession = session as ServerSession;
PlayerManager.Instance.EnterGame(pkt);
}
public static void S_BroadcastLeaveGameHandler(PacketSession session, IPacket packet)
{
S_BroadcastLeaveGame pkt = packet as S_BroadcastLeaveGame;
ServerSession serverSession = session as ServerSession;
PlayerManager.Instance.LeaveGame(pkt);
}
public static void S_PlayerListHandler(PacketSession session, IPacket packet)
{
S_PlayerList pkt = packet as S_PlayerList;
ServerSession serverSession = session as ServerSession;
PlayerManager.Instance.Add(pkt);
}
public static void S_BroadcastMoveHandler(PacketSession session, IPacket packet)
{
S_BroadcastMove pkt = packet as S_BroadcastMove;
ServerSession serverSession = session as ServerSession;
PlayerManager.Instance.Move(pkt);
}
}