- 기존에는 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);
}
}
- 프로젝트 생성 후 2D <~> 3D 간의 전환은 [ Edit ] - [ Project Settings... ] - [ Editor ] - [ Default Behaviour Mode ] 의
[ Mode ] 에서 설정 가능하다.
- 3D에서 2D로 전환한 경우 [ Window ] - [ Package Manager ] 에서 "2D Tilemap Editor"를 직접 설치해준다.
# TileMap 기초
- Pixel Per Unit에서 Unit은 Unity의 거리 단위로, Grid 눈금 1칸이 1x1 Unit에 해당한다.
- Tile Palette는 [ Window ] - [ 2D ] - [ Tile Palette ] 에서 사용 가능하다.
~> Create New Palette 를 통해 새로운 Tile Palette 생성이 가능하다.
~> 사용하고자 하는 Tile, Sprite, Sprite Texture를 끌어 Tile Palette에 옮긴다.
~> [ Hierarchy ] - [ 오른쪽 마우스 ] - [ 2D Object ] - [ Tilemap ] 을 통해 Tilemap 생성이 가능하다.
~> 생성한 Tilemap 위에 원하는 Tile을 페인트 칠한다.
# TileMap Layer
- Tilemap의 Tilemap Renderer Component 에서 Order in Layer 를 통해 Tilemap 간의 Render 우선순위 설정이 가능하다.
(숫자가 낮은 Layer가 먼저 Rendering 되고, 숫자가 높은 Layer가 그 위로 Overlap 된다.)
# TileMap Collision
- Tilemap Collider 2D Component를 추가하는 것보다는 Collider를 위한 Tilemap을 추가하는 것이 Collider를 보다 더
유연하게 만들 수 있다.
# MapTool
- Client 뿐만이 아니라 Server 역시 Map에 관한 정보를 가지고 있어야 한다. (예로 이동 가능/불가 지역과 같은 정보)
~> Server에게 보내기 위한 Map에 관한 정보가 담긴 파일을 Tool을 통해 쉽고 빠르게 생성할 수 있다.
- Tool은 Assets/Editor 경로 안에 위치한다.
~> Editor 폴더는 작성한 Script가 Unity Editor Scripting API에 접근할 수 있도록 허가해주는 예약 폴더이다.
~> 또 다른 예약 폴더로 Resources 폴더가 있으며, 이는 드래그 앤 드롭 대신 Script에서 파일 경로나 이름을 통해 Asset에
접근할 수 있도록 만들어준다.
Assets/Editor 경로 안에 MapEditor Script 생성
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Tilemaps;
using System.IO;
#if UNITY_EDITOR // 전처리기 ~> Unity Editor 상에서는 컴파일되지만, 그 외에는 컴파일되지 않는 코드
using UnityEditor;
#endif
public class MapEditor
{
#if UNITY_EDITOR // 전처리기 ~> Unity Editor 상에서는 컴파일되지만, 그 외에는 컴파일되지 않는 코드
[MenuItem("Tools/GenerateMap %#g")] // 단축키 : %(Ctrl), #(Shift), &(Alt)
private static void GenerateMap()
{
GameObject[] gameObjects = Resources.LoadAll<GameObject>("Prefabs/Map");
foreach (GameObject go in gameObjects)
{
Tilemap tm = Util.FindChild<Tilemap>(go, "Tilemap_Collision", true);
using (var writer = File.CreateText($"Assets/Resources/Map/{go.name}.txt"))
{
writer.WriteLine(tm.cellBounds.xMin);
writer.WriteLine(tm.cellBounds.xMax);
writer.WriteLine(tm.cellBounds.yMin);
writer.WriteLine(tm.cellBounds.yMax);
// Tilemap의 모든 position을 스캔하여 해당 position에 tile이 깔려 있는지 확인
// ~> 이동 가능 지역(즉, tile이 X)은 0, 이동 불가 지역(즉, tile이 O)은 1로
for (int y = tm.cellBounds.yMax; y >= tm.cellBounds.yMin; y--)
{
for (int x = tm.cellBounds.xMin; x <= tm.cellBounds.xMax; x++)
{
TileBase tile = tm.GetTile(new Vector3Int(x, y, 0));
if (tile != null)
writer.Write("1");
else
writer.Write("0");
}
writer.WriteLine();
}
}
}
}
#endif
}
# Player 이동
- Player 이동을 Grid 눈금 1칸 단위로 움직이도록 구현하고자 한다.
Define Script 수정
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class Define
{
public enum MoveDir
{
None,
Up,
Down,
Left,
Right,
}
// ...
}
PlayerController Script 생성
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using static Define;
public class PlayerController : MonoBehaviour
{
public Grid _grid;
public float _speed = 5.0f;
Vector3Int _cellPos = Vector3Int.zero;
MoveDir _dir = MoveDir.None;
bool _isMoving = false; // false인 경우 좌표 이동이 가능, true인 경우 좌표 이동이 불가능
void Start()
{
// CellToWorld() 함수는 Grid를 기반으로 특정 Cell 위치의 World 좌표를 얻고 싶은 경우에 사용
Vector3 pos = _grid.CellToWorld(_cellPos) + new Vector3(0.5f, 0.5f);
transform.position = pos;
}
void Update()
{
// Server 연동을 위한 분리 처리 (과부하 방지)
GetDirInput();
UpdatePosition();
UpdateIsMoving();
}
// 키보드 입력을 통한 방향 설정
void GetDirInput()
{
if (Input.GetKey(KeyCode.W))
{
_dir = MoveDir.Up;
}
else if (Input.GetKey(KeyCode.S))
{
_dir = MoveDir.Down;
}
else if (Input.GetKey(KeyCode.A))
{
_dir = MoveDir.Left;
}
else if (Input.GetKey(KeyCode.D))
{
_dir = MoveDir.Right;
}
else
{
_dir = MoveDir.None;
}
}
// 자연스러운 이동 처리
void UpdatePosition()
{
if (_isMoving == false)
return;
Vector3 destPos = _grid.CellToWorld(_cellPos) + new Vector3(0.5f, 0.5f);
Vector3 moveDir = destPos - transform.position; // 방향 벡터 (방향, 크기)
// 도착 여부 체크
float dist = moveDir.magnitude; // 방향 벡터의 크기 (즉, 목적지까지 남은 거리)
if (dist < _speed * Time.deltaTime) // 거의 도착한 경우
{
transform.position = destPos;
_isMoving = false;
}
else
{
transform.position += moveDir.normalized * _speed * Time.deltaTime;
_isMoving = true;
}
}
// 이동 가능한 상태인 경우 실제 좌표를 이동
void UpdateIsMoving()
{
if (_isMoving == false)
{
switch (_dir)
{
case MoveDir.Up:
_cellPos += Vector3Int.up;
_isMoving = true;
break;
case MoveDir.Down:
_cellPos += Vector3Int.down;
_isMoving = true;
break;
case MoveDir.Left:
_cellPos += Vector3Int.left;
_isMoving = true;
break;
case MoveDir.Right:
_cellPos += Vector3Int.right;
_isMoving = true;
break;
}
}
}
}
# Player Animation
PlayerController Script 수정
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using static Define;
public class PlayerController : MonoBehaviour
{
// ...
MoveDir _dir = MoveDir.None;
Animator _animator;
public MoveDir Dir
{
get { return _dir; }
set
{
if (_dir == value)
return;
switch (value)
{
case MoveDir.Up:
_animator.Play("WALK_BACK");
transform.localScale = new Vector3(1.0f, 1.0f, 1.0f);
break;
case MoveDir.Down:
_animator.Play("WALK_FRONT");
transform.localScale = new Vector3(1.0f, 1.0f, 1.0f);
break;
case MoveDir.Left:
_animator.Play("WALK_RIGHT");
transform.localScale = new Vector3(-1.0f, 1.0f, 1.0f);
break;
case MoveDir.Right:
_animator.Play("WALK_RIGHT");
transform.localScale = new Vector3(1.0f, 1.0f, 1.0f);
break;
case MoveDir.None:
if (_dir == MoveDir.Up)
{
_animator.Play("IDLE_BACK");
transform.localScale = new Vector3(1.0f, 1.0f, 1.0f);
}
else if (_dir == MoveDir.Down)
{
_animator.Play("IDLE_FRONT");
transform.localScale = new Vector3(1.0f, 1.0f, 1.0f);
}
else if (_dir == MoveDir.Left)
{
_animator.Play("IDLE_RIGHT");
transform.localScale = new Vector3(-1.0f, 1.0f, 1.0f);
}
else
{
_animator.Play("IDLE_RIGHT");
transform.localScale = new Vector3(1.0f, 1.0f, 1.0f);
}
break;
}
_dir = value;
}
}
void Start()
{
// ...
_animator = GetComponent<Animator>();
}
// ...
void GetDirInput()
{
if (Input.GetKey(KeyCode.W))
{
Dir = MoveDir.Up;
}
else if (Input.GetKey(KeyCode.S))
{
Dir = MoveDir.Down;
}
else if (Input.GetKey(KeyCode.A))
{
Dir = MoveDir.Left;
}
else if (Input.GetKey(KeyCode.D))
{
Dir = MoveDir.Right;
}
else
{
Dir = MoveDir.None;
}
}
// ...
}
# MapManager
MapManager Script 생성
using System.Collections;
using System.Collections.Generic;
using System.IO;
using UnityEngine;
public class MapManager
{
public Grid CurrentGrid { get; private set; }
public int MinX { get; set; }
public int MaxX { get; set; }
public int MinY { get; set; }
public int MaxY { get; set; }
bool[,] _collision;
public bool CanGo(Vector3Int cellPos)
{
if (cellPos.x < MinX || cellPos.x > MaxX)
return false;
if (cellPos.y < MinY || cellPos.y > MaxY)
return false;
int x = cellPos.x - MinX;
int y = MaxY - cellPos.y;
return !_collision[y, x];
}
public void LoadMap(int mapId)
{
DestroyMap(); // 오류 방지 차원
// 맵 로드
string mapName = "Map_" + mapId.ToString("000");
GameObject go = Managers.Resource.Instantiate($"Map/{mapName}");
go.name = "Map";
GameObject collision = Util.FindChild(go, "Tilemap_Collision", true);
if (collision != null)
collision.SetActive(false);
CurrentGrid = go.GetComponent<Grid>();
// Collision txt 파일을 통해 이동 가능 여부에 관한 데이터 저장
TextAsset txt = Managers.Resource.Load<TextAsset>($"Map/{mapName}");
StringReader reader = new StringReader(txt.text);
MinX = int.Parse(reader.ReadLine());
MaxX = int.Parse(reader.ReadLine());
MinY = int.Parse(reader.ReadLine());
MaxY = int.Parse(reader.ReadLine());
int xCount = MaxX - MinX + 1;
int yCount = MaxY - MinY + 1;
_collision = new bool[yCount, xCount];
for (int y = 0; y < yCount; y++)
{
string line = reader.ReadLine();
for (int x = 0; x < xCount; x++)
{
_collision[y, x] = (line[x] == '1' ? true : false);
}
}
}
public void DestroyMap()
{
GameObject map = GameObject.Find("Map");
if (map != null)
{
GameObject.Destroy(map);
CurrentGrid = null;
}
}
}
Managers Script 수정
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class Managers : MonoBehaviour
{
// ...
#region Contents
MapManager _map = new MapManager();
public static MapManager Map { get { return Instance._map; } }
#endregion
// ...
}
GameScene Script 수정
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class GameScene : BaseScene
{
protected override void Init()
{
// ...
Managers.Map.LoadMap(1);
// ...
}
}
MapEditor Script 수정
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Tilemaps;
using System.IO;
#if UNITY_EDITOR
using UnityEditor;
#endif
public class MapEditor
{
#if UNITY_EDITOR
[MenuItem("Tools/GenerateMap %#g")]
private static void GenerateMap()
{
// ...
foreach (GameObject go in gameObjects)
{
// 전체적인 Map 은 Tilemap_Collision 이 아닌 Tilemap_Base 에 해당하므로 수정
Tilemap tmBase = Util.FindChild<Tilemap>(go, "Tilemap_Base", true);
Tilemap tm = Util.FindChild<Tilemap>(go, "Tilemap_Collision", true);
using (var writer = File.CreateText($"Assets/Resources/Map/{go.name}.txt"))
{
writer.WriteLine(tmBase.cellBounds.xMin); // tm에서 tmBase로 수정
writer.WriteLine(tmBase.cellBounds.xMax); // tm에서 tmBase로 수정
writer.WriteLine(tmBase.cellBounds.yMin); // tm에서 tmBase로 수정
writer.WriteLine(tmBase.cellBounds.yMax); // tm에서 tmBase로 수정
for (int y = tmBase.cellBounds.yMax; y >= tmBase.cellBounds.yMin; y--) // tm에서 tmBase로 수정
{
for (int x = tmBase.cellBounds.xMin; x <= tmBase.cellBounds.xMax; x++) // tm에서 tmBase로 수정
{
// ...
}
// ...
}
}
}
}
#endif
}
PlayerController Script 수정
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using static Define;
public class PlayerController : MonoBehaviour
{
// public Grid _grid; ~> 더이상 드래그 앤 드롭으로 연결 X
// ...
void Start()
{
Vector3 pos = Managers.Map.CurrentGrid.CellToWorld(_cellPos) + new Vector3(0.5f, 0.5f); // Managers.Map.CurrentGrid 을 통해 현재 Grid를 참조
// ...
}
// ...
void UpdatePosition()
{
// ...
Vector3 destPos = Managers.Map.CurrentGrid.CellToWorld(_cellPos) + new Vector3(0.5f, 0.5f); // Managers.Map.CurrentGrid 을 통해 현재 Grid를 참조
// ...
}
void UpdateIsMoving()
{
if (_isMoving == false && _dir != MoveDir.None)
{
Vector3Int destPos = _cellPos;
switch (_dir)
{
case MoveDir.Up:
destPos += Vector3Int.up;
break;
case MoveDir.Down:
destPos += Vector3Int.down;
break;
case MoveDir.Left:
destPos += Vector3Int.left;
break;
case MoveDir.Right:
destPos += Vector3Int.right;
break;
}
if (Managers.Map.CanGo(destPos))
{
_cellPos = destPos;
_isMoving = true;
}
}
}
}
# Controller 정리
- _isMoving, _isSkill, _isJumping과 같이 bool 타입의 변수를 계속해서 생성하는 것보단 state에 관한 enum을 생성하고
이를 통해 상태를 관리하는 것이 더 좋다.
Define Script 수정
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class Define
{
public enum CreatureState
{
Idle,
Moving,
Skill,
Dead,
}
// ...
}
CreatureController Script 생성
using Newtonsoft.Json.Linq;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using static Define;
public class CreatureController : MonoBehaviour
{
public float _speed = 5.0f;
protected Vector3Int _cellPos = Vector3Int.zero;
protected Animator _animator;
protected SpriteRenderer _sprite; // flipX 사용을 위해
// protected bool _isMoving = false;
CreatureState _state = CreatureState.Idle;
public CreatureState State
{
get { return _state; }
set
{
if (_state == value)
return;
_state = value;
UpdateAnimation();
}
}
MoveDir _dir = MoveDir.None;
MoveDir _lastDir = MoveDir.None; // 마지막으로 바라보고 있던 방향
public MoveDir Dir
{
get { return _dir; }
set
{
if (_dir == value)
return;
_dir = value;
if (value != MoveDir.None) // 마지막으로 바라보고 있던 방향 설정
_lastDir = value;
UpdateAnimation();
}
}
// 상태 전환, 방향 전환에 따른 Animation 변경을 위한 함수 생성
protected virtual void UpdateAnimation()
{
if (_state == CreatureState.Idle)
{
switch (_lastDir)
{
case MoveDir.Up:
_animator.Play("IDLE_BACK");
_sprite.flipX = false;
break;
case MoveDir.Down:
_animator.Play("IDLE_FRONT");
_sprite.flipX = false;
break;
case MoveDir.Left:
_animator.Play("IDLE_RIGHT");
_sprite.flipX = true;
break;
case MoveDir.Right:
_animator.Play("IDLE_RIGHT");
_sprite.flipX = false;
break;
}
}
else if (_state == CreatureState.Moving)
{
switch (_dir)
{
case MoveDir.Up:
_animator.Play("WALK_BACK");
_sprite.flipX = false;
break;
case MoveDir.Down:
_animator.Play("WALK_FRONT");
_sprite.flipX = false;
break;
case MoveDir.Left:
_animator.Play("WALK_RIGHT");
_sprite.flipX = true;
break;
case MoveDir.Right:
_animator.Play("WALK_RIGHT");
_sprite.flipX = false;
break;
}
}
else if (_state == CreatureState.Skill)
{
}
else
{
}
}
void Start()
{
Init();
}
void Update()
{
UpdateController();
}
protected virtual void Init()
{
_animator = GetComponent<Animator>();
_sprite = GetComponent<SpriteRenderer>();
Vector3 pos = Managers.Map.CurrentGrid.CellToWorld(_cellPos) + new Vector3(0.5f, 0.5f); // Managers.Map.CurrentGrid 을 통해 현재 Grid를 참조
transform.position = pos;
}
protected virtual void UpdateController()
{
UpdatePosition();
UpdateIsMoving();
}
// 자연스러운 이동 처리
void UpdatePosition()
{
if (State != CreatureState.Moving)
return;
Vector3 destPos = Managers.Map.CurrentGrid.CellToWorld(_cellPos) + new Vector3(0.5f, 0.5f); // Managers.Map.CurrentGrid 을 통해 현재 Grid를 참조
Vector3 moveDir = destPos - transform.position; // 방향 벡터 (방향, 크기)
// 도착 여부 체크
float dist = moveDir.magnitude; // 방향 벡터의 크기 (즉, 목적지까지 남은 거리)
if (dist < _speed * Time.deltaTime) // 거의 도착한 경우
{
transform.position = destPos;
// 예외적으로 Animation을 직접 컨트롤
_state = CreatureState.Idle;
if (_dir == MoveDir.None)
UpdateAnimation();
}
else
{
transform.position += moveDir.normalized * _speed * Time.deltaTime;
State = CreatureState.Moving;
}
}
// 이동 가능한 상태인 경우 실제 좌표를 이동
void UpdateIsMoving()
{
if (State == CreatureState.Idle && _dir != MoveDir.None)
{
Vector3Int destPos = _cellPos;
switch (_dir)
{
case MoveDir.Up:
destPos += Vector3Int.up;
break;
case MoveDir.Down:
destPos += Vector3Int.down;
break;
case MoveDir.Left:
destPos += Vector3Int.left;
break;
case MoveDir.Right:
destPos += Vector3Int.right;
break;
}
if (Managers.Map.CanGo(destPos))
{
_cellPos = destPos;
State = CreatureState.Moving;
}
}
}
}
PlayerController Script 수정
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using static Define;
public class PlayerController : CreatureController
{
protected override void Init()
{
base.Init();
}
protected override void UpdateController()
{
GetDirInput();
base.UpdateController();
}
void LateUpdate()
{
Camera.main.transform.position = new Vector3(transform.position.x, transform.position.y, -10);
}
// 키보드 입력을 통한 방향 설정
void GetDirInput()
{
if (Input.GetKey(KeyCode.W))
{
Dir = MoveDir.Up;
}
else if (Input.GetKey(KeyCode.S))
{
Dir = MoveDir.Down;
}
else if (Input.GetKey(KeyCode.A))
{
Dir = MoveDir.Left;
}
else if (Input.GetKey(KeyCode.D))
{
Dir = MoveDir.Right;
}
else
{
Dir = MoveDir.None;
}
}
}
MonsterController Script 생성
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using static Define;
public class MonsterController : CreatureController
{
protected override void Init()
{
base.Init();
}
protected override void UpdateController()
{
//GetDirInput();
base.UpdateController();
}
// 키보드 입력을 통한 방향 설정
void GetDirInput()
{
if (Input.GetKey(KeyCode.W))
{
Dir = MoveDir.Up;
}
else if (Input.GetKey(KeyCode.S))
{
Dir = MoveDir.Down;
}
else if (Input.GetKey(KeyCode.A))
{
Dir = MoveDir.Left;
}
else if (Input.GetKey(KeyCode.D))
{
Dir = MoveDir.Right;
}
else
{
Dir = MoveDir.None;
}
}
}
# ObjectManager
- Player끼리, Monster끼리, Player와 Monster간의 충돌이 발생할 수 있도록 만들어볼 예정이다.
ObjectManager Script 생성
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class ObjectManager
{
List<GameObject> _objects = new List<GameObject>();
public void Add(GameObject go)
{
_objects.Add(go);
}
public void Remove(GameObject go)
{
_objects.Remove(go);
}
public GameObject Find(Vector3Int cellPos)
{
foreach (GameObject obj in _objects)
{
CreatureController cc = obj.GetComponent<CreatureController>();
if (cc == null)
continue;
if (cc.CellPos == cellPos)
return obj;
}
return null;
}
public void Clear()
{
_objects.Clear();
}
}
Managers Script 수정
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class Managers : MonoBehaviour
{
// ...
#region Contents
// ...
ObjectManager _obj = new ObjectManager();
public static ObjectManager Object { get { return Instance._obj;} }
// ...
#endregion
// ...
}
CreatureController Script 수정
using Newtonsoft.Json.Linq;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using static Define;
public class CreatureController : MonoBehaviour
{
// ...
// 외부에서 접근할 수 있도록 수정
public Vector3Int CellPos { get; set; } = Vector3Int.zero;
// ...
protected virtual void Init()
{
// ...
Vector3 pos = Managers.Map.CurrentGrid.CellToWorld(CellPos) + new Vector3(0.5f, 0.5f); // _cellPos를 CellPos로 수정
// ...
}
// ...
void UpdatePosition()
{
// ...
Vector3 destPos = Managers.Map.CurrentGrid.CellToWorld(CellPos) + new Vector3(0.5f, 0.5f); // _cellPos를 CellPos로 수정
// ...
}
void UpdateIsMoving()
{
if (State == CreatureState.Idle && _dir != MoveDir.None)
{
Vector3Int destPos = CellPos; // _cellPos를 CellPos로 수정
// ...
State = CreatureState.Moving;
if (Managers.Map.CanGo(destPos))
{
if (Managers.Object.Find(destPos) == null) // 만약 destPos 위치에 Object가 존재하지 않는 경우
{
CellPos = destPos; // _cellPos를 CellPos로 수정
}
}
}
}
}
GameScene Script 수정
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class GameScene : BaseScene
{
protected override void Init()
{
// ...
// Player와 Monster를 Prefab화 시킨뒤 수동으로 생성
GameObject player = Managers.Resource.Instantiate("Creature/Player");
player.name = "Player";
Managers.Object.Add(player); // 생성한 Player를 ObjectManager의 _objects List에 추가
for (int i = 0; i < 5; i++)
{
GameObject monster = Managers.Resource.Instantiate("Creature/Monster");
monster.name = $"Monster_{i + 1}";
// 랜덤 위치 스폰 (일단 겹쳐도 OK)
Vector3Int pos = new Vector3Int()
{
x = Random.Range(-10, 10),
y = Random.Range(-5, 5),
};
MonsterController mc = monster.GetComponent<MonsterController>();
mc.CellPos = pos;
Managers.Object.Add(monster); // 생성한 Monster를 ObjectManager의 _objects List에 추가
}
// ...
}
// ...
}
# 평타 스킬
CreatureController Script 수정
using Newtonsoft.Json.Linq;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using static Define;
public class CreatureController : MonoBehaviour
{
// ...
public Vector3Int GetFrontCellPos() // 현재 바라보고 있는 방향의 Cell위치를 얻기 위한 함수
{
Vector3Int cellPos = CellPos;
switch (_lastDir)
{
case MoveDir.Up:
cellPos += Vector3Int.up;
break;
case MoveDir.Down:
cellPos += Vector3Int.down;
break;
case MoveDir.Left:
cellPos += Vector3Int.left;
break;
case MoveDir.Right:
cellPos += Vector3Int.right;
break;
}
return cellPos;
}
protected virtual void UpdateAnimation()
{
// ...
else if (_state == CreatureState.Skill)
{
switch (_lastDir)
{
case MoveDir.Up:
_animator.Play("ATTACK_BACK");
_sprite.flipX = false;
break;
case MoveDir.Down:
_animator.Play("ATTACK_FRONT");
_sprite.flipX = false;
break;
case MoveDir.Left:
_animator.Play("ATTACK_RIGHT");
_sprite.flipX = true;
break;
case MoveDir.Right:
_animator.Play("ATTACK_RIGHT");
_sprite.flipX = false;
break;
}
}
else
{
}
}
// ...
protected virtual void UpdateController()
{
switch (State)
{
case CreatureState.Idle:
UpdateIdle();
break;
case CreatureState.Moving:
UpdateMoving();
break;
case CreatureState.Skill:
break;
case CreatureState.Dead:
break;
}
}
// 자연스러운 이동 처리
protected virtual void UpdateMoving() // UpdatePosition() 에서 UpdateMoving()으로 이름 변경
{
// UpdateController()의 switch문에서 확인하기 때문에 아래 부분 삭제
// if (State != CreatureState.Moving)
// return;
Vector3 destPos = Managers.Map.CurrentGrid.CellToWorld(CellPos) + new Vector3(0.5f, 0.5f);
Vector3 moveDir = destPos - transform.position;
float dist = moveDir.magnitude;
if (dist < _speed * Time.deltaTime)
{
transform.position = destPos;
_state = CreatureState.Idle;
if (_dir == MoveDir.None)
UpdateAnimation();
}
else
{
transform.position += moveDir.normalized * _speed * Time.deltaTime;
State = CreatureState.Moving;
}
}
// 이동 가능한 상태인 경우 실제 좌표를 이동
protected virtual void UpdateIdle() // UpdateIsMoving() 에서 UpdateIdle()로 이름 변경
{
if (_dir != MoveDir.None) // UpdateController()의 switch문에서 확인하기 때문에 "State == CreatureState.Idle &&" 부분 삭제
{
Vector3Int destPos = CellPos;
switch (_dir)
{
case MoveDir.Up:
destPos += Vector3Int.up;
break;
case MoveDir.Down:
destPos += Vector3Int.down;
break;
case MoveDir.Left:
destPos += Vector3Int.left;
break;
case MoveDir.Right:
destPos += Vector3Int.right;
break;
}
State = CreatureState.Moving;
if (Managers.Map.CanGo(destPos))
{
if (Managers.Object.Find(destPos) == null)
{
CellPos = destPos;
}
}
}
}
protected virtual void UpdateSkill()
{
}
protected virtual void UpdateDead()
{
}
}
PlayerController Script 수정
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using static Define;
public class PlayerController : CreatureController
{
Coroutine _coSkill;
// ...
protected override void UpdateController()
{
switch (State)
{
case CreatureState.Idle:
GetDirInput();
GetIdleInput(); // 움직이지 않는 경우에만 Skill 사용이 가능하도록
break;
case CreatureState.Moving:
GetDirInput();
break;
}
base.UpdateController();
}
// ...
void GetIdleInput()
{
if (Input.GetKey(KeyCode.Space))
{
State = CreatureState.Skill;
_coSkill = StartCoroutine("CoStartPunch");
}
}
IEnumerator CoStartPunch() // 스킬 쿨타임
{
// 피격 판정
GameObject go = Managers.Object.Find(GetFrontCellPos());
if (go != null)
{
Debug.Log(go.name);
}
// 대기 시간
yield return new WaitForSeconds(0.5f);
State = CreatureState.Idle;
_coSkill = null;
}
}
# 화살 스킬
CreatureController Script 수정
using Newtonsoft.Json.Linq;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using static Define;
public class CreatureController : MonoBehaviour
{
// ...
protected CreatureState _state = CreatureState.Idle; // 보호수준을 protected로 변경
// ...
protected MoveDir _dir = MoveDir.None; // 보호수준을 protected로 변경
protected MoveDir _lastDir = MoveDir.None; // 보호수준을 protected로 변경
// ...
}
ArrowController Script 생성
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using static Define;
public class ArrowController : CreatureController
{
protected override void Init()
{
switch (_lastDir) // 화살이 나아가야하는 방향에 맞춰 회전
{
case MoveDir.Up:
transform.rotation = Quaternion.Euler(0, 0, 0);
break;
case MoveDir.Down:
transform.rotation = Quaternion.Euler(0, 0, -180);
break;
case MoveDir.Left:
transform.rotation = Quaternion.Euler(0, 0, -90);
break;
case MoveDir.Right:
transform.rotation = Quaternion.Euler(0, 0, 90);
break;
}
base.Init();
}
protected override void UpdateAnimation()
{
// 화살은 Animation이 필요 X ~> override 후 내용을 공백으로 둔다.
}
protected override void UpdateIdle() // 화살의 자연스러운 이동 처리
{
if (_dir != MoveDir.None)
{
Vector3Int destPos = CellPos;
switch (_dir)
{
case MoveDir.Up:
destPos += Vector3Int.up;
break;
case MoveDir.Down:
destPos += Vector3Int.down;
break;
case MoveDir.Left:
destPos += Vector3Int.left;
break;
case MoveDir.Right:
destPos += Vector3Int.right;
break;
}
State = CreatureState.Moving;
if (Managers.Map.CanGo(destPos))
{
GameObject go = Managers.Object.Find(destPos);
if (go == null)
{
CellPos = destPos;
}
else // 만약 화살이 나아가는 방향에 Object가 존재할 경우
{
Debug.Log(go.name);
Managers.Resource.Destroy(gameObject); // 화살 소멸
}
}
else // 만약 화살이 나아가는 방향이 이동 불가능한 지역일 경우
{
Managers.Resource.Destroy(gameObject); // 화살 소멸
}
}
}
}
PlayerController Script 수정
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using static Define;
public class PlayerController : CreatureController
{
// ...
bool _rangeSkill = false; // Skill 사용시 해당 Skill이 범위 Skill 인지 구분하기 위해서
// ...
protected override void UpdateAnimation() // Player만 화살 Skill 사용이 가능하므로 override 하여 기능 추가
{
if (_state == CreatureState.Idle)
{
switch (_lastDir)
{
case MoveDir.Up:
_animator.Play("IDLE_BACK");
_sprite.flipX = false;
break;
case MoveDir.Down:
_animator.Play("IDLE_FRONT");
_sprite.flipX = false;
break;
case MoveDir.Left:
_animator.Play("IDLE_RIGHT");
_sprite.flipX = true;
break;
case MoveDir.Right:
_animator.Play("IDLE_RIGHT");
_sprite.flipX = false;
break;
}
}
else if (_state == CreatureState.Moving)
{
switch (_dir)
{
case MoveDir.Up:
_animator.Play("WALK_BACK");
_sprite.flipX = false;
break;
case MoveDir.Down:
_animator.Play("WALK_FRONT");
_sprite.flipX = false;
break;
case MoveDir.Left:
_animator.Play("WALK_RIGHT");
_sprite.flipX = true;
break;
case MoveDir.Right:
_animator.Play("WALK_RIGHT");
_sprite.flipX = false;
break;
}
}
else if (_state == CreatureState.Skill) // _rangeSkill 여부에 맞춰 Animation 재생
{
switch (_lastDir)
{
case MoveDir.Up:
_animator.Play(_rangeSkill ? "ATTACK_WEAPON_BACK": "ATTACK_BACK");
_sprite.flipX = false;
break;
case MoveDir.Down:
_animator.Play(_rangeSkill ? "ATTACK_WEAPON_FRONT" : "ATTACK_FRONT");
_sprite.flipX = false;
break;
case MoveDir.Left:
_animator.Play(_rangeSkill ? "ATTACK_WEAPON_RIGHT" : "ATTACK_RIGHT");
_sprite.flipX = true;
break;
case MoveDir.Right:
_animator.Play(_rangeSkill ? "ATTACK_WEAPON_RIGHT" : "ATTACK_RIGHT");
_sprite.flipX = false;
break;
}
}
else
{
}
}
// ...
void GetIdleInput()
{
if (Input.GetKey(KeyCode.Space))
{
State = CreatureState.Skill;
_coSkill = StartCoroutine("CoStartShootArrow"); // Test를 위해 CoStartPunch가 아닌 CoStartShootArrow 실행
}
}
// ...
IEnumerator CoStartShootArrow()
{
GameObject go = Managers.Resource.Instantiate("Creature/Arrow"); // 화살 생성
ArrowController ac = go.GetComponent<ArrowController>();
ac.Dir = _lastDir; // 화살 방향은 Player가 마지막으로 바라보고 있던 방향으로 설정
ac.CellPos = CellPos; // 화살 발사 위치는 Player의 위치로 설정
// 대기 시간
_rangeSkill = true;
yield return new WaitForSeconds(0.3f);
State = CreatureState.Idle;
_coSkill = null;
}
}
# 소멸 이펙트
- Player 및 Monster의 죽음은 다양한 원인에 의해 발생할 수 있다.
~> 죽음에 관한 Logic을 매번 복붙할 경우 유지보수가 힘들어질 수 있다. (즉, 한 곳에서 관리하는 것이 중요)
- 피격을 한 객체에서가 아닌 피격을 당한 객체에서 죽음에 관한 Logic을 처리하는 것이 더 좋다.
CreatureController Script 수정
using Newtonsoft.Json.Linq;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using static Define;
public class CreatureController : MonoBehaviour
{
// ...
public virtual void OnDamaged()
{
}
}
MonsterController Script 수정
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using static Define;
public class MonsterController : CreatureController
{
// ...
public override void OnDamaged()
{
GameObject effect = Managers.Resource.Instantiate("Effect/DieEffect"); // Effect 생성
effect.transform.position = transform.position; // Effect 위치를 화살 피격을 당한 Object 위치로 설정
effect.GetComponent<Animator>().Play("START"); // Effect의 Animation 재생
GameObject.Destroy(effect, 0.5f); // 0.5초 후 Effect 삭제
Managers.Object.Remove(gameObject); // 화살 피격을 당한 Object를 _objects List 에서 삭제
Managers.Resource.Destroy(gameObject); // 화살 피격을 당한 Object 삭제
}
}
PlayerController Script 수정
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using static Define;
public class PlayerController : CreatureController
{
// ...
IEnumerator CoStartPunch()
{
GameObject go = Managers.Object.Find(GetFrontCellPos());
if (go != null)
{
CreatureController cc = go.GetComponent<CreatureController>();
if (cc != null)
cc.OnDamaged(); // OnDamaged() 호출
}
_rangeSkill = false;
yield return new WaitForSeconds(0.5f);
State = CreatureState.Idle;
_coSkill = null;
}
// ...
}
ArrowController Script 수정
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using static Define;
public class ArrowController : CreatureController
{
// ...
protected override void UpdateIdle()
{
if (_dir != MoveDir.None)
{
// ...
if (Managers.Map.CanGo(destPos))
{
GameObject go = Managers.Object.Find(destPos);
if (go == null)
{
CellPos = destPos;
}
else
{
CreatureController cc = go.GetComponent<CreatureController>();
if (cc != null)
cc.OnDamaged(); // OnDamaged() 호출
Managers.Resource.Destroy(gameObject);
}
}
else
{
// ...
}
}
}
}
1. MultiThread는 MultiProcess 보다 적은 메모리 공간을 차지하고, Context Switching 이 빠르다는 장점을 가진다.
~> 그러나 MultiThread의 단점으로는 동기화 문제와 1개의 Thread 장애가 전체 Thread 에게 영향을 줄 수 있다는 것이다.
2. MultiProcess는 1개의 Process가 죽더라도 다른 Process 에게 영향을 주지 않아 안정성이 높다는 장점을 가진다.
~> 그러나 MultiProcess의 단점으로는 MultiThread 보다 많은 메모리 공간과 CPU 시간을 차지한다는 것이다.
# 쓰레드 생성
- 고급 레스토랑에서 정직원을 고용하는 것은 상당히 부담스럽다. ( 인건비, 4대 보험 등 ... )
~> 인력 상담소에서 단기 알바를 구하는 것이 좋다.
- 즉, Thread를 생성하는 것은 상당한 부하를 가져다 준다.
~> C#은 사용 가능한 Thread를 할당 받아 사용할 수 있는 Thread Pool을 지원한다. ( 사용 후 반환 / 일꾼이 없으면 대기 )
Thread 응용
using System;
using System.Threading; // 추가해야 Thread 사용 가능
namespace ServerCore
{
class Program
{
static void MainThread()
{
for (int i = 0; i < 5; i++)
Console.WriteLine("Hello Thread!");
}
static void Main(string[] args)
{
Thread t = new Thread(MainThread); // 한명의 직원을 고용한 뒤 MainThread 라는 업무를 할당한 것
t.IsBackground = true; // Main 함수가 종료될 경우 해당 Thread도 종료 (기본값은 foreground)
t.Name = "Test Thread"; // Thread 이름 설정
t.Start(); // Thread 실행
Console.WriteLine("Waiting for Thread");
t.Join(); // 해당 Thread가 종료될때까지 기다리는 것
Console.WriteLine("Hello World");
}
}
}
Thread Pool 응용
using System;
using System.Threading;
namespace ServerCore
{
class Program
{
static void MainThread(object state)
{
for (int i = 0; i < 5; i++)
Console.WriteLine("Hello Thread!");
}
static void Main(string[] args)
{
// Thread를 최소 1개에서 최대 5개까지만 빌려줄 수 있도록 제한
ThreadPool.SetMinThreads(1, 1);
ThreadPool.SetMaxThreads(5, 5);
// 람다식을 통해 Pool에서 Thread를 빌린뒤 영원히 반환하지 않는 함수 생성
for (int i = 0; i < 5; i++)
ThreadPool.QueueUserWorkItem((obj) => { while (true) { } });
// Thread Pool에 더이상 남아있는 Thread가 없어 실행 X
ThreadPool.QueueUserWorkItem(MainThread);
}
}
}
Task 응용
using System;
using System.Threading;
using System.Threading.Tasks; // 추가해야 Task 사용 가능
namespace ServerCore
{
class Program
{
static void MainThread(object state)
{
for (int i = 0; i < 5; i++)
Console.WriteLine("Hello Thread!");
}
static void Main(string[] args)
{
// Thread를 최소 1개에서 최대 5개까지만 빌려줄 수 있도록 제한
ThreadPool.SetMinThreads(1, 1);
ThreadPool.SetMaxThreads(5, 5);
for (int i = 0; i < 5; i++)
{
// Task로 직원이 할 일감을 생성하여 던져주면, ThreadPool에 대기중인 Thread가 해당 일감을 실행
// Task 생성시 TaskCreationOptions.LongRunning 옵션을 설정할 경우 Thread를 따로 관리
Task t = new Task(() => { while (true) { } }, TaskCreationOptions.LongRunning);
t.Start();
}
// TaskCreationOptions.LongRunning 옵션을 통해 Thread를 따로 관리하므로 실행 가능
ThreadPool.QueueUserWorkItem(MainThread);
}
}
}
# 컴파일러 최적화
- Debug 모드와 Release 모드의 가장 큰 차이점은 Debug 모드에서는 코드 최적화를 하지 않고, Release 모드에서는 코드
최적화를 한다는 것이다.
~> 따라서 Debug 모드에서는 잘 실행되던 코드가 Release 모드에서는 실행되지 않을 수 있다.
~> volatile Keyword를 통해 최적화를 금지시킬 수 있다.
volatile Keyword 응용
using System;
using System.Threading;
using System.Threading.Tasks;
namespace ServerCore
{
class Program
{
// volatile Keyword를 통해 해당 변수에 대한 최적화를 금지시킨다.
volatile static bool _stop = false; // 전역 변수는 모든 Thread들이 공유
static void ThreadMain()
{
Console.WriteLine("Thread 시작!");
// Debug 모드가 아닌 Release 모드에서는 코드 최적화로 인해 일어나지 않던 Bug들이 발생할 수 있다.
while (_stop == false)
{
// 누군가가 stop 신호를 true로 바꿔주기를 기다린다.
}
// 위의 while문은 컴파일러 최적화시 아래의 코드와 같이 변경된다. ( 즉, 없던 Bug 발생 )
//if (_stop == false)
//{
// while (true)
// {
// 무한 루프에 빠진다.
// }
//}
Console.WriteLine("Thread 종료!");
}
static void Main(string[] args)
{
Task t = new Task(ThreadMain);
t.Start();
Thread.Sleep(1000); // 1초동안 일시정지
_stop = true;
Console.WriteLine("Stop 호출!");
Console.WriteLine("종료 대기중");
t.Wait(); // 해당 Task가 종료될때까지 기다리는 것 ( Thread의 Join() 과 같은 기능 )
Console.WriteLine("종료 성공");
}
}
}
# 캐시 이론
- 직원이 서빙을 통해 주문을 받고 주방과 원격으로 연결된 주문 현황에 주문 내역을 입력할 경우 주방에서 조리를
시작한다. ( 이때 직원이 주문 현황을 입력하기 위한 동선 자체가 많이 길다고 가정 )
- 직원은 보다 더 효율적으로 움직이기 위해서 주문을 1개 받자마자 멀리 떨어진 주문 현황에 주문 내역을 입력하러
가는 것이 아닌, 단기 기억/미니 수첩/대형 수첩 을 활용하여 주문들을 전부 기록해둔 뒤 나중에 한꺼번에 입력을 한다.
~> 만약 손님이 주문은 번복할 경우 본인이 수첩에 기록해둔 주문만 수정하면 되기 때문에 보다 더 효율적이다.
- 그러나 이러한 방법이 MultiThread 에서는 골치아플 수 있다.
~> 직원 1이 2번 테이블에 대한 콜라 주문을 받고, 2번 테이블 손님들이 직원 2에게 콜라가 아닌 사이다를 달라고
주문을 번복할 경우 혼란이 발생한다.
~> 직원 2 입장에서는 본인이 2번 테이블에 대해 콜라 주문을 받지도 않았고, 확인하고자 주문 현황을 확인해봐도
2번 테이블이 콜라를 주문했다는 사실을 알 수 없다.
( 2번 테이블에 대한 콜라 주문은 아직까지는 직원 1의 미니 수첩에 기록됨 )
- 위의 내용에서 로봇 직원은 Thread, 단기 기억/미니 수첩/대형 수첩이 Cache, 주문 현황은 Main Memory에 해당한다.
- 캐시 철학으로 시간적 지역성, 공간적 지역성이 있다.
~> 시간적 지역성은 최근에 참조된 주소의 내용이 다시 참조될 가능성이 높다는 것이다.
~> 공간적 지역성은 기억장치 내에 서로 인접하여 저장되어 있는 데이터들이 연속적으로 접근될 가능성이 높다는 것이다.
캐시 응용
using System;
using System.Threading;
using System.Threading.Tasks;
namespace ServerCore
{
class Program
{
static void Main(string[] args)
{
int[,] arr = new int[10000, 10000];
{
long start = DateTime.Now.Ticks;
for (int x = 0; x < 10000; x++)
for (int y = 0; y < 10000; y++)
arr[x, y] = 1;
long end = DateTime.Now.Ticks;
Console.WriteLine($"(x, y) 순서 걸린 시간 {end - start}"); // 결과 : 3138744
}
{
long start = DateTime.Now.Ticks;
for (int x = 0; x < 10000; x++)
for (int y = 0; y < 10000; y++)
arr[y, x] = 1;
long end = DateTime.Now.Ticks;
Console.WriteLine($"(y, x) 순서 걸린 시간 {end - start}"); // 결과 : 4003940
}
}
}
}
# 메모리 배리어
- 코드 최적화 뿐만이 아니라 HW 역시 최적화를 진행한다.
~> CPU에게 일련의 명령어들을 시킬 경우 CPU가 의존성이 없다고 판단되는 명령어들의 순서를 제멋대로 재배치한다.
~> Memory Barrier를 통해 코드 순서 재배치를 억제할 수 있다.
- Memory Barrier를 통해 가시성 또한 확보할 수 있다.
~> 이때의 가시성 확보는 MultiThread 환경에서 Thread의 Cache의 상황을 Main Memory에 반영하는 것을 말한다.
~> 따라서 Store 후에 1번, Load 전에 1번 해주는 것이 가시성 확보에 도움이 된다. ( 최신 정보 반영 및 사용 )
HW 최적화 예시
using System;
using System.Threading;
using System.Threading.Tasks;
namespace ServerCore
{
class Program
{
static int x = 0;
static int y = 0;
static int result1 = 0;
static int result2 = 0;
static void Thread_1()
{
// CPU가 아래의 명령어들이 의존성이 없다고 판단하여 코드의 순서를 제멋대로 재배치
y = 1; // Store y
result1 = x; // Load x
}
static void Thread_2()
{
// CPU가 아래의 명령어들이 의존성이 없다고 판단하여 코드의 순서를 제멋대로 재배치
x = 1; // Store x
result2 = y; // Load y
}
static void Main(string[] args)
{
int count = 0;
while (true)
{
count++;
x = y = result1 = result2 = 0;
Task t1 = new Task(Thread_1);
Task t2 = new Task(Thread_2);
t1.Start();
t2.Start();
Task.WaitAll(t1, t2); // 2개의 Thread가 전부 끝날때까지 Main Thread는 대기
// CPU가 코드의 순서를 제멋대로 재배치하여 둘 다 0인 상황이 발생할 수 있었다.
if (result1 == 0 && result2 == 0)
break;
}
Console.WriteLine($"{count}번만에 빠져나옴!");
}
}
}
Memory Barrier 응용 ( 코드 순서 재배치 억제, 가시성 )
using System;
using System.Threading;
using System.Threading.Tasks;
namespace ServerCore
{
// 메모리 배리어 기능
// A) 코드 순서 재배치 억제
// B) 가시성 확보 (MultiThread 환경에서 Thread의 Cache의 상황을 Main Memory에 반영)
// 메모리 배리어 종류
// 1) Full Memory Barrier (어셈블리어 : MFENCE, C# : Thread.MemoryBarrier) : Store/Load 둘 다 막는다.
// 2) Store Memory Barrier (어셈블리어 : SFENCE) : Store 만 막는다.
// 3) Load Memory Barrier (어셈블리어 : LFENCE) : Load 만 막는다.
class Program
{
static int x = 0;
static int y = 0;
static int result1 = 0;
static int result2 = 0;
static void Thread_1()
{
y = 1; // Store y ( Main Memory에 y의 값을 반영 )
Thread.MemoryBarrier(); // 코드 순서 재배치 억제
result1 = x; // Load x ( Main Memory에 있는 x의 값을 반영 )
}
static void Thread_2()
{
x = 1; // Store x ( Main Memory에 x의 값을 반영 )
Thread.MemoryBarrier(); // 코드 순서 재배치 억제
result2 = y; // Load y ( Main Memory에 있는 y의 값을 반영 )
}
static void Main(string[] args)
{
int count = 0;
while (true)
{
count++;
x = y = result1 = result2 = 0;
Task t1 = new Task(Thread_1);
Task t2 = new Task(Thread_2);
t1.Start();
t2.Start();
Task.WaitAll(t1, t2); // 2개의 Thread가 전부 끝날때까지 Main Thread는 대기
// CPU의 코드 재배치를 억제하였기 때문에 둘 다 0인 경우는 존재 X ~> 무한루프
if (result1 == 0 && result2 == 0)
break;
}
Console.WriteLine($"{count}번만에 빠져나옴!");
}
}
}
# interlocked
- Race Condition (경합 조건) 이란 2개 이상의 Process 혹은 Thread가 공유 자원을 서로 사용하려고 경합(Race)하는
현상을 의미한다.
- MultiThread 환경에서는 Process 내의 모든 자원을 공유할 수 있다는 점에서 동기화 문제가 발생한다.
~> Interlocked 를 통해 해결이 가능하다. ( 메모리 동기화 구현 )
~> Interlocked 는 정수만 사용할 수 있다는 단점이 존재한다.
Race Condition 예시
using System;
using System.Threading;
using System.Threading.Tasks;
namespace ServerCore
{
class Program
{
static int number = 0;
static void Thread_1()
{
for (int i = 0; i < 100000; i++)
number++;
// number++은 어셈블리어에서 아래와 같이 동작한다. (즉, atomic operation이 X)
// int temp = number;
// temp += 1;
// number = temp;
}
static void Thread_2()
{
for (int i = 0; i < 100000; i++)
number--;
// number--은 어셈블리어에서 아래와 같이 동작한다. (즉, atomic operation이 X)
// int temp = number;
// temp -= 1;
// number = temp;
}
static void Main(string[] args)
{
Task t1 = new Task(Thread_1);
Task t2 = new Task(Thread_2);
t1.Start();
t2.Start();
Task.WaitAll(t1, t2);
// 위의 주석 처리된 6줄의 코드 순서를 정확히 알지 못하기 때문에 결과 예측 X
Console.WriteLine(number);
}
}
}
Interlocked 를 통한 원자성 및 순서 보장
using System;
using System.Threading;
using System.Threading.Tasks;
namespace ServerCore
{
class Program
{
static int number = 0;
static void Thread_1()
{
for (int i = 0; i < 100000; i++)
Interlocked.Increment(ref number); // 원자성 보장 및 순서 보장 (All or Nothing)
}
static void Thread_2()
{
for (int i = 0; i < 100000; i++)
Interlocked.Decrement(ref number); // 원자성 보장 및 순서 보장 (All or Nothing)
}
static void Main(string[] args)
{
Task t1 = new Task(Thread_1);
Task t2 = new Task(Thread_2);
t1.Start();
t2.Start();
Task.WaitAll(t1, t2);
Console.WriteLine(number);
}
}
}
# Lock 기초
- Critical Section (임계 영역) 은 2개 이상의 Thread가 동시에 접근할 수 없는 공유 자원을 사용하는 코드 영역을 의미한다.
- Mutual Exclusive (상호 배제) 란 특정 시점에서 공유 자원을 1개의 Thread 만이 사용할 수 있으며, 다른 Thread 들은
접근하지 못하도록 제어하는 방법을 의미한다.
Critical Section 및 Mutual Exclusive 응용
using System;
using System.Threading;
using System.Threading.Tasks;
namespace ServerCore
{
class Program
{
static int number = 0;
static object _obj = new object();
static void Thread_1()
{
for (int i = 0; i < 100000; i++)
{
Monitor.Enter(_obj); // 문에 잠금장치 설정 (이미 잠겨있을 경우 대기)
number++;
Monitor.Exit(_obj); // 문에 잠금장치 해제 (생략할 경우 deadlock 발생 가능)
}
}
static void Thread_2()
{
for (int i = 0; i < 100000; i++)
{
Monitor.Enter(_obj); // 문에 잠금장치 설정 (이미 잠겨있을 경우 대기)
number--;
Monitor.Exit(_obj); // 문에 잠금장치 해제 (생략할 경우 deadlock 발생 가능)
}
}
static void Main(string[] args)
{
Task t1 = new Task(Thread_1);
Task t2 = new Task(Thread_2);
t1.Start();
t2.Start();
Task.WaitAll(t1, t2);
Console.WriteLine(number);
}
}
}
lock Keyword 응용
using System;
using System.Threading;
using System.Threading.Tasks;
namespace ServerCore
{
class Program
{
static int number = 0;
static object _obj = new object();
static void Thread_1()
{
for (int i = 0; i < 100000; i++)
{
lock(_obj) // 내부는 Monitor.Enter 과 Monitor.Exit 로 구현됨 (자동으로 lock 해제)
{
number++;
}
}
}
static void Thread_2()
{
for (int i = 0; i < 100000; i++)
{
lock (_obj) // 내부는 Monitor.Enter 과 Monitor.Exit 로 구현됨 (자동으로 lock 해제)
{
number--;
}
}
}
static void Main(string[] args)
{
Task t1 = new Task(Thread_1);
Task t2 = new Task(Thread_2);
t1.Start();
t2.Start();
Task.WaitAll(t1, t2);
Console.WriteLine(number);
}
}
}
# DeadLock
- Dead Lock (교착 상태) 이란2개 이상의 Process 또는 Thread가Mutual Exclusive으로 사용하던 자원을 요청하면서
서로가 가진 자원을 대기하는 현상을 말한다.
- Dead Lock을 try-catch문, Monitor.TryEnter()를 통해 예외를 처리하기보단 Crash 를 통해 오류를 수정하는 것이 더 좋다.
- Dead Lock은 개발 단계에서 잘 발생하지 않다가 출시 이후 User들이 몰리는 상황에 갑작스럽게 발생하는 경우가 많다.
Dead Lock 응용
using System;
using System.Threading;
using System.Threading.Tasks;
namespace ServerCore
{
class SessionManager
{
static object _lock = new object();
public static void TestSession()
{
lock (_lock)
{
}
}
public static void Test()
{
lock (_lock)
{
UserManager.TestUser();
}
}
}
class UserManager
{
static object _lock = new object();
public static void TestUser()
{
lock (_lock)
{
}
}
public static void Test()
{
lock (_lock)
{
SessionManager.TestSession();
}
}
}
class Program
{
static void Thread_1()
{
for (int i = 0; i < 10000; i++)
{
SessionManager.Test();
}
}
static void Thread_2()
{
for (int i = 0; i < 10000; i++)
{
UserManager.Test();
}
}
static void Main(string[] args)
{
Task t1 = new Task(Thread_1);
Task t2 = new Task(Thread_2);
t1.Start();
t2.Start();
Task.WaitAll(t1, t2);
}
}
}
# Lock 구현 이론
- Lock 요청시 이미 누가 Lock을 점유하고 있을 경우 이를 어떻게 처리하는지에 따라 성능이 결정된다.
1. 루프를 돌면서 계속 점유를 시도한다. ( SpinLock )
2. Thread가 자신의 소유권을 포기하고, 나중에 다시 lock을 요청한다. ( 랜덤성 / context switching 비용 발생 )
using System;
using System.Threading;
using System.Threading.Tasks;
namespace ServerCore
{
class SpinLock
{
volatile bool _locked = false; // lock 잠금 상태 (해당 lock의 사용 유무)
public void Acquire() // lock 설정
{
// 오류가 발생하는 이유는 lock 잠금 상태 확인과 lock을 잠그는 것을 따로 하고 있기 때문
// 만약 2개의 Thread가 거의 동시다발적으로 들어와 동시에 while 문을 통과할 경우 문제 발생
// 따라서 아래의 lock 잠금 상태 확인과 lock을 잠그는 코드를 하나로 묶어줘야 한다.
// 오류 발생 원인 시작
while (_locked)
{
// 잠금 상태가 풀릴때까지 무작정 기다린다.
}
_locked = true;
// 오류 발생 원인 종료
}
public void Release() // lock 해제
{
_locked = false;
}
}
class Program
{
static int _num = 0;
static SpinLock _lock = new SpinLock();
static void Thread_1()
{
for (int i = 0; i < 100000; i++)
{
_lock.Acquire();
_num++;
_lock.Release();
}
}
static void Thread_2()
{
for (int i = 0; i < 100000; i++)
{
_lock.Acquire();
_num--;
_lock.Release();
}
}
static void Main(string[] args)
{
Task t1 = new Task(Thread_1);
Task t2 = new Task(Thread_2);
t1.Start();
t2.Start();
Task.WaitAll(t1, t2);
Console.WriteLine(_num);
}
}
}
정상적으로 동작하는 SpinLock 구현 (즉, 루프를 돌면서 계속 점유를 시도)
using System;
using System.Threading;
using System.Threading.Tasks;
namespace ServerCore
{
class SpinLock
{
volatile int _locked = 0; // lock 잠금 상태 (해당 lock의 사용 유무)
public void Acquire() // lock 설정
{
while (true)
{
// 방법 #1
// Interlocked.Exchange는 1번째 인자의 값을 2번째 인자의 값으로 변경하며, 반환값은 1번째 인자의 변경 전 값이다.
int original = Interlocked.Exchange(ref _locked, 1);
if (original == 0) // 즉, 아무도 lock을 사용하지 않는 상황에서 내가 lock을 건 경우
break; // 무작정 기다리는 것을 그만두겠다. (즉, lock 휙득 성공)
// 방법 #2
// Interlocked.CompareExchange는 1, 3번째 인자의 값이 같다면 2번째 인자의 값을 1번째 인자에 대입하며, 반환값은 1번째 인자의 변경 전 값이다.
// Interlocked.CompareExchange는 CAS (Compare-And-Swap) 연산 수행
int expected = 0; // 예상한 값
int desired = 1; // 기대한 값
int original2 = Interlocked.CompareExchange(ref _locked, desired, expected);
if (original2 == 0) // 즉, 아무도 lock을 사용하지 않는 상황에서 내가 lock을 건 경우
break; // 무작정 기다리는 것을 그만두겠다. (즉, lock 휙득 성공)
}
}
public void Release() // lock 해제
{
_locked = 0;
}
}
class Program
{
static int _num = 0;
static SpinLock _lock = new SpinLock();
static void Thread_1()
{
for (int i = 0; i < 100000; i++)
{
_lock.Acquire();
_num++;
_lock.Release();
}
}
static void Thread_2()
{
for (int i = 0; i < 100000; i++)
{
_lock.Acquire();
_num--;
_lock.Release();
}
}
static void Main(string[] args)
{
Task t1 = new Task(Thread_1);
Task t2 = new Task(Thread_2);
t1.Start();
t2.Start();
Task.WaitAll(t1, t2);
Console.WriteLine(_num);
}
}
}
# Context Switching
- 루프를 돌면서 계속 점유를 시도하는 SpinLock과 달리, Sleep과 Yield를 통해 Thread가 자신의 소유권을 포기하고,
나중에 다시 lock을 요청하는 lock 구현 방식이 마냥 장점만 존재한다고는 할 수 없다.
~> Context Switching 비용 문제가 발생한다.
- Context Switching 이란 CPU가 어떤 Process 또는 Thread를 실행하고 있는 상태에서 운영체제의 Scheduling에 의해
더 높은 우선순위를 가진 Process 또는 Thread가 실행되어야 할 때, Register에 저장된 기존 Process 또는 Thread의 정보
를 Kernel에 저장하고 실행하고자 하는 Process 또는 Thread의 정보를 복원하는 일련의 과정을 말하며 이러한 과정은
lock 구현때뿐만이 아닌 늘상 발생하고 있는 자연스러운 현상이다.
Sleep과 Yield 를 통한 lock 구현 (즉, Thread가 자신의 소유권을 포기하고, 나중에 다시 lock을 요청)
using System;
using System.Threading;
using System.Threading.Tasks;
namespace ServerCore
{
class Lock
{
volatile int _locked = 0; // lock 잠금 상태 (해당 lock의 사용 유무)
public void Acquire() // lock 설정
{
while (true)
{
// 방법 #1
int original = Interlocked.Exchange(ref _locked, 1);
if (original == 0)
break;
// 방법 #2
int expected = 0;
int desired = 1;
int original2 = Interlocked.CompareExchange(ref _locked, desired, expected);
if (original2 == 0)
break;
// 잠시 쉬다 올게~ (3개의 방법중 1개 선택)
Thread.Sleep(1); // 무조건 휴식 => 1ms 정도 쉬고 싶은데 정확한 시간은 운영체제가 결정
Thread.Sleep(0); // 조건부 양보 => 나보다 우선순위가 낮은 애들한테는 양보 X => 나보다 우선순위가 높거나 같은 애들이 없다면 남은 시간을 본인이 소진
Thread.Yield(); // 관대한 양보 => 관대하게 양보할테니, 지금 실행이 가능한 애가 있다면 실행하세요 => 실행 가능한 애가 없다면 남은 시간을 본인이 소진
}
}
public void Release() // lock 해제
{
_locked = 0;
}
}
class Program
{
static int _num = 0;
static Lock _lock = new Lock();
static void Thread_1()
{
for (int i = 0; i < 100000; i++)
{
_lock.Acquire();
_num++;
_lock.Release();
}
}
static void Thread_2()
{
for (int i = 0; i < 100000; i++)
{
_lock.Acquire();
_num--;
_lock.Release();
}
}
static void Main(string[] args)
{
Task t1 = new Task(Thread_1);
Task t2 = new Task(Thread_2);
t1.Start();
t2.Start();
Task.WaitAll(t1, t2);
Console.WriteLine(_num);
}
}
}
# AutoResetEvent
AutoResetEvent를 통한 lock 구현 (즉, 운영체제에게 lock이 해제될 경우 본인에게 통보해달라고 요청)
using System;
using System.Threading;
using System.Threading.Tasks;
namespace ServerCore
{
class Lock
{
// bool <- Kernel
AutoResetEvent _avaliable = new AutoResetEvent(true); // true일 경우 아무나 들어올 수 있는 상태, false일 경우 누구도 들어올 수 없는 상태
public void Acquire() // lock 설정
{
_avaliable.WaitOne(); // 입장 시도 (true일 경우 입장이 가능하며, 자동으로 문을 닫아준다.)
// _avaliable.Reset()은 bool의 값을 false로 변경 -> AutoResetEvent에서는 WaitOne() 내부에 포함되어 있다.
}
public void Release() // lock 해제
{
_avaliable.Set(); // bool의 값을 true로 변경
}
}
class Program
{
static int _num = 0;
static Lock _lock = new Lock();
static void Thread_1()
{
for (int i = 0; i < 1000; i++)
{
_lock.Acquire();
_num++;
_lock.Release();
}
}
static void Thread_2()
{
for (int i = 0; i < 1000; i++)
{
_lock.Acquire();
_num--;
_lock.Release();
}
}
static void Main(string[] args)
{
Task t1 = new Task(Thread_1);
Task t2 = new Task(Thread_2);
t1.Start();
t2.Start();
Task.WaitAll(t1, t2);
Console.WriteLine(_num);
}
}
}
오류가 발생하는 ManualResetEvent를 통한 lock 구현 (즉, 운영체제에게 lock이 해제될 경우 본인에게 통보해달라고 요청)
using System;
using System.Threading;
using System.Threading.Tasks;
namespace ServerCore
{
class Lock
{
// bool <- Kernel
ManualResetEvent _avaliable = new ManualResetEvent(true); // true일 경우 아무나 들어올 수 있는 상태, false일 경우 누구도 들어올 수 없는 상태
public void Acquire() // lock 설정
{
// 오류가 발생하는 이유는 입장 시도와 문을 닫는 것을 따로 하고 있기 때문
_avaliable.WaitOne(); // 입장 시도 (true일 경우 입장이 가능하며, 자동으로 문을 닫아주지 않는다.)
_avaliable.Reset(); // bool의 값을 false로 변경 -> ManualResetEvent에서는 WaitOne() 내부에 포함되어 있지 않다.
}
public void Release() // lock 해제
{
_avaliable.Set(); // bool의 값을 true로 변경
}
}
class Program
{
static int _num = 0;
static Lock _lock = new Lock();
static void Thread_1()
{
for (int i = 0; i < 1000; i++)
{
_lock.Acquire();
_num++;
_lock.Release();
}
}
static void Thread_2()
{
for (int i = 0; i < 1000; i++)
{
_lock.Acquire();
_num--;
_lock.Release();
}
}
static void Main(string[] args)
{
Task t1 = new Task(Thread_1);
Task t2 = new Task(Thread_2);
t1.Start();
t2.Start();
Task.WaitAll(t1, t2);
Console.WriteLine(_num);
}
}
}
Mutex를 통한 lock 구현
using System;
using System.Threading;
using System.Threading.Tasks;
namespace ServerCore
{
class Program
{
static int _num = 0;
static Mutex _lock = new Mutex();
static void Thread_1()
{
for (int i = 0; i < 1000; i++)
{
_lock.WaitOne(); // 입장 시도 (입장 성공시 문을 잠근다.)
_num++;
_lock.ReleaseMutex(); // 나가면서 문의 잠금을 해제한다.
}
}
static void Thread_2()
{
for (int i = 0; i < 1000; i++)
{
_lock.WaitOne(); // 입장 시도 (입장 성공시 문을 잠근다.)
_num--;
_lock.ReleaseMutex(); // 나가면서 문의 잠금을 해제한다.
}
}
static void Main(string[] args)
{
Task t1 = new Task(Thread_1);
Task t2 = new Task(Thread_2);
t1.Start();
t2.Start();
Task.WaitAll(t1, t2);
Console.WriteLine(_num);
}
}
}
+ 추가 정리
- ManualResetEvent 사용 이유?
~> ManualResetEvent는 1개의 Thread만 통과시키고 문을 닫는 AutoResetEvent와 달리, 문이 1번 열리면 대기중이던
여러개의 Thread 를 동시에 수행하고자 할 때 유용하다.
- AutoResetEvent와 Mutex의 차이?
~> Mutex는 AutoResetEvent 보다 더 많은 정보를 가진다. (잠금 횟수, Thread ID 등...) 이에 따른 추가비용도 존재한다.
# ReaderWriterLock
ReaderWriterLock 응용
using System;
using System.Threading;
using System.Threading.Tasks;
namespace ServerCore
{
class Program
{
static ReaderWriterLockSlim _lock = new ReaderWriterLockSlim();
// 예를 들어 게임에서 일일퀘스트 보상 지급시 코인, 경험치, 물약은 고정적인 보상이며
// 주말, 명절 이벤트로 일일 퀘스트 보상에 +a 정도의 보상이 추가로 지급된다고 가정하자.
// 보상을 얻는 함수는 수없이 실행될거고, 보상을 추가하는 함수는 가끔 실행될 것이다.
// 따라서 보상을 추가하는 함수의 lock은 실행될 확률이 1.0%, 실행되지 않을 확률이 99.0% 이다.
// 이럴때 쓰는 lock이 ReaderWriterLock이다. (ReaderWriterLockSlim이 더 최신버전)
class Reward
{
}
static Reward GetRewardById(int id)
{
_lock.EnterReadLock(); // 아무도 WriteLock 을 잡고 있지 않은 경우에 마치 lock이 없는것처럼 Thread들이 동시다발적으로 들어올 수 있다.
_lock.ExitReadLock();
return null;
}
void AddReward(Reward reward)
{
_lock.EnterWriteLock(); // 1번에 1개의 Thread만 휙득할 수 있다.
_lock.ExitWriteLock();
}
static void Main(string[] args)
{
}
}
}
- Thread 간에 공유되는 데이터가 존재할 경우, 항상 모든 Thread가 해당 데이터를 읽고 쓰는 것은 아니다.
~> 어떤 Thread는 해당 데이터를 읽기만 하고, 어떤 Thread는 해당 데이터를 쓰기만 할 수도 있다.
~> 또한 다수의 읽기 Thread, 소수의 쓰기 Thread가 수행되는 경우도 존재한다.
~> 이럴때 일반적인 lock을 구현하여 읽기/쓰기가 수행되는 동안에 항상 lock을 설정/해제할 경우
데이터를 단순히 읽기만 하여 값이 변경되지 않는 상황에도 불필요하게 Critical Section을 만들게 되므로
성능 관점에서 손해다.
~> ReaderWriterlock 을 통해 해결이 가능하다.
- ReaderWriterlock은 데이터에 대한 쓰기 작업시에만 lock을 설정하고 읽기 작업시에는 lock을 설정하지 않는
비대칭적인 lock을 구현하여 성능 관점에서 이득을 취했다.
# ReaderWriterLock 구현 연습
재귀적 lock을 허용하지 않는 ReaderWriterLock 구현
using System;
using System.Threading;
using System.Threading.Tasks;
namespace ServerCore
{
// 재귀적 lock을 허용할지 (No)
// Spinlock 정책 (5000번 Spin 후 Yield)
class Lock
{
const int EMPTY_FLAG = 0x00000000;
const int WRITE_MASK = 0X7FFF0000; // WRITE 부분만 추출하기 위한 MASK (AND 연산을 통한 추출)
const int READ_MASK = 0x0000FFFF; // READ 부분만 추출하기 위한 MASK (AND 연산을 통한 추출)
const int MAX_SPIN_COUNT = 5000;
// [ Unused (1bit) ] [ WriteThreadId (15bit) ] [ ReadCount (16bit) ] ~> 32bit (int형)
// Unused : 최상위 bit는 부호 bit이므로 음수가 될 가능성이 있어 사용 X / WriteThreadId : Write lock을 가진 Thread의 Id / ReadCount : 읽기 Thread의 총 수
int _flag = EMPTY_FLAG;
public void WriteLock()
{
// 아무도 WriteLock of ReadLock을 휙득하고 있지 않을 때, 경합해서 소유권을 얻는다.
int desired = (Thread.CurrentThread.ManagedThreadId << 16) & WRITE_MASK; // WriteThreadId (15bit)에는 본인의 Thread Id를 , WriteThreadId (15bit)를 제외한 나머지 bit는 전부 0으로 설정
while (true)
{
for (int i = 0; i < MAX_SPIN_COUNT; i++)
{
// 시도를 해서 성공하면 return
if (Interlocked.CompareExchange(ref _flag, desired, EMPTY_FLAG) == EMPTY_FLAG)
return;
}
Thread.Yield();
}
}
public void WriteUnlock()
{
Interlocked.Exchange(ref _flag, EMPTY_FLAG);
}
public void ReadLock()
{
// 아무도 WriteLock을 휙득하고 있지 않을 때, ReadCount를 1 늘린다.
while (true)
{
for (int i = 0; i < MAX_SPIN_COUNT; i++)
{
int expected = (_flag & READ_MASK); // READ_MASK와 AND 연산시 WRITE 부분도 0 -> 아무도 WriteLock을 휙득하고 있지 X
if (Interlocked.CompareExchange(ref _flag, expected + 1, expected) == expected)
return;
// 위의 연산이 실패하는 경우 ?
// #1. 누군가 WriteLock을 휙득하고 있는 경우
// #2. A와 B Thread가 거의 동시에 도착하여 expected의 값은 동일하게 얻지만,
// A Thread가 먼저 CompareExchange 를 통해 expected의 값을 +1 할 경우
// B Thread가 CompareExchange 시 _flag의 값이 expected + 1 로 변경되어 요청이 실패한다.
}
Thread.Yield();
}
}
public void ReadUnlock()
{
Interlocked.Decrement(ref _flag);
}
}
}
재귀적 lock을 허용하는 ReaderWriterLock 구현
using System;
using System.Threading;
using System.Threading.Tasks;
namespace ServerCore
{
// 재귀적 lock을 허용할지 (Yes) : WriteLock ~> WriteLock (OK), WriteLock ~> ReadLock (OK), ReadLock ~> WriteLock (Not OK)
class Lock
{
const int EMPTY_FLAG = 0x00000000;
const int WRITE_MASK = 0X7FFF0000;
const int READ_MASK = 0x0000FFFF;
const int MAX_SPIN_COUNT = 5000;
int _flag = EMPTY_FLAG;
int _writeCount = 0; // 재귀적으로 몇개의 Write를 할 것인지
public void WriteLock()
{
// 동일 Thread가 WriteLock을 이미 휙득하고 있는지 확인
int lockThreadId = (_flag & WRITE_MASK);
if (Thread.CurrentThread.ManagedThreadId == lockThreadId)
{
_writeCount++;
return;
}
// 아무도 WriteLock of ReadLock을 휙득하고 있지 않을 때, 경합해서 소유권을 얻는다.
int desired = (Thread.CurrentThread.ManagedThreadId << 16) & WRITE_MASK; // WriteThreadId (15bit)에는 본인의 Thread Id를 , WriteThreadId (15bit)를 제외한 나머지 bit는 전부 0으로 설정
while (true)
{
for (int i = 0; i < MAX_SPIN_COUNT; i++)
{
// 시도를 해서 성공하면 return
if (Interlocked.CompareExchange(ref _flag, desired, EMPTY_FLAG) == EMPTY_FLAG)
{
_writeCount = 1;
return;
}
}
Thread.Yield();
}
}
public void WriteUnlock()
{
int lockCount = --_writeCount;
if (lockCount == 0)
Interlocked.Exchange(ref _flag, EMPTY_FLAG);
}
public void ReadLock()
{
// 동일 Thread가 WriteLock을 이미 휙득하고 있는지 확인
int lockThreadId = (_flag & WRITE_MASK);
if (Thread.CurrentThread.ManagedThreadId == lockThreadId)
{
Interlocked.Increment(ref _flag);
return;
}
// 아무도 WriteLock을 휙득하고 있지 않을 때, ReadCount를 1 늘린다.
while (true)
{
for (int i = 0; i < MAX_SPIN_COUNT; i++)
{
int expected = (_flag & READ_MASK); // READ_MASK와 AND 연산시 WRITE 부분도 0 -> 아무도 WriteLock을 휙득하고 있지 X
if (Interlocked.CompareExchange(ref _flag, expected + 1, expected) == expected)
return;
}
Thread.Yield();
}
}
public void ReadUnlock()
{
Interlocked.Decrement(ref _flag);
}
}
}
구현한 ReaderWriterLock 동작 결과 확인
using System;
using System.Threading;
using System.Threading.Tasks;
namespace ServerCore
{
class Program
{
static volatile int count = 0;
static Lock _lock = new Lock();
static void Main(string[] args)
{
Task t1 = new Task(delegate ()
{
for (int i = 0; i < 100000; i++)
{
_lock.WriteLock();
count++;
_lock.WriteUnlock();
}
});
Task t2 = new Task(delegate ()
{
for (int i = 0; i < 100000; i++)
{
_lock.WriteLock();
count--;
_lock.WriteUnlock();
}
});
t1.Start();
t2.Start();
Task.WaitAll(t1, t2);
Console.WriteLine(count);
}
}
}
# Thread Local Storage
- MultiThread 환경에서 수많은 Thread가 하나의 자원에 접근하고자 할 때 lock은 상호 배타적인 개념이기 때문에
1번에 1개의 작업만을 처리할 수 있다. 이에 MultiThread 환경의 장점을 활용하지 못할뿐더러 하나의 Thread가 처리하는
속도보다 못할 수도 있다.
~> Thread Local Storage를 통해 해결이 가능하다.
- Thread Local Storage는 전역변수이지만, Thread 마다 고유하게 접근할 수 있는 전역 변수이다.
Thread Local Storage 응용
using System;
using System.Threading;
using System.Threading.Tasks;
namespace ServerCore
{
class Program
{
// Thread 마다 고유하게 접근할 수 있는 전역변수
static ThreadLocal<string> ThreadName = new ThreadLocal<string>();
static void WhoAmI()
{
ThreadName.Value = $"My Name Is {Thread.CurrentThread.ManagedThreadId}";
Thread.Sleep(1000);
Console.WriteLine(ThreadName.Value);
}
static void Main(string[] args)
{
Parallel.Invoke(WhoAmI, WhoAmI, WhoAmI, WhoAmI); // 매개변수로 입력하는 Action 만큼 Task를 만들어준다.
ThreadName.Dispose(); // 자원 해제
}
}
}
보다 성능을 개선한 Thread Local Storage 응용
using System;
using System.Threading;
using System.Threading.Tasks;
namespace ServerCore
{
class Program
{
// Thread 마다 고유하게 접근할 수 있는 전역변수
// new ThreadLocal의 인자로 Func delegate를 받을 수 있다.
// 이를 통해 TLS가 새로 만들어질 경우 ThreadLocal.Value에 return 값을 넣어준 뒤 그대로 보관한다.
static ThreadLocal<string> ThreadName = new ThreadLocal<string>(() => { return $"My Name Is {Thread.CurrentThread.ManagedThreadId}"; });
static void WhoAmI()
{
// IsValueCreated는 ThreadLocal.Value의 값이 초기화 된 경우 true, 그렇지 않을 경우 false를 반환한다.
bool repeat = ThreadName.IsValueCreated;
if (repeat)
Console.WriteLine(ThreadName.Value + " (repeat)");
else
Console.WriteLine(ThreadName.Value);
}
static void Main(string[] args)
{
ThreadPool.SetMinThreads(1, 1);
ThreadPool.SetMaxThreads(3, 3);
Parallel.Invoke(WhoAmI, WhoAmI, WhoAmI, WhoAmI, WhoAmI, WhoAmI, WhoAmI, WhoAmI); // 매개변수로 입력하는 Action 만큼 Task를 만들어준다.
ThreadName.Dispose(); // 자원 해제
}
}
}
[ 섹션 2. 네트워크 프로그래밍 ]
# 네트워크 기초 이론
- 삼성 아파트 201호에 사는 사람이 삼성 아파트 102호에 사는 사람에게 택배를 보내고자 한다.
- 삼성 아파트 202호에 사는 사람이 현대 아파트 101호에 사는 사람에게 택배를 보내고자 한다.
~> 이때 본인이 직접 택배를 배송하는 것이 아닌 우선 경비실에 본인이 보내고자 하는 택배를 전달한다.
~> 경비실에서 보내는 사람과 받는 사람을 확인한 뒤, 만약 두 사람이 사는 아파트가 같다면 경비 아저씨가 직접
택배를 전달해주고, 같지 않다면 택배 배송센터로 해당 택배를 전달한다.
~> 택배 배송 센터에서 받는 사람이 거주하는 아파트의 경비실에 택배를 전달하면, 경비아저씨가 직접 택배를
전달해준다.
- 위의 내용에서 고객은 단말기, 경비실은 스위치, 택배 배송센터는 라우터, 아파트 단지는 네트워크에 해당한다.
# 통신 모델
- 게임의 규모, 장르, 개발자의 역량에 따라 Protocol을 선택하는 것이 중요하다.
~> MMO RPG는 주로 TCP, Latency가 중요한 FPS는 주로 UDP를 사용한다.
~> TCP는 느린 속도와 높은 신뢰성을 가지고, UDP는 빠른 속도와 낮은 신뢰성을 가진다.
# 소켓 프로그래밍 입문 #1
- 손님은 본인의 핸드폰을 통해 식당에 전화를 걸어 입장이 가능한지 물어보고, 만약 입장이 가능한 경우 본인의 대리인을
식당에 입장 시킨다. 손님은 대리인을 통해 식당과 대화가 가능하다.
- 식당은 우선 문지기를 고용하고, 해당 문지기에게 손님들의 입장 문의 전화를 받을 수 있는 식당 번호를 알려준다.
문지기는 손님 대리인을 통해 손님과 대화가 가능하다.
- 직원 고용 및 교육이 끝난 경우 식당은 영업 및 안내를 시작한다.
- 위의 내용에서손님은 Client, 대리인은 Session, 식당은 Server, 문지기는 Listener 소켓, 문지기 교육은 Bind,
영업 시작은 Listen, 안내는 Accept에 해당한다.
# 소켓 프로그래밍 입문 #2
- 우선 Test를 위해 [ 솔루션 ] - [ 오른쪽 마우스 ] - [ 속성 ] 의 [ 공용 속성 ] - [ 시작 프로젝트 ] 를 다음과 같이 설정한다.
Server 코드 구현
using System;
using System.Text;
using System.Net;
using System.Net.Sockets;
namespace ServerCore
{
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 번호를 매개변수로 입력
// 문지기 고용
Socket listenSocket = new Socket(endPoint.AddressFamily, SocketType.Stream, ProtocolType.Tcp);
try
{
// 문지기 교육
listenSocket.Bind(endPoint);
// 영업 시작
// backlog : 최대 대기 수
listenSocket.Listen(10); // backlog를 매개변수로 입력
while (true)
{
Console.WriteLine("Listening...");
// 손님을 입장시킨다
// Accept() 는 Blocking 함수이므로 Client가 입장하지 않을 경우 무한히 대기한다.
Socket clientSocket = listenSocket.Accept(); // 이때의 clientSocket은 대리인에 해당한다.
// 받는다
byte[] recvBuff = new byte[1024];
int recvBytes = clientSocket.Receive(recvBuff); // 몇 byte를 받았는지 int로 반환
string recvData = Encoding.UTF8.GetString(recvBuff, 0, recvBytes); // 어디에서, 어디부터, 얼만큼 받아올 것인지를 매개변수로 입력
Console.WriteLine($"[From Client] {recvData}");
// 보낸다
byte[] sendBuff = Encoding.UTF8.GetBytes("Welcome to MMORPG Server!");
clientSocket.Send(sendBuff); // 대리인을 통해 Client와 대화
// 쫓아낸다
clientSocket.Shutdown(SocketShutdown.Both); // 연결 해제를 미리 예고
clientSocket.Close();
}
}
catch (Exception e)
{
Console.WriteLine(e.ToString());
}
}
}
}
Client 코드 구현
using System;
using System.Text;
using System.Net;
using System.Net.Sockets;
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 번호를 매개변수로 입력
// 휴대폰 준비
Socket socket = new Socket(endPoint.AddressFamily, SocketType.Stream, ProtocolType.Tcp);
try
{
// 입장 문의
socket.Connect(endPoint);
Console.WriteLine($"Connected To {socket.RemoteEndPoint.ToString()}"); // RemoteEndPoint는 본인과 연결된 대상
// 보낸다
byte[] sendBuff = Encoding.UTF8.GetBytes("Hello World");
socket.Send(sendBuff);
// 받는다
byte[] recvBuff = new byte[1024];
int recvBytes = socket.Receive(recvBuff); // 몇 byte를 받았는지 int로 반환
string recvData = Encoding.UTF8.GetString(recvBuff, 0, recvBytes); // 어디에서, 어디부터, 얼만큼 받아올 것인지를 매개변수로 입력
Console.WriteLine($"[From Server] {recvData}");
// 나간다
socket.Shutdown(SocketShutdown.Both); // 연결 해제를 미리 예고
socket.Close();
}
catch (Exception e)
{
Console.WriteLine(e.ToString());
}
}
}
}
# Listener
- 위의 코드에서 Accept() 는 Blocking 함수이므로 Client가 입장하지 않을 경우 무한히 대기한다.
~> Accept() 뿐만이 아니라 Send(), Receive() 역시 Blocking 방식으로 구현 되었다.
~> 무수한 Client를 받기 위해 Blocking 함수를 채택하는 것은 바람직하지 않을 수 있다.
~> 이를 NonBlocking 방식으로 구현하여 해결할 수 있다.
- Blocking 과 NonBlocking 의 차이
~> 낚시로 비유를 하자면 Blocking 방식은 낚시대를 던져놓고, 물고기가 잡힐때까지 아무 것도 하지 않으면서 낚시대만
뜷어져라 쳐다보고 있는 것이다.
~> 그러나 NonBlocking 방식은 낚시대를 던져놓고, 던지자마자 입질이 올 경우 바로 낚시대를 끌어 올리지만
던진 그 순간에 바로 입질이 오지 않을 경우 본인이 할 다른 일들을 하고 있다가 입질이 올 경우 그제서야 돌아와
낚시대를 끌어올리는 것이다.
Accept를 NonBlocking 방식으로 구현하기 위한 Listener Class 생성
using System;
using System.Text;
using System.Net;
using System.Net.Sockets;
namespace ServerCore
{
internal class Listener
{
Socket _listenSocket;
Action<Socket> _onAcceptHandler;
public void Init(IPEndPoint endPoint, Action<Socket> onAcceptHandler) // Socket 함수의 매개변수를 위한 endPoint, _onAcceptHandler의 event 함수로 등록하기 위한 onAcceptHandler (delegate를 통해 함수를 인자로 받은 것)
{
// 문지기 고용
_listenSocket = new Socket(endPoint.AddressFamily, SocketType.Stream, ProtocolType.Tcp);
_onAcceptHandler += onAcceptHandler; // onAcceptHandler 함수가 _onAcceptHandler event를 구독 신청
// 문지기 교육
_listenSocket.Bind(endPoint);
// 영업 시작
// backlog : 최대 대기 수
_listenSocket.Listen(10); // backlog를 매개변수로 입력
// SocketAsyncEventArgs 는 일꾼 같은 친구임 ( Async 특징상 우리가 당장 값을 추출할 수 없었으니 이런 저런 정보들을 이 친구를 통해 전달받는 것 )
SocketAsyncEventArgs args = new SocketAsyncEventArgs(); // 한번만 만들어주면 계속해서 재사용이 가능하다는 장점을 가진다. ( args 라는 event를 생성 )
// AcceptAsync() 가 완료된 경우 우리에게 call back 방식으로 연락을 줄 수 있도록 event 사용 (딴 일을 하고 있다가 입질이 올 경우 args가 OnAcceptCompleted 함수를 자동으로 호출)
args.Completed += new EventHandler<SocketAsyncEventArgs>(OnAcceptCompleted); // OnAcceptCompleted 메소드가 args Completed event를 구독 신청
// *** 낚시대를 던진다 ***
RegisterAccept(args); // 최초 1번은 직접 등록해준다.
}
void RegisterAccept(SocketAsyncEventArgs args) // AcceptAsync 함수의 매개변수를 위한 args
{
args.AcceptSocket = null; // 초기화 하지 않을 경우 Crash 발생
bool pending = _listenSocket.AcceptAsync(args); // 비동기 Accept() 함수
// *** 낚시대를 던지자마자 물고기가 잡힌 경우 ***
if (pending == false) // AcceptAsync를 실행하는 동시에 Client로부터 접속 요청이 와서 pending 없이 완료된 경우
OnAcceptCompleted(null, args); // 직접 OnAcceptCompleted 함수를 호출
// 만약 pending이 true인 경우 당장은 완료되지 않았지만 나중에라도 완료가 된다면 위의 args가 자동으로 본인을 구독 신청한 구독자들에게 알려준다. (OnAcceptCompleted 함수를 호출)
}
void OnAcceptCompleted(object sender, SocketAsyncEventArgs args) // call back 함수로 등록하기 위한 매개변수 sender와 args ( 즉, 형식을 맞춰주기 위한 것 )
{
// *** 낚은 물고기를 빼낸다 ***
if (args.SocketError == SocketError.Success) // Client로부터 접속 요청이 와서 Accept까지 성공적으로 마친 경우
{
_onAcceptHandler.Invoke(args.AcceptSocket); // _onAcceptHandler가 본인을 구독 신청한 구독자들에게 알려준다. (onAcceptHandler 함수를 호출) (args.AcceptSocket 는 ClientSocket을 뱉어준다.)
}
else
Console.WriteLine(args.SocketError.ToString());
// *** 다시 낚시대를 던진다 ***
RegisterAccept(args); // 여기까지 도달한 경우 다음 Client를 위해서 다시 등록해주는 것
}
}
}
Accept를 NonBlocking 방식으로 구현하기 위한 Server 코드 수정
using System;
using System.Text;
using System.Net;
using System.Net.Sockets;
namespace ServerCore
{
class Program
{
static Listener _listener = new Listener();
static void OnAcceptHandler(Socket clientSocket) // Client Accept가 완료된 경우
{
try
{
// 받는다
byte[] recvBuff = new byte[1024];
int recvBytes = clientSocket.Receive(recvBuff); // 몇 byte를 받았는지 int로 반환
string recvData = Encoding.UTF8.GetString(recvBuff, 0, recvBytes); // 어디에서, 어디부터, 얼만큼 받아올 것인지를 매개변수로 입력
Console.WriteLine($"[From Client] {recvData}");
// 보낸다
byte[] sendBuff = Encoding.UTF8.GetBytes("Welcome to MMORPG Server!");
clientSocket.Send(sendBuff);
// 쫓아낸다
clientSocket.Shutdown(SocketShutdown.Both); // 미리 예고
clientSocket.Close();
}
catch (Exception e)
{
Console.WriteLine(e.ToString());
}
}
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 번호를 매개변수로 입력
// 손님을 입장시킨다.
_listener.Init(endPoint, OnAcceptHandler); // 문지기에게 endPoint 전달, 혹시라도 누군가 접속 요청 후 Accept 완료시 call back을 받기 위해 OnAcceptHandler를 event 함수로 등록하고자 전달
Console.WriteLine("Listening...");
while (true)
{
}
}
}
}
+ 추가 정리
# Session #1
Receive를 NonBlocking 방식으로 구현하기 위한 Session Class 생성
using System;
using System.Text;
using System.Net;
using System.Net.Sockets;
namespace ServerCore
{
class Session
{
Socket _socket;
int _disconnected = 0; // 끊김의 여부를 판단할 flag (MultiThread 환경에서 Disconnect() 를 2번 이상 호출하는 것을 방지)
public void Start(Socket socket)
{
_socket = socket;
SocketAsyncEventArgs recvArgs = new SocketAsyncEventArgs();
// ReceiveAsync() 가 완료된 경우 우리에게 call back 방식으로 연락을 줄 수 있도록 event 사용 (딴 일을 하고 있다가 데이터가 올 경우 recvArgs가 OnRecvCompleted 함수를 자동으로 호출)
recvArgs.Completed += new EventHandler<SocketAsyncEventArgs>(OnRecvCompleted);
// 데이터를 받기 위한 Buffer 설정
recvArgs.SetBuffer(new byte[1024], 0, 1024);
RegisterRecv(recvArgs);
}
public void Send(byte[] sendBuff)
{
_socket.Send(sendBuff);
}
public void Disconnect()
{
if (Interlocked.Exchange(ref _disconnected, 1) == 1)
return;
_socket.Shutdown(SocketShutdown.Both); // 미리 예고
_socket.Close();
}
void RegisterRecv(SocketAsyncEventArgs args)
{
bool pending = _socket.ReceiveAsync(args); // 비동기 Receive() 함수
if (pending == false) // ReceiveAsync를 실행하는 동시에 받고자 하는 데이터가 존재하여 pending 없이 완료된 경우
OnRecvCompleted(null, args); // 직접 OnRecvCompleted 함수를 호출
}
void OnRecvCompleted(object sender, SocketAsyncEventArgs args)
{
if (args.BytesTransferred > 0 && args.SocketError == SocketError.Success) // 데이터를 성공적으로 받은 경우
{
try
{
string recvData = Encoding.UTF8.GetString(args.Buffer, args.Offset, args.BytesTransferred);
Console.WriteLine($"[From Client] {recvData}");
RegisterRecv(args);
}
catch (Exception e)
{
Console.WriteLine($"OnRecvCompleted Failed {e}");
}
}
else
{
Disconnect();
}
}
}
}
Receive를 NonBlocking 방식으로 구현하기 위한 Server 코드 수정
using System;
using System.Text;
using System.Net;
using System.Net.Sockets;
namespace ServerCore
{
class Program
{
static Listener _listener = new Listener();
static void OnAcceptHandler(Socket clientSocket) // Client Accept가 완료된 경우
{
try
{
// 받는다.
Session session = new Session(); // Session Class 객체 생성
session.Start(clientSocket);
// 보낸다.
byte[] sendBuff = Encoding.UTF8.GetBytes("Welcome to MMORPG Server!");
session.Send(sendBuff);
// 쫓아낸다.
session.Disconnect();
}
catch (Exception e)
{
Console.WriteLine(e.ToString());
}
}
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 번호를 매개변수로 입력
// 손님을 입장시킨다.
_listener.Init(endPoint, OnAcceptHandler); // 문지기에게 endPoint 전달, 혹시라도 누군가 접속 요청 후 Accept 완료시 call back을 받기 위해 OnAcceptHandler를 event 함수로 등록하고자 전달
Console.WriteLine("Listening...");
while (true)
{
}
}
}
}
# Session #2
- Send는 시점이 정해져 있지 않다.
~> Send시 보내줄 Buffer와 Msg를 같이 설정해줘야 하는데 미래에 어떤 Msg를 보낼지 모르기 때문에
Receive와 같은 방식으로 접근할 수 없다.
- 또한 Send는 OnSendCompleted() 호출 후 RegisterSend(sendArgs) 를 통해 다시 등록하는 것이 불가능하다.
~> 이는 똑같은 정보를 다시 보내는 것이기 때문이며, 따라서 재사용이 불가능하다.
- 마지막으로 MultiThread 환경에서 동시다발적으로 Send() 를 호출시 매번 RegisterSend() 가 호출되어 SendAsync를
매번 호출하게 되는데 이는 네트워크 과부화를 유발한다.
~> 이는 운영체제가 Kernel에서 처리하기 때문이다.
- 이를 바탕으로 Send는 다음과 같이 구현할 예정이다.
~> _sendArgs를 전역변수로 선언하여 재사용이 가능하도록 만들 것이다.
~> SendAsync를 매번 호출하는 것이 아닌 _sendQueue에 데이터가 어느정도 모일 경우 호출할 것이다.
Send를 NonBlocking 방식으로 구현하기 위한 Session Class 수정
using System;
using System.Text;
using System.Net;
using System.Net.Sockets;
namespace ServerCore
{
class Session
{
// ...
object _lock = new object();
Queue<byte[]> _sendQueue = new Queue<byte[]>();
bool _pending = false; // pending 여부 판단 (true인 경우 누군가 보내고 있는 것, false인 경우 아무도 보내고 있지 않은 것)
SocketAsyncEventArgs _sendArgs = new SocketAsyncEventArgs(); // Send는 언제 호출될지 모르기 때문에 전역 변수로 선언
public void Start(Socket socket)
{
// ...
// SendAsync() 가 완료된 경우 우리에게 call back 방식으로 연락을 줄 수 있도록 event 사용 (딴 일을 하고 있다가 데이터를 보낼 경우 _sendArgs가 OnSendCompleted 함수를 자동으로 호출)
_sendArgs.Completed += new EventHandler<SocketAsyncEventArgs>(OnSendCompleted);
// ...
}
public void Send(byte[] sendBuff)
{
lock (_lock)
{
// 누군가 RegisterSend()를 하고 있어 SendAsync() 가 끝난 뒤 OnSendCompleted() 가 완료되기 전까지는 보내지않고 sendBuff를 Queue에 넣고 종료
// ~> 즉, 누군가 보내고 있으면 _pending 은 true이므로 본인은 보내지 않고 Queue에 집어넣기만 하는 것
_sendQueue.Enqueue(sendBuff);
if (_pending == false) // RegisterSend() 를 가장 먼저 호출하여 전송까지 할 수 있는 상태인 경우
RegisterSend();
}
}
public void Disconnect()
{
if (Interlocked.Exchange(ref _disconnected, 1) == 1)
return;
_socket.Shutdown(SocketShutdown.Both); // 미리 예고
_socket.Close();
}
void RegisterSend()
{
_pending = true;
byte[] buff = _sendQueue.Dequeue();
_sendArgs.SetBuffer(buff, 0, buff.Length);
bool pending = _socket.SendAsync(_sendArgs); // 비동기 Send() 함수
if (pending == false) // SendAsync를 실행하는 동시에 받고자 하는 데이터가 존재하여 pending 없이 완료된 경우
OnSendCompleted(null, _sendArgs);
}
void OnSendCompleted(object sender, SocketAsyncEventArgs args)
{
lock (_lock) // 오직 RegisterSend() 를 통해서만 OnSendCompleted() 가 호출되는 경우에는 필요 X (그러나, 우린 call back을 통해서도 호출 O)
{
if (args.BytesTransferred > 0 && args.SocketError == SocketError.Success) // 데이터를 성공적으로 보낸 경우
{
try
{
// _pending이 false인 상황에서 A Thread가 Send()시 RegisterSend() 를 호출하게 되는데
// 만약 pending이 true여서 OnSendCompleted() 가 바로 호출되는 것이 아닌 call back을 통해 나중에 호출될 경우를 가정하자.
// 이러한 상황에서 만약 다른 Thread들이 Send()시 _pending이 true이기 때문에 RegisterSend() 를 호출하지 않고 Queue에 집어넣기만 한다.
// 이때 A Thread가 OnSendCompleted() 호출시 본인 데이터뿐만이 아닌 Queue에 있는 데이터들까지 처리해준다.
// ~> 즉, 가장 첫번째 예약 대기자가 본인의 예약을 기다리는 동안, 그 후에 예약을 건 사람들의 몫까지 처리해주는 것이다.
if (_sendQueue.Count > 0) // Queue에 보내야 할 데이터가 남아있는 경우
RegisterSend(); // 다음 전송을 위해 RegisterSend() 호출
else // Queue에 보내야 할 데이터가 남아있지 않은 경우
_pending = false;
}
catch (Exception e)
{
Console.WriteLine($"OnSendCompleted Failed {e}");
}
}
else
{
Disconnect();
}
}
}
// ...
}
}
+ 추가 정리
- 어차피 OnSendCompleted()에서 _sendQueue.Count가 0보다 큰 경우 RegisterSend()를 계속해서 호출하게 되는데
그렇게 되면 결국에는 성능적인 관점에서 달라진 것은 거의 없다고 볼 수 있다.
~> 100명의 사람이 Send() 를 호출시 SendAsync() 도 언젠가는 100번 호출 되기 때문이다.
~> 즉, 위의 코드가 완전한 해결책이라고는 보기 어렵다.
~> 따라서 위의 코드는 1차적으로 Send() 를 Async 계열로 바꿨다는 것에 의의를 가진다.
# Session #3
- 위의 코드에서 아쉬운 점은 _sendQueue에 있는 데이터 1개당 SendAsync() 를 1번씩 호출하고 있다는 것이다.
~> BufferList를 통해 해결이 가능하다.
BufferList를 통해 NonBlocking 방식의 Send를 개선하기 위한 Session Class 수정
using System;
using System.Text;
using System.Net;
using System.Net.Sockets;
namespace ServerCore
{
class Session
{
// ...
// _pendingList 사용시 _pending 변수도 필요 X
SocketAsyncEventArgs _sendArgs = new SocketAsyncEventArgs();
SocketAsyncEventArgs _recvArgs = new SocketAsyncEventArgs(); // Receive도 Send와 마찬가지로 전역변수로 선언해도 무방
List<ArraySegment<byte>> _pendingList = new List<ArraySegment<byte>>(); // 대기중인 목록들을 담기 위한 List
// ...
public void Send(byte[] sendBuff)
{
lock (_lock)
{
_sendQueue.Enqueue(sendBuff);
// _pending 변수 대신 _pendingList.Count 사용
if (_pendingList.Count == 0) // RegisterSend() 를 가장 먼저 호출하여 전송까지 할 수 있는 상태인 경우
RegisterSend();
}
}
// ...
void RegisterSend() // 해당 함수로 들어오는 시점에 _pendingList 는 언제나 null
{
// _sendQueue의 데이터들을 List로 연결하여 담아주게 되면 이를 SendAsync() 에 한번에 보낼 수 있다.
// ~> 즉 _sendQueue에 있는 데이터 1개당 SendAsync() 를 1번씩 호출하는 것을 보완
while (_sendQueue.Count > 0)
{
byte[] buff = _sendQueue.Dequeue();
// _sendArgs.BufferList에 직접 Add할 경우 오류 발생
_pendingList.Add(new ArraySegment<byte>(buff, 0, buff.Length)); // ArraySegment 는 어떤 배열의 일부를 나타내는 구조체이다.
}
_sendArgs.BufferList = _pendingList;
bool pending = _socket.SendAsync(_sendArgs);
if (pending == false)
OnSendCompleted(null, _sendArgs);
}
void OnSendCompleted(object sender, SocketAsyncEventArgs args)
{
lock (_lock)
{
if (args.BytesTransferred > 0 && args.SocketError == SocketError.Success) // 데이터들을 성공적으로 보낸 경우
{
try
{
// 초기화
_sendArgs.BufferList = null;
_pendingList.Clear();
if (_sendQueue.Count > 0)
RegisterSend();
}
catch (Exception e)
{
Console.WriteLine($"OnSendCompleted Failed {e}");
}
}
else
{
Disconnect();
}
}
}
// ...
}
}
+ 추가 정리
- 위의 코드 역시 완벽하다고 할 수 없다.
~> RegisterSend() 에서 _sendQueue를 무조건 비워 모든 정보를 한번에 보내고 있는데, 일정 짧은 시간 동안 몇 Byte를
보냈는지 추적하여 너무 과하게 보낸다 싶으면 쉬어 가며 보내는 것이 성능적으로 더 좋다.
~> 또한 상대방이 악의적으로 의미없는 정보를 담은 다수의 패킷을 보내는 경우 이를 체크하여 비정상적이다 싶으면
받지 않도록 하는 것이 좋다.
~> 또한 때에 따라서는 패킷 자체를 1번에 작은 단위로 보내는 것이 아닌 뭉쳐 보내야 하는 경우도 존재한다.
# Session #4
- 추후를 위해 Engine과 Content를 분리하고자 한다.
~> event handler 를 통한 방법과 상속을 통한 방법이 존재한다.
~> 상속을 통한 방법이 더 간단하기 때문에 Session을 상속 받은 GameSession Class를 생성하여
Engine과 Content를 분리해 볼 것이다.
- Content 에서는 Session을 상속받은 GameSession을 생성하여 실행하고자 하는 코드들을 추가하고,
Engine 에서는 GameSession 내의 함수들이 무엇을 실행하는지는 잘 모르겠지만 적절한 타이밍에 해당 함수들을
호출만 해주는 것이다.
Engine과 Content를 분리하기 위한 Session Class 수정
using System;
using System.Text;
using System.Net;
using System.Net.Sockets;
namespace ServerCore
{
abstract class Session
{
// ...
// Session을 상속받은 Class에서 구현하도록 abstract로 선언
// Engine에서 구현하는 것이 아닌 Content에서 구현하는 것이다.
// Engine에서는 해당 함수를 호출만 한다.
public abstract void OnConnected(EndPoint endPoint); // OnConnected() 함수는 엄밀히 말하면 Listener Class 에서 호출 (즉, Session Class 에서는 호출 X)
public abstract void OnRecv(ArraySegment<byte> buffer);
public abstract void OnSend(int numOfBytes);
public abstract void OnDisconnected(EndPoint endPoint);
// ...
public void Disconnect()
{
if (Interlocked.Exchange(ref _disconnected, 1) == 1)
return;
OnDisconnected(_socket.RemoteEndPoint); // OnDisconnected() 함수 호출 (RemoteEndPoint는 본인과 연결된 대상)
_socket.Shutdown(SocketShutdown.Both);
_socket.Close();
}
// ...
void OnSendCompleted(object sender, SocketAsyncEventArgs args)
{
lock (_lock)
{
if (args.BytesTransferred > 0 && args.SocketError == SocketError.Success)
{
try
{
_sendArgs.BufferList = null;
_pendingList.Clear();
OnSend(_sendArgs.BytesTransferred); // OnSend() 함수 호출
if (_sendQueue.Count > 0)
RegisterSend();
}
catch (Exception e)
{
Console.WriteLine($"OnSendCompleted Failed {e}");
}
}
else
{
Disconnect();
}
}
}
// ...
void OnRecvCompleted(object sender, SocketAsyncEventArgs args)
{
if (args.BytesTransferred > 0 && args.SocketError == SocketError.Success)
{
try
{
OnRecv(new ArraySegment<byte>(args.Buffer, args.Offset, args.BytesTransferred)); // OnRecv() 함수 호출
RegisterRecv();
}
catch (Exception e)
{
Console.WriteLine($"OnRecvCompleted Failed {e}");
}
}
else
{
Disconnect();
}
}
}
}
Engine과 Content를 분리하기 위한 Server 코드 수정
using System;
using System.Text;
using System.Net;
using System.Net.Sockets;
using static System.Collections.Specialized.BitVector32;
namespace ServerCore
{
class GameSession : Session // Session Class를 상속 받은 GameSession Class 생성
{
public override void OnConnected(EndPoint endPoint) // OnConnected() 함수 override
{
Console.WriteLine($"OnConnected : {endPoint}");
// 보낸다.
byte[] sendBuff = Encoding.UTF8.GetBytes("Welcome to MMORPG Server!");
Send(sendBuff);
Thread.Sleep(1000);
// 쫓아낸다.
Disconnect();
}
public override void OnDisconnected(EndPoint endPoint) // OnDisconnected() 함수 override
{
Console.WriteLine($"OnDisconnected : {endPoint}");
}
public override void OnRecv(ArraySegment<byte> buffer) // OnRecv() 함수 override
{
string recvData = Encoding.UTF8.GetString(buffer.Array, buffer.Offset, buffer.Count);
Console.WriteLine($"[From Client] {recvData}");
}
public override void OnSend(int numOfBytes) // OnSend() 함수 override
{
Console.WriteLine($"Transferred bytes : {numOfBytes}");
}
}
class Program
{
static Listener _listener = new Listener();
// 기존에는 OnAcceptHandler() 를 call back 하는 방식이였다.
// ~> 잘못된건 아니지만 Session Class 를 상속받은 다양한 Session 개념 등장으로 수정
// ~> OnAcceptHandler() 함수 내의 session.Start(clientSocket) 는 Content 보단 Engine 에 있는것이 적절
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);
// 손님을 입장시킨다.
// 따라서 어떤 Session을 만들지를 Init() 매개변수로 전달하면 Engine에서 이를 처리해주는 방식으로 수정
_listener.Init(endPoint, () => { return new GameSession(); }); // Func<Session> 을 Lambda 식으로 작성
Console.WriteLine("Listening...");
while (true)
{
}
}
}
}
Engine과 Content를 분리하기 위한 Listener Class 수정
using System;
using System.Text;
using System.Net;
using System.Net.Sockets;
namespace ServerCore
{
internal class Listener
{
Socket _listenSocket;
Func<Session> _sessionFactory; // Session을 어떤 방식으로, 어떤 Session 만들 것인지
// GameSession Class 는 Content 쪽에 존재하기 때문에
// Listenr와 Session과 같이 Engine 쪽에서 new 를 통해 생성하지 않도록
// Init()의 delegate 매개변수를 통해 어떤 Session을 만들지에 대한 함수를 인자로 받은 뒤
// 해당 함수를 _sessionFactory event 구독 신청을 통해 실행되도록 수정
public void Init(IPEndPoint endPoint, Func<Session> sessionFactory)
{
// 문지기 고용
_listenSocket = new Socket(endPoint.AddressFamily, SocketType.Stream, ProtocolType.Tcp);
_sessionFactory += sessionFactory; // sessionFactory 함수가 _sessionFactory event를 구독 신청
// 문지기 교육
_listenSocket.Bind(endPoint);
// 영업 시작
// backlog : 최대 대기 수
_listenSocket.Listen(10);
SocketAsyncEventArgs args = new SocketAsyncEventArgs();
args.Completed += new EventHandler<SocketAsyncEventArgs>(OnAcceptCompleted);
RegisterAccept(args);
}
void RegisterAccept(SocketAsyncEventArgs args)
{
args.AcceptSocket = null;
bool pending = _listenSocket.AcceptAsync(args);
if (pending == false)
OnAcceptCompleted(null, args);
}
void OnAcceptCompleted(object sender, SocketAsyncEventArgs args)
{
if (args.SocketError == SocketError.Success)
{
// 받는다.
Session session = _sessionFactory.Invoke(); // _sessionFactory 를 통한 GameSession 생성 (Content 딴에서 요구한 방식대로 Session 생성)
session.Start(args.AcceptSocket);
session.OnConnected(args.AcceptSocket.RemoteEndPoint);
}
else
Console.WriteLine(args.SocketError.ToString());
RegisterAccept(args);
}
}
}
# Connector
- 현재 DummyClient 에서 Connect() 는 Blocking 함수이다.
~> 게임에서 Blocking 함수 사용은 지양해야하므로 이를 NonBlocking 방식으로 구현하여 해결할 수 있다.
- 분산 Server 구현시 Server 끼리 통신하기 위해서는 한쪽은 Listener의 역할을, 한쪽은 Connector의 역할을 해야만 한다.
Connect를 NonBlocking 방식으로 구현하기 위한 Connector Class 생성
using System;
using System.Collections.Generic;
using System.Net;
using System.Net.Sockets;
using System.Text;
namespace ServerCore
{
class Connector
{
Func<Session> _sessionFactory; // Session을 어떤 방식으로, 어떤 Session 만들 것인지
public void Connect(IPEndPoint endPoint, Func<Session> sessionFactory)
{
// 휴대폰 설정
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; // UserToken을 통해 원하는 정보를 넘겨줄 수 있다.
// socket을 전역변수로 선언하는 것이 아닌 UserToken을 통해 정보를 넘겨주는 이유는
// Listener 가 계속해서 뺑뺑이를 돌며 여러명을 받을 수 있듯이
// Connector 역시 여러명을 받을 수 있기 때문이다.
RegisterConnect(args);
}
void RegisterConnect(SocketAsyncEventArgs args)
{
Socket socket = args.UserToken as Socket;
if (socket == null)
return;
bool pending = socket.ConnectAsync(args);
if (pending == false)
OnConnectCompleted(null, args);
}
void OnConnectCompleted(object sender, SocketAsyncEventArgs args)
{
if (args.SocketError == SocketError.Success)
{
Session session = _sessionFactory.Invoke(); // _sessionFactory 를 통한 Session 생성 (Content 딴에서 요구한 방식대로 Session 생성)
session.Start(args.ConnectSocket);
session.OnConnected(args.RemoteEndPoint);
}
else
{
Console.WriteLine($"OnConnectCompleted Fail: {args.SocketError}");
}
}
}
}
- 현재 Connector를 DummyClient 에서 사용할 수 없다. (Connector는 ServerCore의 Class)
~> 그렇다고 ServerCore의 Connector, Listener, Session Class를 복사하여 DummyClient 에 붙여넣는 것은 세련되지 X
- 추후에 DummyClient, Server, ServerCore 를 동시에 사용하게 된다.
~> 이때 ServerCore 는 실제로 실행되는 것이 아닌 라이브러리로만 사용할 예정이다.
- 우선 [ ServerCore 프로젝트 ] - [ 오른쪽 마우스 ] - [ 속성 ] 의 [ 애플리케이션 ] - [ 일반 ] 에서 출력 유형을
[ 콘솔 애플리케이션 ] 이 아닌 [ 클래스 라이브러리 ] 를 선택한다.
~> 클래스 라이브러리 선택시 해당 프로젝트는 독립적으로 실행할 수 없다.
~> 즉, 다른 프로젝트에 기생하여 간접적으로 실행될 수 있다.
- 그 후 [ DummyClient / Server 프로젝트 ] - [ 오른쪽 마우스 ] - [ 추가 ] - [ 프로젝트 참조 ] 의 [ 프로젝트 ] 에서 ServerCore
를 선택한다.
~> 이를 통해 DummyClient 와 Server 프로젝트는 ServerCore 라이브러리를 참조한다.
더이상 ServerCore의 Program Class 는 사용하지 않으므로 해당 내용을 Server의 Program Class로 복사한 뒤 삭제
(Server 입장에서 ServerCore는 다른 프로젝트이므로 ServerCore의 Class들의 보호 수준을 public으로 변경) (이를 통해 Server는 Content, ServerCore는 Engine의 역할을 맡은 것)
using System;
using System.Text;
using System.Net;
using System.Net.Sockets;
using ServerCore; // Servercore 라이브러리 참조
namespace Server
{
class GameSession : Session
{
public override void OnConnected(EndPoint endPoint)
{
Console.WriteLine($"OnConnected : {endPoint}");
// 보낸다.
byte[] sendBuff = Encoding.UTF8.GetBytes("Welcome to MMORPG Server!");
Send(sendBuff);
Thread.Sleep(1000);
// 쫓아낸다.
Disconnect();
}
public override void OnDisconnected(EndPoint endPoint)
{
Console.WriteLine($"OnDisconnected : {endPoint}");
}
public override void OnRecv(ArraySegment<byte> buffer)
{
string recvData = Encoding.UTF8.GetString(buffer.Array, buffer.Offset, buffer.Count);
Console.WriteLine($"[From Client] {recvData}");
}
public override void OnSend(int numOfBytes)
{
Console.WriteLine($"Transferred bytes : {numOfBytes}");
}
}
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 GameSession(); });
Console.WriteLine("Listening...");
while (true)
{
}
}
}
}
- [ 솔루션 ] - [ 오른쪽 마우스 ] - [ 속성 ] 의 [ 공용 속성 ] - [ 시작 프로젝트 ] 에서 [ 한 개의 시작 프로젝트 ] 가 아닌
[ 여러 개의 시작 프로젝트 ] 를 선택한 뒤 DummyClient 와 Server 프로젝트의 작업 상태를 [ 없음 ] 에서 [ 시작 ] 으로,
ServerCore 프로젝트의 작업 상태를 [ 시작 ] 에서 [ 없음 ] 으로 변경한다.
Connector Class 사용을 위한 Client 코드 수정
using System;
using System.Text;
using System.Net;
using System.Net.Sockets;
using ServerCore; // Servercore 라이브러리 참조
namespace DummyClient
{
// GameSession Class 추가
class GameSession : Session
{
public override void OnConnected(EndPoint endPoint)
{
Console.WriteLine($"OnConnected : {endPoint}");
// 보낸다
byte[] sendBuff = Encoding.UTF8.GetBytes("Hello World");
Send(sendBuff);
}
public override void OnDisconnected(EndPoint endPoint)
{
Console.WriteLine($"OnDisconnected : {endPoint}");
}
public override void OnRecv(ArraySegment<byte> buffer)
{
string recvData = Encoding.UTF8.GetString(buffer.Array, buffer.Offset, buffer.Count);
Console.WriteLine($"[From Server] {recvData}");
}
public override void OnSend(int numOfBytes)
{
Console.WriteLine($"Transferred bytes : {numOfBytes}");
}
}
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 Class 사용
Connector connector = new Connector();
connector.Connect(endPoint, () => { return new GameSession(); });
try
{
}
catch (Exception e)
{
Console.WriteLine(e.ToString());
}
}
}
}
# TCP vs UDP
- 게임에서는 패킷 단위로 통신이 이루어진다.
# RecvBuffer
- TCP 특성상 (일부만 보내는 흐름/혼잡제어) Client 에서 보낸 패킷이 100 Byte 라고 해서 100 Byte가 완전히 도착한다는
보장은 없다.
~> 100 Byte 미만의 패킷이 도착한 경우 이를 바로 처리할 수 없기 때문에 이를 recvBuffer에 보관만 하고 있다가
추후에 나머지 패킷이 마저 도착하면 이를 조립한 뒤 한번에 처리할 수 있도록 수정한다.
RecvBuffer 개선을 위한 RecvBuffer Class 생성
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace ServerCore
{
public class RecvBuffer
{
// byte 배열을 들고 있어도 되지만 byte 를 들고 있는 이유?
// ~> 엄청 큰 Byte 배열에서 부분적으로 잘라 사용하고 싶을 수도 있기 때문
ArraySegment<byte> _buffer;
// _readPos와 _writePos는 Buffer에서 커서 역할을 한다.
// Buffer의 크기는 8Byte, 1패킷이 3Byte 라고 가정했을때 아래의 예시를 살펴보자.
// [r w] [ ] [ ] [ ] [ ] [ ] [ ] [ ] (초기 상태)
// [r] [ ] [ ] [w] [ ] [ ] [ ] [ ] (Client로부터 3Byte를 받은 상태)
// [ ] [ ] [ ] [r w] [ ] [ ] [ ] [ ] (Content 코드에서 1패킷을 처리한 상태)
int _readPos;
int _writePos;
// 생성자
public RecvBuffer(int bufferSize)
{
_buffer = new ArraySegment<byte>(new byte[bufferSize], 0, bufferSize);
}
public int DataSize { get { return _writePos - _readPos; } }
public int FreeSize { get { return _buffer.Count - _writePos; } }
public ArraySegment<byte> ReadSegment // 현재까지 받은 데이터의 유효 범위가 어디서부터 어디까지인가?
{
get { return new ArraySegment<byte>(_buffer.Array, _buffer.Offset + _readPos, DataSize); }
}
public ArraySegment<byte> WriteSegment // 다음 Recv시 빈 공간의 유효 범위가 어디서부터 어디까지인가?
{
get { return new ArraySegment<byte>(_buffer.Array, _buffer.Offset + _writePos, FreeSize); }
}
// Clean 함수를 통해 Buffer를 정리하지 않을 경우 [ ] [ ] [ ] [ ] [ ] [ ] [r] [w] 와 같이 _readPos와 _writePos가 Buffer의 끝까지 밀릴 수 있다.
// _readPos와 _writePos의 위치가 다른 경우 _readPos 부터 _writePos - 1 까지의 데이터를 복사한 뒤 _writePos 를 이동시킨다.
// _readPos와 _writePos의 위치가 같은 경우 데이터를 복사하지 않고 _readPos 와 _writePos 를 Buffer의 시작 위치로 이동시킨다.
public void Clean()
{
int dataSize = DataSize;
if (dataSize == 0) // _readPos와 _writePos의 위치가 같은 경우 (Client에서 보낸 모든 데이터를 처리한 상태)
{
_readPos = _writePos = 0;
}
else // _readPos와 _writePos의 위치가 다른 경우
{
// 어느 배열의? 어디서부터? 어느 배열의? 어디로? 얼마만큼?
Array.Copy(_buffer.Array, _buffer.Offset + _readPos, _buffer.Array, _buffer.Offset, dataSize);
_readPos = 0;
_writePos = dataSize;
}
}
public bool OnRead(int numOfBytes) // Content 코드에서 성공적으로 데이터를 가공한 경우 해당 함수를 통해 커서 위치를 이동시킨다.
{
if (numOfBytes > DataSize)
return false;
_readPos += numOfBytes;
return true;
}
public bool OnWrite(int numOfBytes) // Client로부터 데이터를 받은 경우 해당 함수를 통해 커서 위치를 이동시킨다.
{
if (numOfBytes > FreeSize)
return false;
_writePos += numOfBytes;
return true;
}
}
}
RecvBuffer Class 생성에 따른 Session Class 수정
using System;
using System.Text;
using System.Net;
using System.Net.Sockets;
namespace ServerCore
{
public abstract class Session
{
// ...
// RecvBuffer Class 사용
RecvBuffer _recvBuffer = new RecvBuffer(1024);
// ...
public abstract int OnRecv(ArraySegment<byte> buffer); // 반환값을 void에서 int로 수정 (얼마만큼의 데이터를 처리했는지 반환하도록)
// ...
public void Start(Socket socket)
{
_socket = socket;
_recvArgs.Completed += new EventHandler<SocketAsyncEventArgs>(OnRecvCompleted);
// 아래 부분 삭제
// _recvArgs.SetBuffer(new byte[1024], 0, 1024);
_sendArgs.Completed += new EventHandler<SocketAsyncEventArgs>(OnSendCompleted);
RegisterRecv();
}
// ...
void RegisterRecv()
{
// 아래 부분 추가
_recvBuffer.Clean();
ArraySegment<byte> segment = _recvBuffer.WriteSegment;
_recvArgs.SetBuffer(segment.Array, segment.Offset, segment.Count);
bool pending = _socket.ReceiveAsync(_recvArgs);
if (pending == false)
OnRecvCompleted(null, _recvArgs);
}
void OnRecvCompleted(object sender, SocketAsyncEventArgs args)
{
if (args.BytesTransferred > 0 && args.SocketError == SocketError.Success)
{
try
{
// Write 커서 이동
if (_recvBuffer.OnWrite(args.BytesTransferred) == false) // BytesTransferred 는 수신 받은 Byte
{
Disconnect();
return;
}
// Content 쪽으로 데이터를 넘겨주고 얼마나 처리했는지 받는다.
int processLen = OnRecv(_recvBuffer.ReadSegment);
if (processLen < 0 | _recvBuffer.DataSize < processLen)
{
Disconnect();
return;
}
// Read 커서 이동
if (_recvBuffer.OnRead(processLen) == false)
{
Disconnect();
return;
}
RegisterRecv();
}
catch (Exception e)
{
Console.WriteLine($"OnRecvCompleted Failed {e}");
}
}
else
{
Disconnect();
}
}
}
}
Server 코드 수정
using System;
using System.Text;
using System.Net;
using System.Net.Sockets;
using ServerCore; // Servercore 라이브러리 참조
namespace Server
{
class GameSession : Session
{
// ...
// 반환값이 void 에서 int 로 수정됨에 따른 코드 수정
public override int OnRecv(ArraySegment<byte> buffer)
{
string recvData = Encoding.UTF8.GetString(buffer.Array, buffer.Offset, buffer.Count);
Console.WriteLine($"[From Client] {recvData}");
return buffer.Count;
}
// ...
}
// ...
}
Client 코드 수정
using System;
using System.Text;
using System.Net;
using System.Net.Sockets;
using ServerCore; // Servercore 라이브러리 참조
namespace DummyClient
{
class GameSession : Session
{
// ...
// 반환값이 void 에서 int 로 수정됨에 따른 코드 수정
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;
}
// ...
}
// ...
}
# SendBuffer
- Session 마다 자신의 고유 RecvBuffer를 가진다.
~> Client 가 보내는 정보는 각기 다 다르기 때문에 이는 당연한 사실이다.
- 그러나 sendBuffer는 Session 마다 고유하지 않고 보내는 순간에 외부에서 만들어진다.
~> 이는 성능적 이슈와 관련이 있다.
~> 만약 100명의 User가 같은 Zone 안에 있는 경우 User 1명이 이동시 해당 User의 이동 정보를 나머지 99명에게 전부
보내야 한다. 하지만 User 1명만 이동하는 것이 아니기 때문에 이동 패킷이 99 * 100 개가 전송되어야 한다.
- sendBuffer의 Size는 어떻게 결정해야 할까?
~> 만약 보내고자 하는 Class의 멤버 변수로 string 과 List 와 같이 가변적인 길이를 갖는다면 어떻게 해야 할까?
~> Thread 마다 자신만의 Chunk 를 크게 할당한 뒤 이를 계속해서 쪼개서 사용하도록 만든다.
SendBuffer 개선을 위한 SendBuffer Class 생성
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace ServerCore
{
public class SendBufferHelper
{
// ThreadLocal : 본인의 Thread 에서만 사용 가능한 전역 변수 (Thread 끼리의 경합을 없애고자 사용)
public static ThreadLocal<SendBuffer> CurrentBuffer = new ThreadLocal<SendBuffer>(() => { return null; });
public static int ChunkSize { get; set; } = 4096 * 100;
public static ArraySegment<byte> Open(int reserveSize)
{
if (CurrentBuffer.Value == null) // 한번도 사용하지 않은 경우
CurrentBuffer.Value = new SendBuffer(ChunkSize); // 새로 생성
if (CurrentBuffer.Value.FreeSize < reserveSize) // 사용한 적은 있지만 요구한 Size보다 FreeSize가 더 작은 경우
CurrentBuffer.Value = new SendBuffer(ChunkSize); // 기존 Chunk 를 날린뒤 새로운 Chunk 로 교체
return CurrentBuffer.Value.Open(reserveSize);
}
public static ArraySegment<byte> Close(int usedSize)
{
return CurrentBuffer.Value.Close(usedSize);
}
}
public class SendBuffer
{
byte[] _buffer;
// _usedSize는 Buffer에서 커서 역할을 한다.
// Buffer의 크기는 10Byte 라고 가정했을때 아래의 예시를 살펴보자.
// [u] [ ] [ ] [ ] [ ] [ ] [ ] [ ] [ ] [ ] (초기 상태)
int _usedSize = 0;
public int FreeSize { get { return _buffer.Length - _usedSize; } }
// 생성자
public SendBuffer(int chunkSize)
{
_buffer = new byte[chunkSize];
}
public ArraySegment<byte> Open(int reserveSize) // 최대 reserveSize 만큼의 Buffer 공간을 사용하고자 한다.
{
if (reserveSize > FreeSize)
return null;
return new ArraySegment<byte>(_buffer, _usedSize, reserveSize);
}
public ArraySegment<byte> Close(int usedSize) // 실제로 사용한 Buffer 공간을 반환하고자 한다.
{
ArraySegment<byte> segment = new ArraySegment<byte>(_buffer, _usedSize, usedSize);
_usedSize += usedSize;
return segment;
}
// Send는 1명이 아닌 여러명에게 보내는 경우도 있기 때문에 본인이 Send 하였다고 해서 막바로 Clean 할 수 X
// (옮기고자 하는 곳을 다른 누군가가 아직 참조중일 수도 있기 때문)
// 따라서 SendBuffer는 Clean 하는 것이 아닌 1회용으로 사용한다.
}
}
SendBuffer Class 생성에 따른 Server 코드 수정
using System;
using System.Text;
using System.Net;
using System.Net.Sockets;
using ServerCore; // Servercore 라이브러리 참조
namespace Server
{
class Knight
{
public int hp;
public int attack;
public string name;
public List<int> skills = new List<int>();
}
class GameSession : Session
{
public override void OnConnected(EndPoint endPoint)
{
Console.WriteLine($"OnConnected : {endPoint}");
Knight knight = new Knight() { hp = 100, attack = 10 };
ArraySegment<byte> openSegment = SendBufferHelper.Open(4096);
byte[] buffer = BitConverter.GetBytes(knight.hp);
byte[] buffer2 = BitConverter.GetBytes(knight.attack);
// 어느 배열의? 어디서부터? 어느 배열의? 어디로? 얼마만큼?
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(1000);
// 쫓아낸다.
Disconnect();
}
// ...
}
// ...
}
Session 코드 수정
using System;
using System.Text;
using System.Net;
using System.Net.Sockets;
namespace ServerCore
{
public abstract class Session
{
// ...
Queue<ArraySegment<byte>> _sendQueue = new Queue<ArraySegment<byte>>(); // Queue의 타입을 byte[] 에서 ArraySegment<byte> 로 수정
// ...
// 매개변수의 타입을 byte[] 에서 ArraySegment<byte> 로 수정
public void Send(ArraySegment<byte> sendBuff)
{
lock (_lock)
{
_sendQueue.Enqueue(sendBuff);
if (_pendingList.Count == 0)
RegisterSend();
}
}
// ...
void RegisterSend()
{
while (_sendQueue.Count > 0)
{
ArraySegment<byte> buff = _sendQueue.Dequeue(); // buff의 타입을 byte[] 에서 ArraySegment<byte> 로 수정
_pendingList.Add(buff);
}
_sendArgs.BufferList = _pendingList;
bool pending = _socket.SendAsync(_sendArgs);
if (pending == false)
OnSendCompleted(null, _sendArgs);
}
// ...
}
}
# PacketSession
- 게임에서는 패킷 단위로 통신이 이루어진다.
~> 패킷 전용 OnRecv 를 만들 필요가 있다.
Session 코드 수정
using System;
using System.Text;
using System.Net;
using System.Net.Sockets;
namespace ServerCore
{
// abstract class PacketSession 추가
public abstract class PacketSession : Session
{
public static readonly int HeaderSize = 2;
// 패킷을 받은 경우 [ size (2Byte) ] [ packetId (2Byte) ] [ ... ]
public sealed override int OnRecv(ArraySegment<byte> buffer) // sealed는 다른 Class가 PacketSession을 상속 받은 뒤 OnRecv를 override 하고자 할 경우 오류 발생
{
int processLen = 0; // 내가 몇 Byte 를 처리했는가
while (true) // 패킷을 처리할 수 있을때까지 계속해서 반복
{
// 최소한 Header 는 Parsing 할 수 있는지 확인
if (buffer.Count < HeaderSize)
break;
// 패킷이 완전체로 도착했는지 확인
ushort dataSize = BitConverter.ToUInt16(buffer.Array, buffer.Offset); // ToUInt16은 Byte 배열을 ushort로 뽑아달라는 것
if (buffer.Count < dataSize)
break;
// 여기까지 왔으면 패킷 조립 가능
OnRecvPacket(new ArraySegment<byte>(buffer.Array, buffer.Offset, dataSize)); // 패킷의 유효 범위를 넘겨준다.
processLen += dataSize;
buffer = new ArraySegment<byte>(buffer.Array, buffer.Offset + dataSize, buffer.Count - dataSize);
}
return processLen;
}
public abstract void OnRecvPacket(ArraySegment<byte> 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 Packet // 패킷 설계시 최대한 Size를 압축하여 보내는 것이 중요하다.
{
// Packet이 완전체로 왔는지? 잘려서 왔는지? 구분할 수 있어야 한다.
public ushort size; // ushort는 2Byte
public ushort packetId; // ushort는 2Byte
}
class GameSession : PacketSession // Session 이 아닌 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(1000);
// 쫓아낸다.
Disconnect();
}
// abstract 함수인 OnRecvPacket override 는 필수
public override void OnRecvPacket(ArraySegment<byte> buffer)
{
ushort size = BitConverter.ToUInt16(buffer.Array, buffer.Offset); // ToUInt16은 Byte 배열을 ushort로 뽑아달라는 것
ushort id = BitConverter.ToUInt16(buffer.Array, buffer.Offset + 2);
Console.WriteLine($"RecvPacketID: {id}, Size: {size}");
}
// Sealed 로 OnRecv 함수를 override 하는 것을 금지 시켰기 때문에 아래 부분 삭제
//public override int OnRecv(ArraySegment<byte> buffer)
//{
// string recvData = Encoding.UTF8.GetString(buffer.Array, buffer.Offset, buffer.Count);
// Console.WriteLine($"[From Client] {recvData}");
// return buffer.Count;
//}
// ...
}
// ...
}
Client 코드 수정
using System;
using System.Text;
using System.Net;
using System.Net.Sockets;
using System.Threading;
using ServerCore; // Servercore 라이브러리 참조
namespace DummyClient
{
class Packet
{
public ushort size; // ushort는 2Byte
public ushort packetId; // ushort는 2Byte
}
class GameSession : Session
{
public override void OnConnected(EndPoint endPoint)
{
Console.WriteLine($"OnConnected : {endPoint}");
Packet packet = new Packet() { size = 4, packetId = 7 };
for (int i = 0; i < 5; i++)
{
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(packet.size);
Send(sendBuff);
}
}
// ...
}
// ...
}
namespace CSharpStudy
{
class Program
{
static void Main(string[] args)
{
int number;
string input = Console.ReadLine(); // Enter를 누르는 순간에 변수에 저장된다.
number = int.Parse(input);
Console.WriteLine();
}
}
}
String Format 방식
namespace CSharpStudy
{
class Program
{
static void Main(string[] args)
{
int hp = 80;
int maxHp = 100;
string msg = string.Format("당신의 HP는 {0}/{1} 입니다.", hp, maxHp);
Console.WriteLine(msg);
}
}
}
String Interpolation 방식
namespace CSharpStudy
{
class Program
{
static void Main(string[] args)
{
int hp = 100;
int maxHp = 100;
// 엄밀히 말하면 Casting과는 거리가 멀긴 하다.
string msg = $"당신의 HP는 {hp}/{maxHp} 입니다.";
Console.WriteLine(msg);
}
}
}
# 산술 연산
삼항 연산자 응용
namespace CSharpStudy
{
class Program
{
static void Main(string[] args)
{
int number = 25;
bool isPair = ((number % 2 == 0) ? true : false);
}
}
}
# 데이터 마무리
-var Keyword를 통한 변수 선언시 Type을 직접 선언한 것처럼 컴파일러가 추론을 통해Type을 결정해준다.
~> var a = 10, var b = 4.14f, var c = "Hello", var d = true;
~> 그러나 가독성을 위해 Type을 직접 명시해주는 것이 좋다.
[ 섹션 2. 코드의 흐름 제어 ]
# switch
- switch문은 if/else문에 비해 응용 범위가 넓지 않다.
-어떤 case문에도 해당되지 않을 경우 default문이 실행된다.
- case문에는 반드시 변수가 아닌 상수를 넣어야 한다.
switch문 응용
namespace CSharpStudy
{
class Program
{
static void Main(string[] args)
{
int choice = 0; // 0: 가위, 1: 바위, 2: 보, 3: 치트키
switch (choice)
{
case 0:
Console.WriteLine("가위입니다.");
break;
case 1:
Console.WriteLine("바위입니다.");
break;
case 2:
Console.WriteLine("보입니다.");
break;
case 3:
Console.WriteLine("치크티입니다.");
break;
default:
Console.WriteLine("다 실패했습니다.");
break;
}
}
}
}
# 가위-바위-보 게임
간단한 가위-바위-보 게임 구현 #1
namespace CSharpStudy
{
class Program
{
static void Main(string[] args)
{
// 0: 가위, 1: 바위, 2: 보
Random rand = new Random();
int aiChoice = rand.Next(0, 3); // 0 ~ 2 사이의 랜덤 값
int choice = Convert.ToInt32(Console.ReadLine()); // 사용자로부터 입력 받은 값을 숫자로 변형하여 대입
switch (choice)
{
case 0:
Console.WriteLine("당신의 선택은 가위입니다.");
break;
case 1:
Console.WriteLine("당신의 선택은 바위입니다.");
break;
case 2:
Console.WriteLine("당신의 선택은 보입니다.");
break;
}
switch (aiChoice)
{
case 0:
Console.WriteLine("컴퓨터의 선택은 가위입니다.");
break;
case 1:
Console.WriteLine("컴퓨터의 선택은 바위입니다.");
break;
case 2:
Console.WriteLine("컴퓨터의 선택은 보입니다.");
break;
}
if (choice == 0)
{
if (aiChoice == 0)
Console.WriteLine("무승부입니다.");
else if (aiChoice == 1)
Console.WriteLine("패배입니다.");
else
Console.WriteLine("승리입니다.");
}
else if (choice == 1)
{
if (aiChoice == 0)
Console.WriteLine("승리입니다.");
else if (aiChoice == 1)
Console.WriteLine("무승부입니다.");
else
Console.WriteLine("패배입니다.");
}
else
{
if (aiChoice == 0)
Console.WriteLine("패배입니다.");
else if (aiChoice == 1)
Console.WriteLine("승리입니다.");
else
Console.WriteLine("무승부입니다.");
}
}
}
}
간단한 가위-바위-보 게임 구현 #2
namespace CSharpStudy
{
class Program
{
static void Main(string[] args)
{
// 0: 가위, 1: 바위, 2: 보
Random rand = new Random();
int aiChoice = rand.Next(0, 3); // 0 ~ 2 사이의 랜덤 값
int choice = Convert.ToInt32(Console.ReadLine()); // 사용자로부터 입력 받은 값을 숫자로 변형하여 대입
switch (choice)
{
case 0:
Console.WriteLine("당신의 선택은 가위입니다.");
break;
case 1:
Console.WriteLine("당신의 선택은 바위입니다.");
break;
case 2:
Console.WriteLine("당신의 선택은 보입니다.");
break;
}
switch (aiChoice)
{
case 0:
Console.WriteLine("컴퓨터의 선택은 가위입니다.");
break;
case 1:
Console.WriteLine("컴퓨터의 선택은 바위입니다.");
break;
case 2:
Console.WriteLine("컴퓨터의 선택은 보입니다.");
break;
}
if (choice == aiChoice)
Console.WriteLine("무승부입니다.");
else if (choice == 0 && aiChoice == 2)
Console.WriteLine("승리입니다.");
else if (choice == 1 && aiChoice == 0)
Console.WriteLine("승리입니다.");
else if (choice == 2 && aiChoice == 1)
Console.WriteLine("승리입니다.");
else
Console.WriteLine("패배입니다.");
}
}
}
# 상수와 열거형
- const Keyword를 통해상수 필드 또는 로컬 상수를 선언할 수 있다.
- enum Keyword는 열거형 상수를 표현하기 위한 것으로 이를 통해 상수 숫자들을 보다 의미있는 단어들로 표현할 수 있다.
~> 가독성 측면에서 도움이 된다.
~> 별도의 지정이 없을 경우 첫번째 요소는 0, 두번째 요소는 1등과 같이 1씩 증가된 값들을 할당받는다.
상수를 통한 가위-바위-보 게임 수정
namespace CSharpStudy
{
class Program
{
static void Main(string[] args)
{
const int ROCK = 1;
const int PAPER = 2;
const int SCISSORS = 0;
Random rand = new Random();
int aiChoice = rand.Next(0, 3); // 0 ~ 2 사이의 랜덤 값
int choice = Convert.ToInt32(Console.ReadLine()); // 사용자로부터 입력 받은 값을 숫자로 변형하여 대입
switch (choice)
{
case SCISSORS:
Console.WriteLine("당신의 선택은 가위입니다.");
break;
case ROCK:
Console.WriteLine("당신의 선택은 바위입니다.");
break;
case PAPER:
Console.WriteLine("당신의 선택은 보입니다.");
break;
}
switch (aiChoice)
{
case SCISSORS:
Console.WriteLine("컴퓨터의 선택은 가위입니다.");
break;
case ROCK:
Console.WriteLine("컴퓨터의 선택은 바위입니다.");
break;
case PAPER:
Console.WriteLine("컴퓨터의 선택은 보입니다.");
break;
}
if (choice == aiChoice)
Console.WriteLine("무승부입니다.");
else if (choice == SCISSORS && aiChoice == PAPER)
Console.WriteLine("승리입니다.");
else if (choice == ROCK && aiChoice == SCISSORS)
Console.WriteLine("승리입니다.");
else if (choice == PAPER && aiChoice == ROCK)
Console.WriteLine("승리입니다.");
else
Console.WriteLine("패배입니다.");
}
}
}
열거형을 통한 가위-바위-보 게임 수정
namespace CSharpStudy
{
class Program
{
enum Choice // 명시적으로 값을 선언하지 않을 경우 0부터 시작
{
Rock = 1,
Paper = 2,
Scissors = 0
}
static void Main(string[] args)
{
Random rand = new Random();
int aiChoice = rand.Next(0, 3); // 0 ~ 2 사이의 랜덤 값
int choice = Convert.ToInt32(Console.ReadLine()); // 사용자로부터 입력 받은 값을 숫자로 변형하여 대입
switch (choice)
{
case (int)Choice.Scissors:
Console.WriteLine("당신의 선택은 가위입니다.");
break;
case (int)Choice.Rock:
Console.WriteLine("당신의 선택은 바위입니다.");
break;
case (int)Choice.Paper:
Console.WriteLine("당신의 선택은 보입니다.");
break;
}
switch (aiChoice)
{
case (int)Choice.Scissors:
Console.WriteLine("컴퓨터의 선택은 가위입니다.");
break;
case (int)Choice.Rock:
Console.WriteLine("컴퓨터의 선택은 바위입니다.");
break;
case (int)Choice.Paper:
Console.WriteLine("컴퓨터의 선택은 보입니다.");
break;
}
if (choice == aiChoice)
Console.WriteLine("무승부입니다.");
else if (choice == (int)Choice.Scissors && aiChoice == (int)Choice.Paper)
Console.WriteLine("승리입니다.");
else if (choice == (int)Choice.Rock && aiChoice == (int)Choice.Scissors)
Console.WriteLine("승리입니다.");
else if (choice == (int)Choice.Paper && aiChoice == (int)Choice.Rock)
Console.WriteLine("승리입니다.");
else
Console.WriteLine("패배입니다.");
}
}
}
# while
do while문 응용
namespace CSharpStudy
{
class Program
{
static void Main(string[] args)
{
string answer;
// y를 입력할때까지 무한히 반복
do
{
Console.WriteLine("강사님은 잘생기셨나요? (y/n) : ");
answer = Console.ReadLine();
} while (answer != "y");
Console.WriteLine("정답입니다!");
}
}
}
# ref, out
-기본적으로 C#은 메소드에 매개변수를 전달할 때 call by value (값 전달) 을 한다.
~> 이때 ref Keyword를 통해 명시적으로 call by reference (참조 전달)을 할 수 있다.
ref Keyword 응용
namespace CSharpStudy
{
class Program
{
static void AddOne (ref int num)
{
num = num + 1;
}
static void Main(string[] args)
{
int a = 0;
Program.AddOne(ref a); // 같은 Class 내의 함수를 호출시 Program 생략 가능
Console.WriteLine(a);
}
}
}
-out Keyword는 매개변수 한정자로 변수가 참조로 전달이 된다.
~> out Keyword를 사용한 매개변수는 함수 내부에서 반드시 값을 초기화 시켜줘야 한다.
~> 여러개의 값을 반환해야 하는 경우에 유용하다.
out Keyword 응용
namespace CSharpStudy
{
class Program
{
// result1는 몫, result2는 나머지
static void Divide(int a, int b, out int result1, out int result2)
{
result1 = a / b;
result2 = a % b;
}
static void Main(string[] args)
{
int num1 = 10;
int num2 = 3;
int result1;
int result2;
Divide(10, 3, out result1, out result2);
Console.WriteLine(result1);
Console.WriteLine(result2);
}
}
}
# 오버로딩
-오버로딩의 사전적 의미는 "과적하다" 이며, 간단하게 말하면 함수 이름의 재사용이다.
~> 오버로딩시 하나의 메소드에 여러개의 구현을 과적할 수 있다.
~> 오버로딩은 같은 메소드 이름으로 매개변수의 개수, Type을 다르게 정의할 수 있다.
~> 반환 값은 오버로딩에 영향을 주지 않는다. (즉, 반환값만 달리 할 경우 오버로딩 X)
오버로딩 응용
namespace CSharpStudy
{
class Program
{
// 함수 이름의 재사용
static int Add(int a, int b)
{
return a + b;
}
static int Add(int a, int b, int c)
{
return a + b + c;
}
static float Add(float a, float b)
{
return a + b;
}
static void Main(string[] args)
{
int result = Program.Add(2, 3);
float result2 = Program.Add(2.0f, 3.0f);
}
}
}
- 선택적 매개변수란매개 변수를 필수 또는 선택 사항으로 지정할 수 있다.
선택적 매개변수 응용 #1
namespace CSharpStudy
{
class Program
{
// 선택적 매개변수
static int Add(int a, int b, int c = 0)
{
return a + b + c;
}
static void Main(string[] args)
{
int result = Program.Add(1, 2);
int result2 = Program.Add(1, 2, 3);
}
}
}
선택적 매개변수 응용 #2
namespace CSharpStudy
{
class Program
{
// 선택적 매개변수
static int Add(int a, int b, int c = 0, float d = 1.0f, double e = 3.0)
{
return a + b + c;
}
static void Main(string[] args)
{
int result = Program.Add(1, 2, d: 2.0f);
}
}
}
[ 섹션 3. TextRPG ]
# 디버깅 기초
1. 우선변수의 값, 메모리 동작 또는 코드 분기의 실행 여부를 확인하기 위해실행 중인 코드를 일시 중단해야 하는 위치에 중단점을 설정한다.
2. F5를 통해 디버깅을 시작할 수 있고, 디버깅이 시작된 뒤노란색 화살표는 일시 중지된 문을 가리킨다.
3. F10을 통해 프로시저 단위로 실행할 수 있고, F11을 통해 코드를한 단계씩실행할 수도 있다.
~> 프로시저는 메소드 (함수) 와 같다. (즉, 어떤 함수를 만나더라도 해당 함수로 들어가지 말고 결과만 보겠다는 것)
namespace CSharpStudy
{
class Program
{
// ...
struct Player
{
public int hp;
public int attack;
}
// ...
static void CreatePlayer(ClassType choice, out Player player)
{
switch (choice)
{
case ClassType.Knight:
player.hp = 100;
player.attack = 10;
break;
case ClassType.Archer:
player.hp = 75;
player.attack = 12;
break;
case ClassType.Mage:
player.hp = 50;
player.attack = 15;
break;
default:
player.hp = 0;
player.attack = 0;
break;
}
}
static void Main(string[] args)
{
while (true)
{
ClassType choice = ChooseClass();
if (choice != ClassType.None)
{
// 캐릭터 생성
Player player;
CreatePlayer(choice, out player);
}
}
}
}
}
# TextRPG 몬스터 생성
TextRPG 몬스터 생성
using System.Diagnostics.Tracing;
namespace CSharpStudy
{
class Program
{
// ...
enum MonsterType
{
None = 0,
Slime = 1,
Orc = 2,
Skeleton = 3
}
struct Monster
{
public int hp;
public int attack;
}
// ...
static void CreateRandomMonster(out Monster monster)
{
Random rand = new Random();
int randMonster = rand.Next(1, 4);
switch (randMonster)
{
case (int)MonsterType.Slime:
Console.WriteLine("슬라임이 스폰되었습니다!");
monster.hp = 20;
monster.attack = 2;
break;
case (int)MonsterType.Orc:
Console.WriteLine("오크가 스폰되었습니다!");
monster.hp = 40;
monster.attack = 4;
break;
case (int)MonsterType.Skeleton:
Console.WriteLine("스켈레톤이 스폰되었습니다!");
monster.hp = 30;
monster.attack = 3;
break;
default:
monster.hp = 0;
monster.attack = 0;
break;
}
}
static void EnterField()
{
Console.WriteLine("필드에 접속했습니다!");
// 랜덤으로 1~3 몬스터 중 하나를 스폰
Monster monster;
CreateRandomMonster(out monster);
Console.WriteLine("[1] 전투 모드로 돌입");
Console.WriteLine("[2] 일정 확률로 마을로 도망");
}
// ...
static void Main(string[] args)
{
while (true)
{
ClassType choice = ChooseClass();
if (choice != ClassType.None)
{
// 캐릭터 생성
Player player;
CreatePlayer(choice, out player);
// 게임 입장
EnterGame();
}
}
}
}
}
# TextRPG 전투
TextRPG 전투
using System.Diagnostics.Tracing;
using System.Numerics;
namespace CSharpStudy
{
class Program
{
// ...
static void Fight(ref Player player, ref Monster monster)
{
while (true)
{
// player가 monster 공격
monster.hp -= player.attack;
if (monster.hp <= 0)
{
Console.WriteLine("승리했습니다!");
Console.WriteLine($"남은 체력 : {player.hp}");
break;
}
// monster의 반격
player.hp -= monster.attack;
if (player.hp <= 0)
{
Console.WriteLine("패배했습니다!");
break;
}
}
}
static void EnterField(ref Player player)
{
while (true)
{
Console.WriteLine("필드에 접속했습니다!");
// 랜덤으로 1~3 몬스터 중 하나를 스폰
Monster monster;
CreateRandomMonster(out monster);
Console.WriteLine("[1] 전투 모드로 돌입");
Console.WriteLine("[2] 일정 확률로 마을로 도망");
string input = Console.ReadLine();
if (input == "1")
Fight(ref player, ref monster);
else if (input == "2")
{
// 도망칠 확률이 33%
Random rand = new Random();
int randValue = rand.Next(0, 101);
if (randValue <= 33)
{
Console.WriteLine("도망치는데 성공했습니다!");
}
else
Fight(ref player, ref monster);
}
}
}
static void EnterGame(ref Player player)
{
while (true)
{
Console.WriteLine("마을에 접속했습니다!");
Console.WriteLine("[1] 필드로 간다");
Console.WriteLine("[2] 로비로 돌아가기");
string input = Console.ReadLine();
if (input == "1")
EnterField(ref player);
else if (input == "2")
break;
}
}
static void Main(string[] args)
{
while (true)
{
ClassType choice = ChooseClass();
if (choice == ClassType.None)
continue;
// 캐릭터 생성
Player player;
CreatePlayer(choice, out player);
// 게임 입장
EnterGame(ref player);
}
}
}
}
[ 섹션 4. 객체지향 여행 ]
# 객체지향의 시작
- 객체 지향 프로그래밍 (Object Oriented Programming) 을 OOP 라고 한다.
- Object (객체) : Obejct는 설계도에 해당하는 Class를 통해 생성된 실체화된 것이다. (사람, 사물 등)
- Class (클래스) : Class는 Object를 만들기 위한 설계도와 같다.
~> 해당 객체가 어떤 속성과 기능을 가지는지 정의하는 역할을 한다.
- Instance (인스턴스) : Instance는 Class를 통해 Obejct를 생성한 결과물과 같다.
~> Class를 통해 Object를 생성하는 것을 인스턴스화 한다고 한다.
Class를 통해 Object를 인스턴스화 하기 위한 예제
namespace CSharpStudy
{
class Knight
{
public int hp;
public int attack;
public void Move()
{
Console.WriteLine("Knight Move");
}
public void Attack()
{
Console.WriteLine("Knight Attack");
}
}
class Program
{
static void Main(string[] args)
{
Knight knight = new Knight();
knight.hp = 100;
knight.attack = 10;
knight.Move();
knight.Attack();
}
}
}
# 복사(값)와 참조
Class와 Struct의 가장 큰 차이점
namespace CSharpStudy
{
class Knight
{
public int hp;
public int attack;
}
struct Mage
{
public int hp;
public int attack;
}
class Program
{
static void KillKnight(Knight night)
{
night.hp = 0;
}
static void KillMage(Mage mage)
{
mage.hp = 0;
}
static void Main(string[] args)
{
// struct는 복사
Mage mage; // struct는 = new Mage() 생략이 가능
mage.hp = 100;
mage.attack = 50;
KillMage(mage); // 함수 결과로 mage의 hp는 그대로 100
Mage mage2 = mage;
mage2.hp = 0; // 함수 결과로 mage의 hp는 그대로 100
// class는 참조
Knight knight = new Knight();
knight.hp = 100;
knight.attack = 10;
KillKnight(knight); // 함수 결과로 knight의 hp는 0
Knight knight2 = knight;
knight2.hp = 100; // 함수 결과로 knight의 hp는 100
}
}
}
얕은 복사와 깊은 복사의 차이
namespace CSharpStudy
{
class Knight
{
public int hp;
public int attack;
public Knight Clone()
{
Knight knight = new Knight();
knight.hp = hp;
knight.attack = attack;
return knight;
}
}
class Program
{
static void KillKnight(Knight night)
{
night.hp = 0;
}
static void Main(string[] args)
{
// 얕은 복사
Knight knight = new Knight();
knight.hp = 100;
knight.attack = 10;
Knight knight2 = knight;
knight2.hp = 0; // 함수 결과로 knight의 hp는 0
// 깊은 복사
Knight knight3 = knight.Clone();
knight2.hp = 100; // 함수 결과로 knight의 hp는 그대로 0
}
}
}
# 스택과 힙
- Stack과 Heap은 데이터를 위한 Memory 라는 공통점을 가지지만, 용도에 따라 구분된다.
- Stack은 메소드의 실행, 해당 메소드로 전달되는 매개변수, 메소드 내에서 사용되는 지역변수를 처리한다.
~> Stack은 불완전하고 일시적으로 사용하는 Memory로 메모장과 같은 존재다.
~> Stack 영역은 이름에서 알 수 있듯 자료구조에서 다루는 Stack과 동작방식이 같다. (LIFO)
~> Stack 영역은 메소드의 실행이 끝날 경우 해당 메소드와 관련된 영역은 해제된다.
~> new 연산자를 사용하여 객체 생성시 실제 데이터 자체는 Heap 영역에, 데이터 주소는 Stack 영역에 저장된다.
~> Stack 영역은 값 형식이다.
- Heap은 동적으로 할당되는 데이터와 객체들을 처리한다. (주로 Class)
~> Heap 영역은 new 연산자를 사용하여 객체를 생성하거나 메모리를 동적으로 할당시 사용된다.
~> C#은 C++과 달리 GC가 Heap 영역을 관리하여 Memory 누수 및 기타 문제를 방지한다.
(C++은 프로그래머가 직접 delete를 통해 해제를 해야지만 Memory 누수 및 기타 문제를 방지 가능)
~> Heap 영역은 참조 형식이다.
# 생성자
- 생성자는 반환 형식을 선언하지 않는다.
- this() 생성자는 생성자에서 본인의 다른 생성자를 호출한다.
- 매개변수를 받는 생성자를 생성시, 기본 생성자를 따로 만들어주지 않는 이상 기본 생성자는 사용이 불가능하다.
생성자 응용 및 this() 생성자 응용
namespace CSharpStudy
{
class Knight
{
public int hp;
public int attack;
public int defense;
public Knight()
{
hp = 100;
attack = 10;
defense = 10;
}
public Knight(int hp)
{
this.hp = hp;
}
public Knight(int hp, int attack) : this(hp)
{
this.attack = attack;
}
public Knight(int hp, int attack, int defense) : this(hp, attack)
{
this.defense = defense;
}
}
class Program
{
static void Main(string[] args)
{
Knight knight = new Knight();
}
}
}
# static의 정체
- Class 내부의 필드 값은 각 Instance 마다 제각각일 수 있다. (즉, 인스턴스마다 고유한 값을 가질 수 있다.)
~> Class를 통해 Instance가 생성될 때 각 객체마다 따로 생긴다.
- Class 내부에 static과 함께 선언한 필드와 메소드는 각 Instance에 종속되는 것이 아닌 해당 Class에 종속된다.
~> 해당 Class가 처음 사용될 때 한번 초기화 되며, 그 뒤로는 계속해서 동일한 Memory를 사용한다.
~> Class 내부에static과 함께 선언한 필드와 메소드 사용시 Class이름.필드/메소드이름 과 같이 접근해야한다.
~> static을 통해 Class로 부터 Instance를 생성하지 않고 필드와 메소드를 호출할 수 있다.
- Class 내부에 static과 함께 선언한 메소드 내부에서는 static 필드만 사용 가능하다. (Class의 Instance 필드 참조 불가능)
~> 다만 내부에서 새로운 객체 생성시 해당 객체의 필드 참조는 가능하다.
- static과 함께 선언한 Class는 모든 멤버가 static 필드, static 메소드로 이루어져 있으며, 객체를 생성할 수 없다.
static 응용
namespace CSharpStudy
{
class Knight
{
// 정적 필드
static public int counter = 1;
public int id;
public int hp;
public int attack;
// 정적 메소드
static public void AddCounter()
{
counter++;
}
// 정적 메소드 (static이라고 해서 Instance에 접근을 못하는 것은 X)
static public Knight CreateKnight()
{
Knight knight = new Knight(); // 다만 내부에서 새로운 객체를 생성해야 한다.
knight.hp = 100;
knight.attack = 5;
return knight;
}
public Knight()
{
id = counter++;
hp = 100;
attack = 10;
}
}
class Program
{
static void Main(string[] args)
{
Knight knight = Knight.CreateKnight();
}
}
}
# 상속성
- 상속성은 OOP 특징 중 하나이다.
- 부모 Class (기반 Class) 로부터 상속하여 새로운 자식 Class (파생 Class)를 만들 수 있다.
- 상속을 통해 부모 Class의 필드 및 메소드들을 자식 Class에서 사용할 수 있다.
상속 응용
namespace CSharpStudy
{
class Player // 부모 Class 또는 기반 Class
{
static public int counter = 1;
public int id;
public int hp;
public int attack;
public void Move()
{
Console.WriteLine("Player Move!");
}
public void Attack()
{
Console.WriteLine("Player Attack!");
}
}
class Knight : Player // 자식 Class 또는 파생 Class
{
public void Stun()
{
Console.WriteLine("Stun!");
}
}
class Program
{
static void Main(string[] args)
{
Knight knight = new Knight();
knight.Move();
knight.Attack();
}
}
}
상속시 생성자 응용 #1 (자식클래스가 생성될때 부모 Class의 생성자가 먼저 호출)
namespace CSharpStudy
{
class Player // 부모 Class 또는 기반 Class
{
static public int counter = 1;
public int id;
public int hp;
public int attack;
public Player()
{
Console.WriteLine("Player 생성자 호출!");
}
}
class Knight : Player // 자식 Class 또는 파생 Class
{
public Knight()
{
Console.WriteLine("Knight 생성자 호출!");
}
static public Knight CreateKnight()
{
Knight knight = new Knight();
knight.hp = 100;
knight.attack = 5;
return knight;
}
}
class Program
{
static void Main(string[] args)
{
Knight knight = Knight.CreateKnight();
}
}
}
상속시 생성자 응용 #2 (부모의 생성자가 여러개 존재할 경우 부모의 기본 생성자를 호출)
namespace CSharpStudy
{
class Player // 부모 Class 또는 기반 Class
{
static public int counter = 1;
public int id;
public int hp;
public int attack;
public Player()
{
Console.WriteLine("Player 생성자 호출!");
}
public Player(int hp)
{
this.hp = hp;
Console.WriteLine("Player hp 생성자 호출!");
}
}
class Knight : Player // 자식 Class 또는 파생 Class
{
public Knight()
{
Console.WriteLine("Knight 생성자 호출!");
}
static public Knight CreateKnight()
{
Knight knight = new Knight();
knight.hp = 100;
knight.attack = 5;
return knight;
}
}
class Program
{
static void Main(string[] args)
{
Knight knight = Knight.CreateKnight();
}
}
}
상속시 생성자 응용 #3 및 base() 생성자 응용
namespace CSharpStudy
{
class Player // 부모 Class 또는 기반 Class
{
static public int counter = 1;
public int id;
public int hp;
public int attack;
public Player()
{
Console.WriteLine("Player 생성자 호출!");
}
public Player(int hp)
{
this.hp = hp;
Console.WriteLine("Player hp 생성자 호출!");
}
}
class Knight : Player // 자식 Class 또는 파생 Class
{
public Knight() : base(100)
{
Console.WriteLine("Knight 생성자 호출!");
}
static public Knight CreateKnight()
{
Knight knight = new Knight();
knight.hp = 100;
knight.attack = 5;
return knight;
}
}
class Program
{
static void Main(string[] args)
{
Knight knight = Knight.CreateKnight();
}
}
}
# 은닉성
- 은닉성은 OOP 특징 중 하나이다.
- 사용자에게 필요한 최소의 기능만을 노출하고 내부를 감추는 것이다.
접근 제한자를 통한 은닉성 응용
namespace CSharpStudy
{
class Knight
{
int hp; // 접근 제한자를 선언하지 않은 경우 기본적으로 private
public void SetHp(int hp)
{
this.hp = hp;
}
}
class Program
{
static void Main(string[] args)
{
Knight knight = new Knight();
knight.SetHp(100);
}
}
}
namespace CSharpStudy
{
class Player
{
protected int hp;
protected int attack;
}
class Knight : Player
{
}
class Mage : Player
{
public int mp;
}
class Program
{
static void Main(string[] args)
{
Knight knight = new Knight();
Mage mage = new Mage();
// 자식 클래스 -> 부모 클래스 변환은 문제 X
Player player = knight;
// 부모 클래스 -> 자식 클래스 변환은 문제 발생
Mage otherMage = (Mage)player; // 프로그램을 실행해봐야 오류 발견 가능
}
}
}
is 연산자를 통한 클래스 형식 변환 예시 오류 수정
namespace CSharpStudy
{
class Player
{
protected int hp;
protected int attack;
}
class Knight : Player
{
}
class Mage : Player
{
public int mp;
}
class Program
{
static void EnterGame(Player player)
{
// is 문법을 통한 오류 수정
bool isMage = player is Mage;
if (isMage)
{
Mage mage = (Mage)player;
mage.mp = 10;
}
}
static void Main(string[] args)
{
Knight knight = new Knight();
Mage mage = new Mage();
EnterGame(knight);
}
}
}
as 연산자를 통한 클래스 형식 변환 예시 오류 수정
namespace CSharpStudy
{
class Player
{
protected int hp;
protected int attack;
}
class Knight : Player
{
}
class Mage : Player
{
public int mp;
}
class Program
{
static void EnterGame(Player player)
{
// as 문법을 통한 오류 수정
Mage mage = player as Mage;
if (mage != null)
{
mage.mp = 10;
}
}
static void Main(string[] args)
{
Knight knight = new Knight();
Mage mage = new Mage();
EnterGame(knight);
}
}
}
~> Casting이 가능한 경우 true를, 불가능한 경우 false를 return한다.
- as 연산자
~> Casting시 사용한다.
~> Casting에 성공할 경우 Casting 결과를, 실패할 경우 null을 return한다.
# 다형성
- 다형성은 OOP 특징 중 하나이다.
- 다형성은 Object가 여러 형태를 가질 수 있다는 것을 의미한다.
- virtual Keyword는 자식 Class에서 재정의 (override) 를 할 수 있도록 만들어준다.
- override Keyword는 부모 Class에서 virtual이나 abstract로 선언된 메소드나 프로퍼티를 재정의 (override) 한다.
virtual Keyword와 override Keyword 응용
using System.Diagnostics.Tracing;
using System.Numerics;
namespace CSharpStudy
{
class Player
{
protected int hp;
protected int attack;
public virtual void Move()
{
Console.WriteLine("Player 이동!");
}
}
class Knight : Player
{
public override void Move()
{
base.Move(); // 부모 Class가 가진 Move() 메소드를 실행
Console.WriteLine("Knight 이동!");
}
}
class Program
{
static void EnterGame(Player player)
{
player.Move(); // 매개변수의 Type은 Player지만 override를 통해 knight의 Move() 메소드가 실행
}
static void Main(string[] args)
{
Knight knight = new Knight();
EnterGame(knight);
}
}
}
~> overriding은 부모 Class에서 물려받은 메소드를 자식 Class에서 재정의하여 자식 클래스의 메소드가 더 우선시 된다.
# 문자열 둘러보기
string 관련 메소드들
using System.Diagnostics.Tracing;
using System.Numerics;
namespace CSharpStudy
{
class Program
{
static void Main(string[] args)
{
string name = "Harry Potter";
// 1. 찾기
bool found = name.Contains("Harry");
int index = name.IndexOf('P'); // 찾지 못할 경우 -1 을 return
// 2. 변형
name = name + " Junior";
string lowerCaseName = name.ToLower();
string upperCaseName = name.ToUpper();
string newName = name.Replace('r', 'l');
// 3. 분할
string[] names = name.Split(new char[] { ' ' });
string substringName = name.Substring(5);
}
}
}
[ 섹션 5. TextRPG2 ]
# TextRPG2 플레이어 & 몬스터 생성
TextRPG2에서 모든 생명체를 위한 부모 Class Creature 생성
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace CSharpStudy
{
public enum CreatureType
{
None,
Player = 1,
Monster = 2
}
class Creature
{
CreatureType type;
protected int hp = 0;
protected int attack = 0;
protected Creature(CreatureType type)
{
this.type = type;
}
public void SetInfo(int hp, int attack)
{
this.hp = hp;
this.attack = attack;
}
public int GetHp() { return hp; }
public int GetAttack() { return attack; }
public bool IsDead() { return hp <= 0; }
public void OnDamaged(int damage)
{
hp -= damage;
if (hp < 0)
hp = 0;
}
}
}
Creature를 상속받는 Player 생성
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace CSharpStudy
{
public enum PlayerType
{
None = 0,
Knight = 1,
Archer = 2,
Mage = 3
}
class Player : Creature
{
protected PlayerType type = PlayerType.None;
// 매개변수를 받는 생성자를 생성시, 기본 생성자를 따로 만들어주지 않는 이상 기본 생성자는 사용 불가
protected Player(PlayerType type) : base(CreatureType.Player)
{
this.type = type;
}
public PlayerType GetPlayerType() { return type; }
}
class Knight : Player
{
public Knight() : base(PlayerType.Knight)
{
SetInfo(100, 10);
}
}
class Archer : Player
{
public Archer() : base(PlayerType.Archer)
{
SetInfo(75, 12);
}
}
class Mage : Player
{
public Mage() : base(PlayerType.Mage)
{
SetInfo(50, 15);
}
}
}
Creature를 상속 받는 Monster 생성
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace CSharpStudy
{
public enum MonsterType
{
None = 0,
Slime = 1,
Orc = 2,
Skeleton = 3
}
class Monster : Creature
{
protected MonsterType type;
protected Monster(MonsterType type) : base(CreatureType.Monster)
{
this.type = type;
}
public MonsterType GetMonsterType() { return type; }
}
class Slime : Monster
{
public Slime() : base(MonsterType.Slime)
{
SetInfo(10, 1);
}
}
class Orc : Monster
{
public Orc() : base(MonsterType.Orc)
{
SetInfo(20, 2);
}
}
class Skeleton : Monster
{
public Skeleton() : base(MonsterType.Skeleton)
{
SetInfo(15, 5);
}
}
}
TextRPG2 플레이어 & 몬스터 생성
using System.Diagnostics.Tracing;
using System.Numerics;
namespace CSharpStudy
{
class Program
{
static void Main(string[] args)
{
Player player = new Knight();
Monster monster = new Orc();
int damage = player.GetAttack();
monster.OnDamaged(damage);
}
}
}
# TextRPG2 게임 진행 & 마무리
TextRPG2 게임 진행을 위한 Game 생성
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace CSharpStudy
{
public enum GameMode
{
None,
Lobby,
Town,
Field
}
// 게임 진행과 관련된 전반적인 사항들을 관리
class Game
{
private GameMode mode = GameMode.Lobby;
private Player player = null;
private Monster monster = null;
Random rand = new Random();
public void Process()
{
switch (mode)
{
case GameMode.Lobby:
ProcessLobby();
break;
case GameMode.Town:
ProcessTown();
break;
case GameMode.Field:
ProcessField();
break;
}
}
private void ProcessLobby()
{
Console.WriteLine("직업을 선택하세요");
Console.WriteLine("[1] 기사");
Console.WriteLine("[2] 궁수");
Console.WriteLine("[3] 법사");
string input = Console.ReadLine();
switch (input)
{
case "1":
player = new Knight();
mode = GameMode.Town;
break;
case "2":
player = new Archer();
mode = GameMode.Town;
break;
case "3":
player = new Mage();
mode = GameMode.Town;
break;
}
}
private void ProcessTown()
{
Console.WriteLine("마을에 입장했습니다!");
Console.WriteLine("[1] 필드로 가기");
Console.WriteLine("[2] 로비로 돌아가기");
string input = Console.ReadLine();
switch (input)
{
case "1":
mode = GameMode.Field;
break;
case "2":
mode = GameMode.Lobby;
break;
}
}
private void ProcessField()
{
Console.WriteLine("필드에 입장했습니다!");
Console.WriteLine("[1] 싸우기");
Console.WriteLine("[2] 일정 확률로 마을 돌아가기");
CreateRandomMonster();
string input = Console.ReadLine();
switch (input)
{
case "1":
ProcessFight();
break;
case "2":
TryEscape();
break;
}
}
private void CreateRandomMonster()
{
int randValue = rand.Next(0, 3);
switch (randValue)
{
case 0:
monster = new Slime();
Console.WriteLine("슬라임이 생성되었습니다!");
break;
case 1:
monster = new Orc();
Console.WriteLine("오크가 생성되었습니다!");
break;
case 2:
monster = new Skeleton();
Console.WriteLine("스켈레톤이 생성되었습니다!");
break;
}
}
private void ProcessFight()
{
while (true)
{
int damage = player.GetAttack();
monster.OnDamaged(damage);
if (monster.IsDead())
{
Console.WriteLine("승리했습니다");
Console.WriteLine($"남은 체력 : {player.GetHp()}");
break;
}
damage = monster.GetAttack();
player.OnDamaged(damage);
if (player.IsDead())
{
Console.WriteLine("패배했습니다");
mode = GameMode.Lobby;
break;
}
}
}
private void TryEscape()
{
int randValue = rand.Next(0, 101);
if (randValue < 33)
{
mode = GameMode.Town;
}
else
{
ProcessFight();
}
}
}
}
TextRPG2 게임 진행
using System.Diagnostics.Tracing;
using System.Numerics;
namespace CSharpStudy
{
class Program
{
static void Main(string[] args)
{
Game game = new Game();
while (true)
{
game.Process();
}
}
}
}
[ 섹션 6. 자료구조 맛보기 ]
# 배열
- 배열은 참조 타입이다.
- foreach문은 배열, Collection 안의 일련의 데이터들을 차례로 처리하기 위해 사용되는 반복문이다.
배열 및 foreach문 응용
namespace CSharpStudy
{
class Program
{
static void Main(string[] args)
{
// 배열 (참조 타입)
int[] MathScores = new int[] { 10, 20, 30, 40, 50 };
int[] EngScores = { 40, 50, 70, 90 };
for (int i = 0; i < MathScores.Length; i++)
{
Console.WriteLine("MathScore : " + MathScores[i]);
}
foreach (int score in EngScores)
{
Console.WriteLine($"EngScore : {score}");
}
}
}
}
namespace CSharpStudy
{
class Map
{
int[,] tiles =
{
{ 1, 1, 1, 1, 1 },
{ 1, 0, 0, 0, 1 },
{ 1, 0, 0, 0, 1 },
{ 1, 0, 0, 0, 1 },
{ 1, 1, 1, 1, 1 }
};
public void Render()
{
ConsoleColor defaultColor = Console.ForegroundColor;
for (int y = 0; y < tiles.GetLength(0); y++)
{
for (int x = 0; x < tiles.GetLength(1); x++)
{
if (tiles[y, x] == 1)
Console.ForegroundColor = ConsoleColor.Red;
else
Console.ForegroundColor = ConsoleColor.Green;
Console.Write("\u25cf");
}
Console.WriteLine();
}
Console.ForegroundColor = defaultColor;
}
}
class Program
{
static void Main(string[] args)
{
Map map = new Map();
map.Render();
}
}
}
가변 배열 응용
namespace CSharpStudy
{
class Program
{
static void Main(string[] args)
{
int[][] a = new int[3][];
a[0] = new int[3];
a[1] = new int[6];
a[2] = new int[2];
a[0][0] = 1;
}
}
}
# List
- List는 참조 타입이다.
- List는 배열과 달리 동적으로 크기 조절이 가능하다. 따라서 배열의 크기에 대해 크게 신경 쓸 필요가 없다.
~> 배열과 마찬가지로 인덱스를 사용하여 요소에 접근할 수 있다. (List가 최소 1개 이상의 데이터를 가져야 접근 가능)
List 응용
namespace CSharpStudy
{
class Program
{
static void Main(string[] args)
{
List<int> list = new List<int>();
for (int i = 0; i < 5; i++)
list.Add(i); // list의 맨 뒤에 데이터 추가
list.Insert(2, 999); // index를 통한 데이터 삽입
list.Remove(3); // 값을 통한 데이터 삭제 (여러개 존재할 경우 가장 처음으로 발견한 데이터를 삭제)
list.RemoveAt(0); // index를 통한 데이터 삭제
list.Clear(); // list를 초기화
for (int i = 0; i < list.Count; i++) // list의 크기는 Length를 사용하는 배열과 달리 Count 사용
Console.WriteLine(list[i]);
foreach (int num in list)
Console.WriteLine(num);
}
}
}
# Dictionary
- Dictionary는 참조 타입이다.
- Dictionary는 인덱스 대신 Key 값을 통해 Value 값을 찾는다.
- Hash Table을 사용하기 때문에 빠르지만, 메모리 낭비가 심하다.
Dictionary 응용
namespace CSharpStudy
{
class Monster
{
public int id;
public Monster(int id)
{
this.id = id;
}
}
class Program
{
static void Main(string[] args)
{
Dictionary<int, Monster> dic = new Dictionary<int, Monster>();
for (int i = 0; i < 10000; i++)
{
dic.Add(i, new Monster(i));
}
Monster monster;
bool isFound = dic.TryGetValue(5000, out monster);
dic.Remove(7777);
dic.Clear();
}
}
}
- Object 타입은 모든 타입 (int, float ...) 부터 참조 (Class, string ...) 그리고 우리가 만들어내는 모든 데이터 Type들까지도
객체를 담을 수 있다.
~> 이러한 이유는 C#에서는 모든 데이터 Type이 Object를 상속 받도록 구조가 짜여져 있기 때문이다.
~> 하지만 마구잡이로 사용시 메모리 낭비가 발생하기 때문에 Boxing과 UnBoxing의 개념을 이해해야 한다.
- Boxing이란 Stack 영역에 저장된 값 타입의 데이터를 Object 타입을 통해 Heap 영역에 저장하는 과정을 말한다.
~> 말 그대로 박스로 감싸는 과정이며, Object에 값 타입의 int 값을 넣으면 Stack 영역에 저장되어야 하는 값이 Box로
감싸져 Heap 영역에 저장된다.
- UnBoxing은 이렇게 Box로 감싸진 데이터를 풀어내는 과정을 말한다.
- Object obj = 5 가 Boxing의 예시, int a = (int)obj 가 UnBoxing의 예시이다.
# Interface (인터페이스)
- interface의 특징
~> C#은 다중 상속을 지원하지 않지만, interface는 다중 상속이 가능하다.
~> interface는 멤버 변수를 포함할 수 없다.
~> interface는 접근 제한 한정자를 사용할 수 없고, 구현부가 존재하지 않는다. (즉, 자체적으로 구현이 불가능)
~> interface를 상속받는 Class는 반드시 interface의 모든 메소드를 override (재정의) 해야 한다.
(override시 모든 메소드는 public 으로 선언)
~> interface는 객체 생성이 불가능하지만, interface를 상속 받은 Class는 객체 생성이 가능하다.
+ 추가 정리
- virtual과 abstract과 interface의 차이
virtual의 특징
namespace CSharpStudy
{
class Monster
{
int hp;
public virtual void Shout()
{
Console.WriteLine("Monster의 Shout!");
}
}
class Orc : Monster
{
public override void Shout()
{
Console.WriteLine("Orc의 Shout!");
}
}
class Skeleton : Monster
{
// virtual에 대한 override는 필수 X
}
class Program
{
static void Main(string[] args)
{
// virtual은 객체 생성 가능
Monster monster = new Monster();
}
}
}
abstract의 특징
namespace CSharpStudy
{
abstract class Monster
{
int hp;
public abstract void Shout(); // 자체적으로는 구현 불가능
}
class Orc : Monster
{
public override void Shout() // abstract에 대한 override는 필수
{
Console.WriteLine("Orc의 Shout!");
}
}
class Skeleton : Monster
{
public override void Shout() // abstract에 대한 override는 필수
{
Console.WriteLine("Skeleton의 Shout!");
}
}
class Program
{
static void Main(string[] args)
{
// abstract은 객체 생성 불가능
// Monster monster = new Monster();
}
}
}
interface의 특징
namespace CSharpStudy
{
abstract class Monster
{
int hp;
public abstract void Shout();
}
interface IFlyable
{
// 멤버 변수 포함 불가능
// bool isFly;
void Fly(); // 접근 제한 한정자 사용 불가능, 자체적으로는 구현 불가능
}
interface IRunable
{
// 멤버 변수 포함 불가능
// int speed;
void Run(); // 접근 제한 한정자 사용 불가능, 자체적으로는 구현 불가능
}
class Orc : Monster
{
public override void Shout()
{
Console.WriteLine("Orc의 Shout!");
}
}
class Skeleton : Monster
{
public override void Shout()
{
Console.WriteLine("Skeleton의 Shout!");
}
}
class FlyableOrc : Orc, IFlyable, IRunable // interface는 다중 상속 가능
{
public void Fly() // 접근 제한 한정자는 반드시 public
{
Console.WriteLine("Orc의 Fly!"); // interface에 대한 override는 필수
}
public void Run() // 접근 제한 한정자는 반드시 public
{
Console.WriteLine("Orc의 Run!"); // interface에 대한 override는 필수
}
}
class Program
{
static void Main(string[] args)
{
// interface는 객체 생성 불가능
// IFlyable iflyable = new IFlyable();
// interface를 상속 받은 Class의 객체 생성은 가능
FlyableOrc flyableOrc = new FlyableOrc();
}
}
}
~> 1개의 부모 Class를 2개의 자식 Class가 상속 받고, 2개의 자식 Class를 다시 1개의 자식 Class가 상속 받는 것이다.
~> 위의 그림에서 ComboDrive가 burn() 함수를 호출시 어떤 부모 Class의 burn() 함수를 실행 시킬지 모호하다.
# Property (프로퍼티)
- Property는 OOP의 특징 중 하나인 은닉성을 위해 사용한다.
~> Property는 get과 set을 통해 private로 선언된 변수에 접근이 가능하도록 한다.
~> 정보 은닉을 위해 private로 선언하였으나, get과 set을 통해 변수에 접근할 수 있더라도 Property를 사용하는 이유는
변수의 값을 변경하거나 가져올 때 조건을 걸어서 변수의 접근을 제어할 수 있기 때문이다.
Property 응용
namespace CSharpStudy
{
class Knight
{
private int hp;
public int Hp
{
get { return hp; }
set { hp = value; } // 기본적으로 매개변수는 value Keyword가 제공된다.
}
}
class Program
{
static void Main(string[] args)
{
Knight knight = new Knight();
knight.Hp = 100;
int hp = knight.Hp;
}
}
}
자동 구현 Property 응용
namespace CSharpStudy
{
class Knight
{
public int HP { get; set; } = 100
}
class Program
{
static void Main(string[] args)
{
Knight knight = new Knight();
int hp = knight.HP;
}
}
}
# Delegate (대리자)
- Delegate는 메소드 자체를 인자로 넘겨주는 형식이다. (함수가 아닌 형식 (int, string ...) 인 것에 유의)
~> 버튼 클릭시 발생하는 일들을 순차적으로 코딩할 경우 UI에 관련된 Logic과 게임과 관련된 Logic 사이에
간섭 및 복잡성이 발생하는 문제가 있다. 따라서 최대한 분리를 시켜 관리해야 장기적인 차원에서 유리하다.
~> Unity 자체에서 제공하는 Event 메소드들은 자체적으로 수정할 수 없다.
~> 위와 같은 문제들을 메소드 자체를 인자로 넘겨주는 Delegate 를 통해 해결할 수 있다.
- Delegate 목록에 실행할 메소드들을 등록 해두면 해당 Delegate가 등록된 함수들을 연쇄적으로 대신 실행시켜준다.
- Delegate 선언시 등록할 수 있는 함수의 조건을 제한한다. (매개변수, 반환값)
- Delegate를 직접 선언하지 않아도, C#에서 제공하는 Func, Action을 통해 Delegate 사용이 가능하다.
~> Anonymous Function (무명함수 or 익명함수) 을 통해 작성한다.
~> 반환값이 존재할 경우 Func을, 반환값이 존재하지 않을 경우 Action을 사용한다.
(Func의 < > 안에 들어있는 n개의 형식 중 마지막은 반환값, 나머지는 인자에 해당)
Delegate 응용
using System.Diagnostics.Tracing;
using System.Numerics;
namespace CSharpStudy
{
class Program
{
// delegate : 메소드 자체를 인자로 넘겨주는 형식
// 인자 : X, 반환값 : int
// OnClicked : delegate 형식의 이름
delegate int OnClicked();
static void ButtonPressed (OnClicked clickedFunction)
{
clickedFunction();
}
static int TestDelegate()
{
Console.WriteLine("Hello Delegate");
return 0;
}
static int TestDelegate2()
{
Console.WriteLine("Hello Delegate2");
return 0;
}
static void Main(string[] args)
{
// delegate 사용 방법 #1
ButtonPressed(TestDelegate);
ButtonPressed(TestDelegate2);
// delegate 사용 방법 #2 (객체 생성 방법)
OnClicked clicked = new OnClicked(TestDelegate);
clicked += TestDelegate2; // 객체 생성시 delegate chaining 가능
clicked();
// delegate 사용 방법 #3
OnClicked clicked2 = new OnClicked(TestDelegate);
clicked2 += TestDelegate2; // 객체 생성시 delegate chaining 가능
ButtonPressed(clicked2);
}
}
}
# Event (이벤트)
- Event는 Observer Pattern 을 사용한다.
~> Observer Pattern 이란 한 객체의 상태가 바뀌면 해당 객체에 의존하는 다른 객체들한테 연락이 가서 자동으로 내용이
갱신되는 방식으로 1:N 의존성을 정의한다.
- delegate는 외부에서 호출이 가능하지만, event는 호출이 불가능하다. (구독 신청만 가능)
Event 응용을 위한 InputManager 생성
using System;
using System.Collections.Generic;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Text;
using System.Threading.Tasks;
namespace CSharpStudy
{
internal class InputManager
{
// delegate와 event의 접근 제한 한정자는 동일하게 설정
public delegate void OnInputKey();
public event OnInputKey InputKey;
public void Update()
{
if (Console.KeyAvailable == false)
return;
ConsoleKeyInfo info = Console.ReadKey();
if (info.Key == ConsoleKey.A)
{
// 해당 event를 구독 신청한 구독자들에게 알려준다.
InputKey();
}
}
}
}
Event 응용
using System.Diagnostics.Tracing;
using System.Numerics;
namespace CSharpStudy
{
class Program
{
static void Main(string[] args)
{
static void OnInputTest()
{
Console.WriteLine("Input Received!");
}
InputManager inputManager = new InputManager();
inputManager.InputKey += OnInputTest; // OnInputTest 메소드가 InputKey event를 구독 신청
while (true)
{
inputManager.Update();
// event는 외부에서 호출이 불가능 (delegate와 가장 큰 차이점)
// inputManager.InputKey();
}
}
}
}
# Lambda (람다식)
- Lambda 를 사용하는 이유는 대체로 간결하고 가독성이 높은 코드 작성을 위해 사용된다.
~> 또한 복잡한 코드를 더욱 쉽게 작성하여 개발자의 생산정을 높여주고, 코드의 유지 보수성을 높일 수 있다.
무명 함수 및 Lambda 응용
using System.Diagnostics.Tracing;
using System.Numerics;
namespace CSharpStudy
{
enum ItemType
{
Weapon,
Armor,
Amulet,
Ring
}
enum Rarity
{
Normal,
Uncommon,
Rare
}
class Item
{
public ItemType itemType;
public Rarity rarity;
}
class Program
{
static List<Item> _items = new List<Item>();
delegate bool ItemSelector(Item item);
static Item FindItem(ItemSelector selector)
{
foreach (Item item in _items)
{
if (selector(item))
return item;
}
return null;
}
static void Main(string[] args)
{
_items.Add(new Item() { itemType = ItemType.Weapon, rarity = Rarity.Normal });
_items.Add(new Item() { itemType = ItemType.Armor, rarity = Rarity.Uncommon });
_items.Add(new Item() { itemType = ItemType.Ring, rarity = Rarity.Rare });
// Anonymous Function (무명 함수 or 익명 함수)
Item item = FindItem(delegate (Item item) { return item.itemType == ItemType.Weapon; });
// Lambda (람다식)
Item item2 = FindItem((Item item) => { return item.itemType == ItemType.Weapon; });
}
}
}
Func 및 Lambda 응용
using System.Diagnostics.Tracing;
using System.Numerics;
namespace CSharpStudy
{
enum ItemType
{
Weapon,
Armor,
Amulet,
Ring
}
enum Rarity
{
Normal,
Uncommon,
Rare
}
class Item
{
public ItemType itemType;
public Rarity rarity;
}
class Program
{
static List<Item> _items = new List<Item>();
static Item FindItem(Func<Item, bool> selector)
{
foreach (Item item in _items)
{
if (selector(item))
return item;
}
return null;
}
static void Main(string[] args)
{
_items.Add(new Item() { itemType = ItemType.Weapon, rarity = Rarity.Normal });
_items.Add(new Item() { itemType = ItemType.Armor, rarity = Rarity.Uncommon });
_items.Add(new Item() { itemType = ItemType.Ring, rarity = Rarity.Rare });
// Anonymous Function (무명 함수 or 익명 함수)
Item item = FindItem(delegate (Item item) { return item.itemType == ItemType.Weapon; });
// Lambda (람다식)
Item item2 = FindItem((Item item) => { return item.itemType == ItemType.Weapon; });
}
}
}
# Exception (예외 처리)
- Exception은 throw-try-catch-finally 문을 통해 사용할 수 있다.
~> throw 문을 통해 예외를 던진다.
~> try 문에 예외가 발생할 수 있는 소스코드를 작성한다.
~> catch 문에 예외를 처리하는 소스코드를 작성한다.
~> finally 문에 예외 발생 여부와 상관없이 항상 실행되는 소스코드를 작성한다.
- 발생한 예외가 특정 catch 문에 해당할 경우 해당 catch 문을 제외한 나머지 catch 문은 실행되지 않는다.
- 모든 예외는 Exception Class를 상속 받는다.
~> 따라서 catch 문 간의 순서가 중요하다.
(Exception catch문이 catch문 중 가장 위에 위치할 경우 아래의 catch문들은 평생 실행되지 X)
- 게임 업계에서 게임 Logic에 대해서는 Exception 을 잘 사용하지 X
~> Exception 을 사용하여 예외를 처리하기보다는 Crash를 통한 오류 수정이 더욱 중요하다.
Exception 응용
using System.ComponentModel;
using System.Diagnostics.Tracing;
using System.Numerics;
namespace CSharpStudy
{
class Program
{
class TestException : Exception
{
int a, b, result;
public TestException()
{
this.a = 10;
this.b = 0;
this.result = a / b;
}
}
static void Main(string[] args)
{
try
{
throw new TestException();
}
catch (DivideByZeroException e)
{
Console.WriteLine("0으로 나눌 수 없습니다!");
}
catch (Exception e)
{
Console.WriteLine("또 다른 예외가 발생했습니다!");
}
finally
{
Console.WriteLine("반드시 실행되는 구문!");
}
}
}
}
# Reflection (리플렉션)
-C#은 Reflection 기능을 지원한다. 이는 객체(Instance)를 토대로 데이터타입의 메타적인 정보를 가져오는 기법이다.
~> Reflection은 조사, Instance 생성, 기존 개체에서 형식을 가져와 호출, 접근 기능을 제공한다.
~> using System.Reflection; 을 통해 Reflection 기능을 사용할 수 있다.
- Attribute는 코드에 대한 부가 정보를 기록하고 읽을 수 있는 기능이다.
~> 주석은 사람이 작성하고 사람이 읽는 정보라면, Attribute는 사람이 작성하고 컴퓨터가 읽는 정보이다.
~> Attribute는 Metadata 형식으로 저장되며, Compile time, Runtime 시에도 컴퓨터에게 정보를 제공한다.
~> 미리 구현된 Attribute도 존재하며, 직접 Attribute를 만들 수도 있다.
(Unity에서 자주 사용하는 Attribute는 대표적으로 [ SerializeField ] 가 존재)
Reflection 및 Attribute 응용
using System.ComponentModel;
using System.Diagnostics.Tracing;
using System.Numerics;
using System.Reflection;
namespace CSharpStudy
{
class Program
{
class Important : System.Attribute // Custom Attribute는 System.Attribute를 상속 받아 구현
{
string msg;
public Important(string msg) { this.msg = msg; }
}
class Monster
{
[Important("My Important variable")]
public int hp;
protected int attack;
private float speed;
void Attack() { }
}
static void Main(string[] args)
{
// Reflection : X-Ray
Monster monster = new Monster();
Type type = monster.GetType();
FieldInfo[] fields = type.GetFields(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.NonPublic);
foreach (FieldInfo field in fields)
{
string access = "None";
if (field.IsPublic)
access = "Public";
else if (field.IsPrivate)
access = "Private";
var attributes = field.GetCustomAttributes();
Console.WriteLine($"{access} {field.FieldType.Name} {field.Name}");
}
}
}
}
# Nullable (널러블)
- Nullable은 Null을 가질 수 없는 데이터 타입을 Null을 가질 수 있는 타입으로 만든 새로운 타입이다.
~> 일반적으로 값 타입들 (int, double, bool, struct ...) 이 Null을 가질 수 없다.
Nullable 응용
using System.ComponentModel;
using System.Diagnostics.Tracing;
using System.Numerics;
using System.Reflection;
namespace CSharpStudy
{
class Monster
{
public int Id { get; set; }
}
class Program
{
static void Main(string[] args)
{
// Nullable -> Null + able
int? number = null;
int value;
Monster monster = null;
// int number의 Null 확인 방법 #1
if (number != null)
{
value = number.Value;
}
// int number의 Null 확인 방법 #2
if (number.HasValue)
{
value = number.Value;
}
// int number의 Null 확인 방법 #3
value = number ?? 0; // number가 Null이 아닐 경우 해당 값으로, Null일 경우 0으로 초기화
// 참조 타입인 class에서도 사용이 가능하다.
// Monster monster의 Null 확인 방법 #1 (아래의 주석 처리된 코드들을 한 줄로 끝낼 수 있다.)
int? id = monster?.Id; // monster가 Null이 아닐 경우 해당 값으로, Null일 경우 null로 초기화
// if (monster != null)
// int id = monster.Id;
// else
// int id = 0;
}
}
}
# 코루틴과 Invoke는 MonoBehaviour을 상속 받는 Class에서만 사용 가능하다.
# 코루틴에 매개변수 전달시 StartCoroutine("FunctionName", 매개변수) 를 통해 전달할 경우 1개의 매개변수만 전달 가능
~> 2개 이상의 매개변수 전달시 StartCoroutine(FunctionName(매개변수, 매개변수)) 와 같이 코루틴을 호출 # GameObject가 비활성화인 상태에서는 Find 함수를 통해 찾을 수 없다. ~> 활성화된 부모를 찾아 자식을 찾는 형식으로 접근해야 한다. # transform.position을 통해 Player를 움직일 경우 떨림 현상이 발생한다. ~> 물리 계산 과정에서 발생하는 현상으로 rigid.MovePosition 를 통해 해결할 수 있다. (rigid는 Rigidbody Component) # 같은 Canvas 안에서의 UI는 Hierarchy 창에서 밑에 있을수록 출력 우선순위가 높다. ~> 다른 Canvas 간의 UI 출력 우선순위는 Hierarchy 창을 통해 변경할 수 없고 Sort Order를 통해 변경할 수 있다. # Awake()는 일반적으로 게임이 시작되기 전에, 모든 변수와 게임의 상태를 초기화하기 위해서 호출된다. Start()는 Behaviour의 주기 동안에 1번만 호출된다. (즉, Script Instance로 활성화된 경우에만 실행된다.)
# UI는 Component로 Transform이 아닌 RectTransform을 가진다.
~> 이를 단순히 transform.position 를 통해 이동시 전혀 의도치 않은 방식으로 이동하게 된다.
~> GetComponent<RectTransform>().anchoredPosition 을 통해 이동시 올바르게 이동한다.
- NavMesh는 Navigation Mesh의 줄임말로, 게임 월드에서 걸을 수 있는 표면을 뜻한다.
- NavMesh는 [ Window ] - [ AI ] - [ Navigation ] 에서 설정 가능하다.
> [ Bake ] 에서 미리 갈 수 있는 영역 또는 제한 사항들을 Bake한다.
(계산에 포함되는 Mesh는 Navigation Static 속성을 가진다.)
- 설정된 NavMesh를 이용하여 목적지까지 경로를 찾고 이동하는 캐릭터에 부착되는 컴포넌트가 Nav Mesh Agent이다.
NavMesh를 이용한 PlayerController 수정
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.AI;
public class PlayerController : MonoBehaviour
{
//...
void UpdateMoving()
{
Vector3 dir = _destPos - transform.position; // 목적지까지의 방향벡터를 알 수 있다.
if (dir.magnitude < 0.1f) // 만약 목적지까지 거의 도착을 완료했다면
{
_state = PlayerState.Idle;
}
else
{
// NavMesh를 사용
NavMeshAgent nma = gameObject.GetOrAddComponent<NavMeshAgent>();
float moveDist = Mathf.Clamp(Time.deltaTime * _speed, 0, dir.magnitude);
nma.Move(dir.normalized * moveDist);
Debug.DrawRay(transform.position + Vector3.up * 0.5f, dir.normalized, Color.green); // Ray를 시각적으로 표현
if (Physics.Raycast(transform.position + Vector3.up * 0.5f, dir, 1.0f, LayerMask.GetMask("Block"))) // 건물, 사물과 인접시 Player가 멈추도록 설정
{
_state = PlayerState.Idle;
return;
}
transform.rotation = Quaternion.Slerp(transform.rotation, Quaternion.LookRotation(dir), 10 * Time.deltaTime); // LookAt을 위한 회전을 보다 자연스럽게 하도록
}
// 애니매이션
Animator anim = GetComponent<Animator>();
anim.SetFloat("speed", _speed);
}
//...
}
# 스탯
스탯 관리를 위한 공용 Stat Script 생성
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class Stat : MonoBehaviour
{
[SerializeField]
protected int _level;
[SerializeField]
protected int _hp;
[SerializeField]
protected int _maxHp;
[SerializeField]
protected int _attack;
[SerializeField]
protected int _defense;
[SerializeField]
protected float _moveSpeed;
public int Level { get { return _level; } set { _level = value; } }
public int Hp { get { return _hp; } set { _hp = value; } }
public int MaxHp { get { return _maxHp; } set { _maxHp = value; } }
public int Attack { get { return _attack; } set { _attack = value; } }
public int Defense { get { return _defense; } set { _defense = value; } }
public float MoveSpeed { get { return _moveSpeed; } set { _moveSpeed = value; } }
private void Start()
{
_level = 1;
_hp = 100;
_maxHp = 100;
_attack = 10;
_defense = 5;
_moveSpeed= 5.0f;
}
}
Player의 스탯 관리를 위해 Stat를 상속받는 PlayerStat Script 생성
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class PlayerStat : Stat
{
[SerializeField]
protected int _exp;
[SerializeField]
protected int _gold;
public int Exp { get { return _exp; } set { _exp = value; } }
public int Gold { get { return _gold; } set { _gold = value; } }
private void Start()
{
_level = 1;
_hp = 100;
_maxHp = 100;
_attack = 10;
_defense = 5;
_moveSpeed = 5.0f;
_exp = 0;
_gold = 0;
}
}
# 마우스 커서
- Cursor로 사용하기 위한 Image는 Inspector 창에서 Texture Type을 Cursor로 설정한다.
마우스 커서를 위한 PlayerController 수정
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.AI;
public class PlayerController : MonoBehaviour
{
// ...
Texture2D _attackIcon;
Texture2D _handIcon;
enum CursorType
{
None,
Attack,
Hand,
}
CursorType _cursorType = CursorType.None;
void Start()
{
_attackIcon = Managers.Resource.Load<Texture2D>("Textures/Cursor/Attack"); // Image 파일을 Texture2D를 통해 불러온다.
_handIcon = Managers.Resource.Load<Texture2D>("Textures/Cursor/Hand"); // Image 파일을 Texture2D를 통해 불러온다.
}
void Update()
{
UpdateMouseCursor();
}
void UpdateMouseCursor()
{
Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition);
RaycastHit hit;
if (Physics.Raycast(ray, out hit, 100.0f, _mask))
{
if (hit.collider.gameObject.layer == (int)Define.Layer.Monster)
{
if (_cursorType != CursorType.Attack)
{
Cursor.SetCursor(_attackIcon, new Vector2(_attackIcon.width / 5, 0), CursorMode.Auto); // 마우스 Cursor를 설정한다.
_cursorType = CursorType.Attack;
}
}
else
{
if (_cursorType != CursorType.Hand)
{
Cursor.SetCursor(_handIcon, new Vector2(_attackIcon.width / 3, 0), CursorMode.Auto); // 마우스 Cursor를 설정한다.
_cursorType = CursorType.Hand;
}
}
}
}
// ...
}
+ 추가 검색
- Cursor.SetCursor() 함수는 Cursor의 Image를 사용자 지정 Texture로 바꿔준다.
> 첫번째 인자는 Cursor에 지정할 Texture, 두번째 인자는 Cursor의 Spot, 세번째 인자는 최적화 방법이다.
#타게팅 락온
타게팅 락온을 위해 Define의 MouseEvent 추가
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class Define
{
// ...
public enum MouseEvent
{
Press,
PointerDown, // 마우스를 Click하는 순간
PointerUp, // 마우스 Click을 떼는 순간
Click, // 순간적인 Click (굉장히 짧은 시간)
}
// ...
}
InputManager 수정
using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.EventSystems;
public class InputManager
{
// ...
float _pressedTime = 0; // Click과 PointerUp을 구분하기 위한 것
// 체크하는 부분이 유일해짐
public void OnUpdate()
{
// ...
if (MouseAction != null)
{
if (Input.GetMouseButton(0))
{
if (!_pressed) // 마우스를 Click하는 순간
{
MouseAction.Invoke(Define.MouseEvent.PointerDown);
_pressedTime = Time.time;
}
MouseAction.Invoke(Define.MouseEvent.Press); // Press Event를 발생시켜 알려준다.
_pressed = true;
}
else
{
if (_pressed)
{
if (Time.time < _pressedTime + 0.2f) // 0.2초 내에 Mouse Click을 뗀 경우
MouseAction.Invoke(Define.MouseEvent.Click); // Click Event를 발생시켜 알려준다.
MouseAction.Invoke(Define.MouseEvent.PointerUp);
}
_pressed = false;
_pressedTime = 0; // 초기화
}
}
}
}
PlayerController 수정
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.AI;
public class PlayerController : MonoBehaviour
{
// ...
void UpdateMouseCursor()
{
if (Input.GetMouseButton(0)) // 만약 마우스를 누르고 있다면 커서가 변경되면 안되기 때문
return;
Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition);
RaycastHit hit;
if (Physics.Raycast(ray, out hit, 100.0f, _mask))
{
if (hit.collider.gameObject.layer == (int)Define.Layer.Monster)
{
if (_cursorType != CursorType.Attack)
{
Cursor.SetCursor(_attackIcon, new Vector2(_attackIcon.width / 5, 0), CursorMode.Auto); // 마우스 Cursor를 설정한다.
_cursorType = CursorType.Attack;
}
}
else
{
if (_cursorType != CursorType.Hand)
{
Cursor.SetCursor(_handIcon, new Vector2(_attackIcon.width / 3, 0), CursorMode.Auto); // 마우스 Cursor를 설정한다.
_cursorType = CursorType.Hand;
}
}
}
}
int _mask = (1 << (int)Define.Layer.Ground) | (1 << (int)Define.Layer.Monster);
GameObject _lockTarget;
void OnMouseEvent(Define.MouseEvent evt)
{
if (_state == PlayerState.Die)
return;
RaycastHit hit;
Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition);
bool raycastHit = Physics.Raycast(ray, out hit, 100.0f, _mask);
//Debug.DrawRay(Camera.main.transform.position, ray.direction * 100.0f, Color.red, 1.0f); // Ray를 시각적으로 표현
switch (evt)
{
case Define.MouseEvent.PointerDown:
{
if (raycastHit)
{
_destPos = hit.point;
_state = PlayerState.Moving;
if (hit.collider.gameObject.layer == (int)Define.Layer.Monster)
_lockTarget = hit.collider.gameObject;
else
_lockTarget = null;
}
}
break;
case Define.MouseEvent.Press:
{
if(_lockTarget != null)
_destPos = _lockTarget.transform.position;
else if (raycastHit)
_destPos = hit.point;
}
break;
case Define.MouseEvent.PointerUp:
_lockTarget = null;
break;
}
}
}
- CrossFade( )는 Play( ) 에 대한 결과를 보다 더 부드럽고 자연스럽게 Blending 하여 Animation을 재생한다.
> 첫번째 Parameter는 재생하고자 하는 Clip의 이름
> 두번째 Parameter는 지연 시간 (다음 Animation으로 바뀌는데 걸리는 Fade 시간)
> 세번째 Parameter는 Animation Layer
> 네번째 Parameter는 Animation의 재생 시작 시점 (0 ~ 1 비율)
~> 0.0f로 설정시 다시 처음으로 돌아가 재생한다.
~> Clip의 Loop Time이 체크되어 있지 않더라도 반복 재생시킬 수 있다.
# 체력 게이지 #1
- UI는 2D UI와 (게임 세상과 별개), 3D UI (게임 세상과 공존)로 나눌 수 있다.
> 3D UI를 만들기 위해서는 Canvas의 Render Mode를 World Space로 변경한다.
> 3D UI를 만들때 Canvas의 Scale을 약 1/100 로 줄이면 적당한 크기로 변경이 가능하다.
- Slider의 Value가 0과 1일때 텅 비어 있지도, 가득 차있지도 않다.
> 이는 Fill Area의 [ Inspector ] - [ Rect Transform ] 에서 Left와 Right의 값을 전부 0으로 설정하면 해결이 가능하다.
- Slider의 Fill이 Background의 경계선을 넘어 튀어나와 있다.
> 이는 Fill의 [ Inspector ] - [ Rect Transform ] 에서 Left와 Right의 값을 전부 0으로 설정하면 해결이 가능하다.
HP_Bar의 Canvas를 Prefab으로 저장한 뒤 UI_HPBar Script 생성
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class UI_HPBar : UI_Base
{
enum GameObjects
{
HPBar
}
public override void Init()
{
Bind<GameObject>(typeof(GameObjects));
}
void Update()
{
// HP_Bar를 캐릭터의 머리 위로 위치시키기 위해서
Transform parent = transform.parent;
transform.position = parent.position + Vector3.up * (parent.GetComponent<Collider>().bounds.size.y); // 부모님 위치를 기준으로 Collider만큼의 높이를 올려 저장
}
}
UIManager 수정
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class UIManager
{
// ...
public T MakeWorldSpaceUI<T>(Transform parent = null, string name = null) where T : UI_Base
{
if (string.IsNullOrEmpty(name))
name = typeof(T).Name;
GameObject go = Managers.Resource.Instantiate($"UI/WorldSpace/{name}");
if (parent != null)
go.transform.SetParent(parent);
Canvas canvas = go.GetOrAddComponent<Canvas>();
canvas.renderMode = RenderMode.WorldSpace;
canvas.worldCamera = Camera.main;
return Util.GetOrAddComponent<T>(go);
}
// ...
}
PlayerController 수정
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.AI;
public class PlayerController : MonoBehaviour
{
// ...
void Start()
{
_stat = gameObject.GetComponent<PlayerStat>();
Managers.Input.MouseAction -= OnMouseEvent; // 혹시라도 다른 곳에서 구독 신청을 하고 있는 경우를 대비
Managers.Input.MouseAction += OnMouseEvent;
Managers.UI.MakeWorldSpaceUI<UI_HPBar>(transform);
}
// ...
}
#체력 게이지 #2
상속받는 Class들이 Start 함수 선언 없이 Init 함수를 실행하도록 UI_Base 수정
using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
using UnityEngine.EventSystems;
public abstract class UI_Base : MonoBehaviour
{
// ...
public abstract void Init();
void Start()
{
Init();
}
// ...
}
Billboard 기능 구현 및 실시간 HP 감소를 위한 UI_HPBar 수정
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
public class UI_HPBar : UI_Base
{
enum GameObjects
{
HPBar
}
Stat _stat;
public override void Init()
{
Bind<GameObject>(typeof(GameObjects));
_stat = transform.parent.GetComponent<Stat>();
}
void Update()
{
// HP_Bar를 캐릭터의 머리 위로 위치시키기 위해서
Transform parent = transform.parent;
transform.position = parent.position + Vector3.up * (parent.GetComponent<Collider>().bounds.size.y); // 부모님 위치를 기준으로 Collider만큼의 높이를 올려 저장
transform.rotation = Camera.main.transform.rotation; // 자신이 바라보는 방향을 카메라가 바라보는 방향으로 설정 (Billboard의 개념)
float ratio = _stat.Hp / (float)_stat.MaxHp;
SetHpRatio(ratio);
}
public void SetHpRatio(float ratio)
{
GetObject((int)GameObjects.HPBar).GetComponent<Slider>().value = ratio;
}
}
Hit Event 발생시 상대방의 HP를 감소시키기 위한 PlayerController 수정
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.AI;
public class PlayerController : MonoBehaviour
{
// ...
void OnHitEvent()
{
if (_lockTarget != null)
{
Stat targetStat = _lockTarget.GetComponent<Stat>();
Stat myStat = gameObject.GetComponent<PlayerStat>();
int damage = Mathf.Max(0, myStat.Attack - targetStat.Defense);
Debug.Log(damage);
targetStat.Hp -= damage;
}
if (_stopSkill)
{
State = PlayerState.Idle;
}
else
{
State = PlayerState.Skill;
}
}
// ...
}
+ 추가 검색
- Unity에서 3D 오브젝트가 카메라를 바라보도록 만드는 기술을 Billboard라고 한다.
> UI인 Slider를 카메라를 바라보도록 수정할 경우 좌우 반전이 된 채로 표시된다.
( transform.LookAt(Camera.main) 을 통해 )
~> 따라서 UI인 Slider에 Billboard 기술을 적용하기 위해서는 아래와 같이 수정한다.
( transform.rotation = Camera.main.transform.rotation 으로 수정 )
#몬스터 AI #1
- 새로 생성하고자 하는 MonsterController가 PlayerController와 겹치는 부분이 상당히 많아서 이럴때는 BaseController를
새로 생성하여 MonsterController와 PlayerController가 BaseController를 상속받도록 하는 것이 좋다.
Define 수정
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class Define
{
public enum State
{
Die,
Moving,
Idle,
Skill,
}
// ...
}
Base Controller 생성
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public abstract class BaseController : MonoBehaviour
{
[SerializeField]
protected Vector3 _destPos; // 목적지 좌표를 저장하기 위한 변수
[SerializeField]
protected Define.State _state = Define.State.Idle;
[SerializeField]
protected GameObject _lockTarget;
public virtual Define.State State // State가 변함과 동시에 Animation도 변경하기 위한 프로퍼티 생성
{
get { return _state; }
set
{
_state = value;
Animator anim = GetComponent<Animator>();
switch (_state)
{
case Define.State.Die:
break;
case Define.State.Idle:
anim.CrossFade("WAIT", 0.1f);
break;
case Define.State.Moving:
anim.CrossFade("RUN", 0.1f);
break;
case Define.State.Skill:
anim.CrossFade("ATTACK", 0.1f);
break;
}
}
}
private void Start()
{
Init();
}
void Update()
{
switch (State)
{
case Define.State.Die:
UpdateDie();
break;
case Define.State.Moving:
UpdateMoving();
break;
case Define.State.Idle:
UpdateIdle();
break;
case Define.State.Skill:
UpdateSkill();
break;
}
}
public abstract void Init();
protected virtual void UpdateDie() { }
protected virtual void UpdateMoving() { }
protected virtual void UpdateIdle() { }
protected virtual void UpdateSkill() { }
}
PlayerController 수정
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.AI;
public class PlayerController : BaseController
{
int _mask = (1 << (int)Define.Layer.Ground) | (1 << (int)Define.Layer.Monster);
PlayerStat _stat;
bool _stopSkill = false;
public override void Init()
{
_stat = gameObject.GetComponent<PlayerStat>();
Managers.Input.MouseAction -= OnMouseEvent; // 혹시라도 다른 곳에서 구독 신청을 하고 있는 경우를 대비
Managers.Input.MouseAction += OnMouseEvent;
if (gameObject.GetComponentInChildren<UI_HPBar>() == null)
Managers.UI.MakeWorldSpaceUI<UI_HPBar>(transform);
}
protected override void UpdateMoving()
{
// 몬스터가 내 사정거리보다 가까우면 공격
if (_lockTarget != null)
{
_destPos = _lockTarget.transform.position;
float distance = (_destPos - transform.position).magnitude;
if (distance <= 1)
{
State = Define.State.Skill;
return;
}
}
Vector3 dir = _destPos - transform.position; // 목적지까지의 방향벡터를 알 수 있다.
if (dir.magnitude < 0.1f) // 만약 목적지까지 거의 도착을 완료했다면
{
State = Define.State.Idle;
}
else
{
// NavMesh를 사용
NavMeshAgent nma = gameObject.GetOrAddComponent<NavMeshAgent>();
float moveDist = Mathf.Clamp(Time.deltaTime * _stat.MoveSpeed, 0, dir.magnitude);
nma.Move(dir.normalized * moveDist);
Debug.DrawRay(transform.position + Vector3.up * 0.5f, dir.normalized, Color.green); // Ray를 시각적으로 표현
if (Physics.Raycast(transform.position + Vector3.up * 0.5f, dir, 1.0f, LayerMask.GetMask("Block"))) // 건물, 사물과 인접시 Player가 멈추도록 설정
{
if (Input.GetMouseButton(0) == false)
State = Define.State.Idle;
return;
}
transform.rotation = Quaternion.Slerp(transform.rotation, Quaternion.LookRotation(dir), 10 * Time.deltaTime); // LookAt을 위한 회전을 보다 자연스럽게 하도록
}
}
protected override void UpdateSkill()
{
if (_lockTarget != null)
{
Vector3 dir = _lockTarget.transform.position - transform.position; // 목적지까지의 방향벡터를 알 수 있다.
Quaternion quat = Quaternion.LookRotation(dir); // 목적지를 바라보기 위해서
transform.rotation = Quaternion.Lerp(transform.rotation, quat, 20 * Time.deltaTime); // 자연스럽게 연결되도록
}
}
void OnHitEvent()
{
if (_lockTarget != null)
{
Stat targetStat = _lockTarget.GetComponent<Stat>();
Stat myStat = gameObject.GetComponent<PlayerStat>();
int damage = Mathf.Max(0, myStat.Attack - targetStat.Defense);
Debug.Log(damage);
targetStat.Hp -= damage;
}
if (_stopSkill)
{
State = Define.State.Idle;
}
else
{
State = Define.State.Skill;
}
}
void OnMouseEvent(Define.MouseEvent evt)
{
switch (State)
{
case Define.State.Idle:
OnMouseEvent_IdleRun(evt);
break;
case Define.State.Moving:
OnMouseEvent_IdleRun(evt);
break;
case Define.State.Skill:
{
if (evt == Define.MouseEvent.PointerUp)
_stopSkill = true;
}
break;
}
}
void OnMouseEvent_IdleRun(Define.MouseEvent evt)
{
RaycastHit hit;
Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition);
bool raycastHit = Physics.Raycast(ray, out hit, 100.0f, _mask);
//Debug.DrawRay(Camera.main.transform.position, ray.direction * 100.0f, Color.red, 1.0f); // Ray를 시각적으로 표현
switch (evt)
{
case Define.MouseEvent.PointerDown:
{
if (raycastHit)
{
_destPos = hit.point;
State = Define.State.Moving;
_stopSkill = false;
if (hit.collider.gameObject.layer == (int)Define.Layer.Monster)
_lockTarget = hit.collider.gameObject;
else
_lockTarget = null;
}
}
break;
case Define.MouseEvent.Press:
{
if (_lockTarget == null && raycastHit)
_destPos = hit.point;
}
break;
case Define.MouseEvent.PointerUp:
_stopSkill = true;
break;
}
}
}
#몬스터 AI #2
MonsterController 생성
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.AI;
public class MonsterController : BaseController
{
Stat _stat;
[SerializeField]
float _scanRange = 10;
[SerializeField]
float _attackRange = 2;
public override void Init()
{
_stat = gameObject.GetComponent<Stat>();
if (gameObject.GetComponentInChildren<UI_HPBar>() == null)
Managers.UI.MakeWorldSpaceUI<UI_HPBar>(transform);
}
protected override void UpdateIdle()
{
GameObject player = GameObject.FindGameObjectWithTag("Player");
if (player == null)
return;
float distance = (player.transform.position - transform.position).magnitude;
if (distance <= _scanRange) // 사정 거리 안으로 들어올 경우 움직이기 시작
{
_lockTarget = player;
State = Define.State.Moving;
return;
}
}
protected override void UpdateMoving()
{
// 플레이어가 내 사정거리보다 가까우면 공격
if (_lockTarget != null)
{
_destPos = _lockTarget.transform.position;
float distance = (_destPos - transform.position).magnitude;
if (distance <= _attackRange)
{
NavMeshAgent nma = gameObject.GetOrAddComponent<NavMeshAgent>();
nma.SetDestination(transform.position);
State = Define.State.Skill;
return;
}
}
Vector3 dir = _destPos - transform.position; // 목적지까지의 방향벡터를 알 수 있다.
if (dir.magnitude < 0.1f) // 만약 목적지까지 거의 도착을 완료했다면
{
State = Define.State.Idle;
}
else
{
// NavMesh를 사용
NavMeshAgent nma = gameObject.GetOrAddComponent<NavMeshAgent>();
nma.SetDestination(_destPos);
nma.speed = _stat.MoveSpeed;
transform.rotation = Quaternion.Slerp(transform.rotation, Quaternion.LookRotation(dir), 10 * Time.deltaTime); // LookAt을 위한 회전을 보다 자연스럽게 하도록
}
}
protected override void UpdateSkill()
{
if (_lockTarget != null)
{
Vector3 dir = _lockTarget.transform.position - transform.position; // 목적지까지의 방향벡터를 알 수 있다.
Quaternion quat = Quaternion.LookRotation(dir); // 목적지를 바라보기 위해서
transform.rotation = Quaternion.Lerp(transform.rotation, quat, 20 * Time.deltaTime); // 자연스럽게 연결되도록
}
}
void OnHitEvent()
{
if (_lockTarget != null)
{
Stat targetStat = _lockTarget.GetComponent<Stat>();
Stat myStat = gameObject.GetComponent<Stat>();
int damage = Mathf.Max(0, myStat.Attack - targetStat.Defense);
targetStat.Hp -= damage;
if (targetStat.Hp > 0)
{
float distance = (_lockTarget.transform.position - transform.position).magnitude;
if (distance <= _attackRange)
State = Define.State.Skill;
else
State = Define.State.Moving;
}
else
{
State = Define.State.Idle;
}
}
else
{
State = Define.State.Idle;
}
}
}
#Destroy #1
- Destroy된 Object는 사실 실제로 삭제된건 C++ Native Object이고, UnityEngine.Object C# Class 부분은 아직 Memory
상에 존재하기 때문에 실제로 null이 된 것은 아니지만 없어진 것처럼 행동해야 하기 때문에 "null"로 처리된다.
> 이것이 바로 fake null 이다.
> 이에 대한 처리가 UnityEngine.Object의 == 연산자 오버로딩에 구현되어 있다.
(진짜 null이 된게 아님에도 Destroy된 Object == null 을 하면 true를 리턴하도록 구현됨)
- Destroy된 Object의 Component를 참조하는 것들을 잘 확인해야 한다.
> Object가 해제되면 당연히 해당 Object의 Component도 참조할 수 없다.
MosterController 수정
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.AI;
public class MonsterController : BaseController
{
// ...
void OnHitEvent()
{
// ...
if (targetStat.Hp <=0)
{
GameObject.Destroy(targetStat.gameObject);
}
// ...
}
}
CameraController 수정
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class CameraController : MonoBehaviour
{
// ...
// Player의 움직임이 실행된 뒤 Camera의 위치를 움직여야 떨리는 현상이 줄어든다.
void LateUpdate() // 게임 Logic에서 LateUpdate()는 Update()보다 늦게 실행된다.
{
if (_mode == Define.CameraMode.QuaterView)
{
if (_player == null)
{
return;
}
// ...
}
}
// ...
}
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class Define
{
public enum WorldObject
{
Unknown,
Player,
Monster,
}
// ...
}
BaseController 수정
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public abstract class BaseController : MonoBehaviour
{
// ...
public Define.WorldObject WorldObjectType { get; protected set; } = Define.WorldObject.Unknown;
// ...
}
PlayerController 수정
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.AI;
public class PlayerController : BaseController
{
// ...
public override void Init()
{
WorldObjectType = Define.WorldObject.Player;
// ...
}
// ...
}
MonsterController 수정
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.AI;
public class MonsterController : BaseController
{
// ...
public override void Init()
{
WorldObjectType = Define.WorldObject.Monster;
// ...
}
// ...
void OnHitEvent()
{
if (_lockTarget != null)
{
// ...
if (targetStat.Hp <=0)
{
Managers.Game.Despawn(targetStat.gameObject);
}
// ...
}
// ...
}
}
GameManagerEx 생성
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class GameManagerEx : MonoBehaviour
{
// 스폰된 Object들은 ID로 관리하는 것이 좋다 => Dictionary가 적당
// 서버에서는 해당 ID를 통해 통신하기 때문이다.
//Dictionary<int, GameObject> _players = new Dictionary<int, GameObject>();
//Dictionary<int, GameObject> _monsters = new Dictionary<int, GameObject>();
// 현재는 멀티가 아닌 싱글 게임이기 때문에 아래와 같이 관리한다.
GameObject _player;
HashSet<GameObject> _monsters = new HashSet<GameObject>(); // HashSet은 Dictionary와 비슷하지만 Key가 X
public GameObject Spawn(Define.WorldObject type, string path, Transform parent = null)
{
GameObject go = Managers.Resource.Instantiate(path, parent);
switch (type)
{
case Define.WorldObject.Monster:
_monsters.Add(go);
break;
case Define.WorldObject.Player:
_player = go;
break;
}
return go;
}
public Define.WorldObject GetWorldObjectType(GameObject go)
{
BaseController bc = go.GetComponent<BaseController>();
if (bc == null)
return Define.WorldObject.Unknown;
return bc.WorldObjectType ;
}
public void Despawn(GameObject go)
{
Define.WorldObject type = GetWorldObjectType(go);
switch (type)
{
case Define.WorldObject.Monster:
{
if (_monsters.Contains(go))
_monsters.Remove(go);
}
break;
case Define.WorldObject.Player:
{
if (_player == go)
_player = null;
}
break;
}
Managers.Resource.Destroy(go);
}
}
Managers 수정
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class Managers : MonoBehaviour
{
// ...
GameManagerEx _game = new GameManagerEx();
// ...
public static GameManagerEx Game { get { return Instance._game; } }
// ...
}
Extension 수정
using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.EventSystems;
public static class Extension
{
// ...
// 폴링하는 Object일 경우 Destroy가 아닌 비활성화 처리가 된다.
// 따라서 null 체크뿐만이 아닌 활성화 상태 여부도 체크해줘야 한다.
public static bool IsValid(this GameObject go)
{
return go != null && go.activeSelf;
}
}
CameraController 수정
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class CameraController : MonoBehaviour
{
// ...
public void SetPlayer(GameObject player) { _player = player; }
// Player의 움직임이 실행된 뒤 Camera의 위치를 움직여야 떨리는 현상이 줄어든다.
void LateUpdate() // 게임 Logic에서 LateUpdate()는 Update()보다 늦게 실행된다.
{
if (_mode == Define.CameraMode.QuaterView)
{
if (_player.IsValid() == false)
{
return;
}
// ...
}
}
// ...
}
GameScene 수정
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class GameScene : BaseScene
{
protected override void Init() {
// ...
GameObject player = Managers.Game.Spawn(Define.WorldObject.Player, "Player");
Camera.main.gameObject.GetOrAddComponent<CameraController>().SetPlayer(player);
Managers.Game.Spawn(Define.WorldObject.Monster, "Knight");
}
// ...
}
> Add( ), Remove( ), Clear( ), UnionWith( ), IntersectWith( ), ExceptWith( ) 등의 함수가 존재한다.
#레벨업
- Damage와 관련된 부분은 공격을 하는 객체가 아닌 공격을 받는 객체에서 처리해주는 것이 좋다.
(공격을 받는 객체의 방어력, 버프 등이 존재하기 때문)
Stat 수정
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class Stat : MonoBehaviour
{
// ...
public virtual void OnAttacked(Stat attacker)
{
int damage = Mathf.Max(0, attacker.Attack - Defense);
Hp -= damage;
if (Hp <= 0) {
Hp = 0;
OnDead();
}
}
protected virtual void OnDead()
{
Managers.Game.Despawn(gameObject);
}
}
PlayerStat 수정
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class PlayerStat : Stat
{
// ...
protected override void OnDead()
{
Debug.Log("Player Dead");
}
}
MonsterController 수정
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.AI;
public class PlayerController : BaseController
{
// ...
void OnHitEvent()
{
if (_lockTarget != null)
{
Stat targetStat = _lockTarget.GetComponent<Stat>();
targetStat.OnAttacked(_stat);
}
// ...
}
// ...
}
PlayerController 수정
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.AI;
public class PlayerController : BaseController
{
// ...
void OnHitEvent()
{
if (_lockTarget != null)
{
Stat targetStat = _lockTarget.GetComponent<Stat>();
targetStat.OnAttacked(_stat);
}
// ...
}
// ...
}
- Monster를 죽일 경우 Monster에 따른 경험치 휙득량을 데이터를 통해 관리해주는 것이 좋다.
using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
namespace Data
{
#region Stat
[Serializable]
public class Stat
{
public int level;
public int maxHp;
public int attack;
public int totalExp;
}
// ...
#endregion
}
Stat 수정
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class Stat : MonoBehaviour
{
// ...
public virtual void OnAttacked(Stat attacker)
{
int damage = Mathf.Max(0, attacker.Attack - Defense);
Hp -= damage;
if (Hp <= 0) {
Hp = 0;
OnDead(attacker);
}
}
protected virtual void OnDead(Stat attacker)
{
PlayerStat playerStat = attacker as PlayerStat;
if (playerStat != null)
{
playerStat.Exp += 5;
}
Managers.Game.Despawn(gameObject);
}
}
PlayerStat 수정
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class PlayerStat : Stat
{
// ...
public int Exp {
get { return _exp; }
set
{
_exp = value;
// Level Up 체크
int level = Level;
while (true)
{
Data.Stat stat;
if (Managers.Data.StatDict.TryGetValue(level + 1, out stat) == false) // 다음 Level이 존재하지 않을 경우
break;
if (_exp < stat.totalExp)
break;
level++;
}
if (level != Level) // Level에 변화가 있을 경우
{
Level = level;
SetStat(Level);
}
}
}
// ...
private void Start()
{
_level = 1;
_exp = 0;
_defense = 5;
_moveSpeed = 5.0f;
_gold = 0;
SetStat(_level);
}
public void SetStat(int level)
{
Dictionary<int, Data.Stat> dict = Managers.Data.StatDict;
Data.Stat stat = dict[level];
_hp = stat.maxHp;
_maxHp = stat.maxHp;
_attack = stat.attack;
}
protected override void OnDead(Stat attacker)
{
Debug.Log("Player Dead");
}
}
# 몬스터 자동 생성
SpawningPool 생성
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.AI;
public class SpawningPool : MonoBehaviour
{
[SerializeField]
int _monsterCount = 0; // 현재 몬스터 수
int _reserveCount = 0; // 현재 Spawn 예약 수
[SerializeField]
int _keepMonsterCount = 0; // 유지시켜야 하는 몬스터 수
[SerializeField]
Vector3 _spawnPos; // Spawn 중심점
[SerializeField]
float _spawnRadius = 15.0f; // Spawn 중심점을 기준으로 랜덤 생성을 위한 생성 거리 범위 제한
[SerializeField]
float _spawnTime = 5.0f; // 랜덤 생성을 위한 생성 시간 범위 제한
public void AddMonsterCount(int value) { _monsterCount += value; }
public void SetkeepMonsterCount(int count) { _keepMonsterCount = count; }
void Start()
{
Managers.Game.OnSpawnEvent -= AddMonsterCount;
Managers.Game.OnSpawnEvent += AddMonsterCount;
}
void Update()
{
while (_reserveCount + _monsterCount < _keepMonsterCount)
{
StartCoroutine("ReserveSpawn");
}
}
IEnumerator ReserveSpawn()
{
_reserveCount++;
yield return new WaitForSeconds(Random.Range(0, _spawnTime));
GameObject obj = Managers.Game.Spawn(Define.WorldObject.Monster, "Knight");
NavMeshAgent nma = obj.GetOrAddComponent<NavMeshAgent>();
Vector3 randPos;
while(true) // randPos가 갈 수 있는 길이 나올때까지 반복
{
Vector3 randDir = Random.insideUnitSphere * Random.Range(0, _spawnRadius); // 랜덤으로 뽑힌 방향벡터가 randDir에 담긴다. (insideUnitCircle은 2D)
randDir.y = 0; // 땅을 뜷고 Spawn 되지 않도록
randPos = _spawnPos + randDir;
// randPos가 갈 수 있는 길인지 체크
NavMeshPath path = new NavMeshPath();
if (nma.CalculatePath(randPos, path)) // 갈 수 있는 길이면 True, 갈 수 없는 길이면 False를 반환
break;
}
obj.transform.position = randPos;
_reserveCount--;
}
}
GameManagerEx 수정
using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class GameManagerEx : MonoBehaviour
{
// ...
public Action<int> OnSpawnEvent; // int는 증가/감소 수
// ...
public GameObject Spawn(Define.WorldObject type, string path, Transform parent = null)
{
// ...
switch (type)
{
case Define.WorldObject.Monster:
// ...
if (OnSpawnEvent != null)
OnSpawnEvent.Invoke(1);
break;
// ...
}
// ...
}
// ...
public void Despawn(GameObject go)
{
// ...
switch (type)
{
case Define.WorldObject.Monster:
{
if (_monsters.Contains(go)) {
_monsters.Remove(go);
if (OnSpawnEvent != null)
OnSpawnEvent.Invoke(-1);
}
}
break;
// ...
}
// ...
}
}
GameScene 수정
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class GameScene : BaseScene
{
protected override void Init() {
// ...
//Managers.Game.Spawn(Define.WorldObject.Monster, "Knight");
GameObject go = new GameObject { name = "SpawningPool" };
SpawningPool pool = go.GetOrAddComponent<SpawningPool>();
pool.SetkeepMonsterCount(5);
}
// ...
}
+ 추가 검색( )
-Unity는 난수 생성과 관련된 함수들이 있는 집합인 Random 이라는 기능을 제공한다.
~> 가장 대표적인 기능이 Random.Range(min, max) 이며 이는 min과 max 범위 내에서 랜덤한 값을 반환한다.
(이때 max의 Type이 int일 경우 max의 값은 포함되지 X)
~> Random.insideUnitSphere은 반경 1을 갖는 구 안의 임의의 위치(Vector3)를 반환하는 프로퍼티이다.
~> Random. insideUnitCircle 은 반경 1을 갖는 구 안의 임의의 위치(Vector2)를 반환하는 프로퍼티이다.