단순히 생각해 각 유저마다 게임 월드가 평행 우주처럼 동작한다고 생각하면 된다.
예를 들어 2인 멀티 플레이 게임을 한다 하면 캐릭터는 총 4개가 존재한다.
A플레이어의 컴퓨터에는 로컬로 동작하는 a캐릭터, 리모트로 동작하는 b가 존재하고
B플레이어의 컴퓨터에는 리모트로 동작하는 a캐릭터, 로컬로 동작하는 b가 존재한다.
- 서버의 역할
- 서버는 각 평행우주에서 동작하고 있는 리모트 캐릭터에게 로컬이 행하고 있는 명령을 전송해주어 로컬캐릭터와 리모트 캐릭터가 같은 명령을 행하도록 해주는 역할이다.
- 로컬 권한 검사
- 리모트 캐릭터는 서버의 명령, 동기화를 통해서만 명령을 받아야하므로 로컬의 명령을 받지 않도록 방지 해주어야 한다. 이는if(!local) { return; }
을 사용해 방지해줄 수 있다.
리모트 캐릭터는 local = false, 로컬 플레이는 local = true로 되어있어 리모트 캐릭터는 로컬의 명령을 받는걸 방지할 수 있다.
기본적으로 네트워크 기반 게임은 대기중인 서버에 클라이언트들이 접속하는 서버-클라이언트 방식
- 서버의 자원이 온전히 네트워크를 유지하는 데 사용되며, 서버가 플레이어로서 게임에 참가하지 않는 형태.
- 클라이언트는 서버에 언제든지 접속 가능하며, 고성능의 서버가 제공되기 때문에 쾌적한 환경에서 플레이 가능하다. 하지만 서버를 유지하기 위한 고정비용이 많이 발생한다.
- 서버의 모습
- 전용 서버 대신 클라이언트중 하나가 서버의 역할을 한다. 다른 이름으로는 'Play as Host'라고도 부른다. 이로 인해 호스트 역할을 담당한 클라이언트는 네트워크를 유지하기 위해 연산량이 많아진다.
- 전용서버처럼 서버가 아니라 호스트와 연결하기 때문에 서버의 유지비용이 저렴하다. 그러나 품질은 호스트의 네트워크 품질에 따라 크게 달라진다. 호스트가 접속을 종료하면 다른 호스트를 선정해야 하는 절차가 있어야 한다.
- 서버의 모습
- 참가한 클라이언트 모두가 호스트 호스트 역할을 겸한다. 이로 인해 호스트에게 부여되는 네트워크 연산량을 분산하여 계산, 연결된 다른 클라이언트에게 결과만 전송하여 연산부담을 확 낮출수 있다.
- P2P방식과 다르게 한 클라이언트가 접속을 종료하여도 호스트를 재선정 할 필요가 없어진다.
- 클라이언트가 증가할수록 반응 속도가 현저하게 감소하게 된다. 통상 16개의 클라이언트를 상한선으로 두며 이러한 증상이 나타나는 이유는 각각의 클라이언트가 자신을 제외한 모든 클라이언트와 직접 연결되어 있어야 하기 때문이다.
- 수치 변조에 취약하다. 리슨서버 방식이나, 전용 서버 방식은 전송받은 데이터와 서버에 저장된 데이터를 비교하여 호스트에게 데이터를 다시 전송받기 때문에 클라이언트는 수치를 변조하기 어렵지만, P2P서버는 각 클라이언트가 연산을 하고 서로 동기화 하기 때문에 수치를 변경하여 다른 클라이언트에게 전송만 하면 되므로 취약하다.
- 서버의 모습
네트워크 게임은 공정한 결과를 보장하고 수치에 대한 위변조를 방지해야 한다.
- 승패와 관련된 중요한 연산은 서버(호스트)에게 위임한다. 이 방식에서 클라이언트는 서버(호스트)연산 결과를 표시하는 화면을 담당한다.
- 동기화에 오차가 존재하는 경우 기준이 되는 월드를 정하기 위하여
- 클라이언트의 변조나 위조 행위를 막기위하
- Remote Procedure Call
- 클라이언트가 호스트에게 처리를 위임하고, 호스트가 처리 결과를 클리아언트에게 전송하려면 RPC를 구현해야 한다.
- 정보 클래스 : IPAddress, Dns, IPHostEntry, IPEndPoint
- 연결 클래스 : TcpListener, TcpClient, UdpClient
- ip : '0. 0. 0. 0'과 같은 형식으로 되어있는 것
- Domain : www.XXX.com과 같은 문자형식으로 되어있는 것
IPAddress, Dns, IPHostEntry, IPEndPoint
- using System.Net 선언
- ip주소 설정 : string에 ip주소를 넣으면 IPAddress를 반환한다.
public static IPAddress Parse(string);
- ip주소 반환 : 내부에 저장된 ip주소를 반환가능하다.
public override string Tostring();
using System.Net;
//...
public void Main(string[] args)
{
string address = "127.0.0.1";
IPAddress IP = IPAddress.Parse(address);
Debug.Log("IP : " + IP.Tostring());
}
- GetHostEntry : IPAddress타입인 address의 ip주소로 도메인명을 검색하여 저장한다.
public static IPHostEntry GetHostEntry(address);
- string타입인 hostNameOrAddress에 도메인 명을 기입하여 ip주소를 검색하여 저장한다.
public static IPAddress[] GetHostAddresses(hostNameOrAddress);
using System.Net;
//...
public void Main(string[] args)
{
IPAddress[] IP = Dns.GetGHostAddressrs("www.naver.com");
foreach(IPAddress HostIP in IP)
{
Debug.Log(HostIP);
}
}
출력결과로 알수 있는것 : 하나의 도메인 주소로 여러개의 ip주소를 가질수 있다.
- 다수의 ip주소를 저장할 수 있다.
public IPAddress[] AddressList { get; set; }
- 호스트명을 설정하거나 가져올 수 있다.
public string HostName { get; set; }
using System.Net;
//...
public void Main(string[] args)
{
IPHostEntry HostInfo = Dns.GetHostEntry("www.naver.com");
foreach(IPAddress HostIP in HostInfo.AddressList)
{
Debug.Log(HostIP);
}
Debug.Log(HostInfo.HostName);
}
- ip주소와 port번호 설정
public IPEndPoint(long address, int port); or public IPEndPoint(IPAddress address, int port);
- Address속성
public IPAddress Address { get; set; }
- Port속성
public int Port { get; set; }
using System.Net;
//...
public void Main(string[] args)
{
IPAddress IPInfo = IPAddess.Parse("127.0.0.1");
int Port = 8888;
IPEndPoint endpoint = new IPEndPoint(IPInfo, Port);
Debug.Log("IP : " + endpoint.Address + "Port : " + endpoint.Port);
Debug.Log(endpoint.Tostring());
}
TcpListener, TcpClient, UdpClient
- TcpListener(서버), TcpClient(서버, 클라이언트), UdpClient(서버, 클라이언트)
- Socket가반이다.
- using System.Net.Sockets 선언
- 생성자 : 서버 ip주소와 포트 번호 설정
public TcpListener(IPAddress serveraddress, int serverport); or public TcpListener(IPEndPoint serverEP);
- 서버 시작 , 일시정지
TcpListener.Start() // 시작 TcpListener.Stop() // 일시정지
- Start()를 하고 사용할수 있도록 해주는것
- 클라이언트의 요청을 받아 Accept를 대기한다. 연결시 TcpClient를 반환한다.TcpListener.AcceptTcpClient()
using System.Net;
using System.Net.Sockets;
//...
public void Main(string[] args)
{
IPAddress IP = IPAddess.Parse("127.0.0.1");
int Port = 8888;
TcpListener listener = new TcpListener(IP, Port)
Debug.Log("EP : " + listener.LocalEndPoint.ToString());
}
using System.Net;
using System.Net.Sockets;
//...
public void Main(string[] args)
{
IPAddress IP = IPAddress.Parse("127.0.0.1");
int PORT = 8888;
TcpListener listener = new TcpListener(IP, PORT);
listenenr.Start();
Debug.Log("대기상태 시작");
TcpClient client = listener.AcceptTcpClient();
Debug.Log("대기상태 종료");
//클라이언트가 접속하면 대기상태가 종료된다.
listener.Stop();
}
- 생성자 : ip주소와 포트 설정
public TcpClient(string ServerDomain, int port);
using System.Net;
using System.Net.Sockets;
//...
public void Main(string[] args)
{
string ServerIP = "0.0.0.0";
int ServerPort = 8888;
TcpClient client = new TcpClient(ServerIP, ServerPort);
if(client.Connected)
{
Debug.Log("서버 연결 성공");
}
else
{
Debug.Log("서버 연결 실패");
}
client.Close();
}
- 서버와 클라이언트가 계속 연결을 유지하는 양방향 프로그래밍 방식
- 서버와 클라이언트가 실시간으로 데이터를 주고받는 상황이 필요한 경우 사용
socket()
클라이언트와 연결할 Socket을 생성해준다.
bind()
서버가 사용할 IP와 포트를 조합하여 소켓이랑 결합해준다.
listen()
클라이언트의 연결요청을 대기한다. (괄호 안에 정수를 넣는다면 그만큼의 대기열을 생성하는것이고 순서대로 접속 처리한다.)
accept()
클라이언트로부터 연결 요청이 수신되면 새로 소켓을 생성하여 연결한다.
send() / receive()
해당 소켓을 통하여 클라이언트와 데이터를 송수산할 수 있다.
close()
데이터 송수신이 완료되면 해당 소켓을 닫는다.
socket()
서버와 연결할 Socket을 생성해준다.
connect()
IP와 포트번호로 식별되는 대상에게 연결 요청을 보낸다.
send() / receive()
소켓을 통하여 연결된 대상과 데이터를 송수산할 수 있다.
close()
연결된 대상과의 연결을 해제하고 소켓을 닫는다.
close()를 호출한 후 다시 데이터를 주고 받으려면 기존의 소켓은 사용이 불가하고 새로 생성해서 대상과 연결후 사용한다.
Socket 참고 : https://www.zehye.kr/network/2021/10/23/Network_socket_http/
소켓을 연결하여 서버와 클라이언트간 데이터를 송수신 하기위해 일정한 크기의 Buffer가 필요하다.
byte[] sendBuffer = new byte[128]; // 수신
byte[] receiveBuffer = new byte[128]; // 송신
Packet
- 버퍼를 사용하여 데이터를 전송할때 구조체를 만들어 그 안에 전송하고자 하는 데이터를 넣어 전송한다.
- '길이' - '헤더' - '데이터' - '엔드마커'의 구조를 가진다.
- 길이는 패킷의 전체 길이를 뜻한다.
'길이' = 1byte , '헤더' = 2byte
'데이터' = Xbyte , '엔드마커' = 1byte
- 헤더를 이용하여 어디에 사용할 데이터인지 확인할수 있다. (데이터 식별)
void MakePacket(_data, _socket, _header)
{
byte sendlength = (byte)sendbuffer.Length;
byte[] Lenght = { sendlength };
byte[] Header = BitConverter.Getbytes(_header);
byte[] Data = _data;
Array.Copy(Length, 0, sendbuffer, 0, Lenght.Lenght);
Array.Copy(Header, 0, sendbuffer, 1, Header.Lenght);
Array.Copy(Data, 2, sendbuffer, 3, Data.Lenght);
//_socket에 할당된 상대에게 전송
}
void DismantlePacket(_)
{
byte[] Length = new byte[1];
byte[] Header = new byte[2];
Array.Copy(tmp, 0, Length, 0, Length.Length);
Array.Copy(tmp, 1, Header, 0, Header.Length);
short _header = BitConverter.ToInt16(Header, 0);
// _header가 어떤 데이터에 적용시켜야 하는지 if()문이나 switch()문을 통하여 데이터를 적용한다.
// 사용 예시
switch(_header)
{
case : 1001 // 1001이 이동명령을 처리할 헤더로 임의지정
byte[] _xPos = new byte[4];
byte[] _yPos = new byte[4];
byte[] _zPos = new byte[4];
Array.Copy(tmp, 5, _xPos, 0, _xPos.Length);
Array.Copy(tmp, 9, _yPos, 0, _yPos.Length);
Array.Copy(tmp, 13, _zPos, 0, _zPos.Length);
otherPeerPos.x = BitConverter.ToSingle(_xPos, 0);
otherPeerPos.y = BitConverter.ToSingle(_yPos, 0);
otherPeerPos.z = BitConverter.ToSingle(_zPos, 0);
break;
}
}
클라이언트와 서버는 어떤 헤더가 어떤 데이터에 적용되어야 할지 알고 있어야 한다.