Game Server 연결하기
이번 포스팅에서는 지난번 포스팅에 이어 서버 선택창에서 특정 서버를 클릭할 경우 Login Scene에서 Lobby Scene으로 이동함과 동시에 AccountServer뿐 아니라 GameServer에 연결을 시도하는 작업을 할 것이다.
특정 서버를 클릭했을 때 서버 연결은 나름 간단하다.
public void OnClickServer(PointerEventData data)
{
// 서버 접속
Managers.Network.ConnectToGame(Info);
}
public void ConnectToGame(ServerInfo info)
{
ServInfo = info;
IPAddress ipAddr = IPAddress.Parse(info.IpAddress);
IPEndPoint endPoint = new IPEndPoint(ipAddr, info.Port);
Connector connector = new Connector();
connector.Connect(endPoint,
() => { return _session; },
1);
}
ServInfo를 NetworkManager에서 들고 있게 하여 언제든지 현재 서버에 대한 정보를 받아 올 수 있게 한다.
쨌든 커넥터를 통해 우리가 받아온 서버 정보(ip주소, port)를 이용해 연결을 시도한다.
연결이 성공적으로 됐다면 아마 GameServer에서 OnConnected가 호출될 것이다.
밑은 게임서버의 일부분이다.
// 게임 서버
public override void OnConnected(EndPoint endPoint)
{
{
S_Connected connectedPacket = new S_Connected();
Send(connectedPacket);
}
GameLogic.Instance.PushAfter(5000, Ping);
}
게임서버에서 하나의 세션이 연결되면 두개의 패킷을 세션에게 보내게한다. 하나는 정상적으로 연결이 됐다는 패킷이고 하나는 주기적으로 클라이언트와 서버와의 연결이 제대로 이루어져있는지 확인하는 Ping체크 패킷이다. 5초에 한번씩 Ping 체크를 보낼것이고 클라이언트는 그거에 맞춰 에코서버처럼 동작해 Pong 패킷을 보낼것이다. 만약 Pong 패킷이 30초 이내에 오지 않는다면 클라이언트를 강제로 끊어내게 할 것이다.
다시 돌아와서 서버쪽에서 성공적으로 클라이언트에게 접속이 됐다고 패킷을 보내면 클라이언트에서 로그인 요청을 하게된다.
이때 우리가 이전시간에 만들어둔 ShardDb가 매우 중요한 순간이다.
ShardDb에는 AccountServer에서 플레이어가 계정을 생성하고 로그인 시도를 했을때, 시도한 계정의 Id와 Token을 발급한다.
이제 그 Token을 이용해 클라이언트가 정상적으로 서버에게 로그인 요청을 보냈는지 확인할 수 있다.
먼저 클라이언트의 코드이다.
public static void S_PingHandler(PacketSession session, IMessage packet)
{
C_Pong pongPacket = new C_Pong();
Managers.Network.Send(pongPacket);
}
public static void S_ConnectedHandler(PacketSession session, IMessage packet)
{
C_Login loginPacket = new C_Login();
loginPacket.AccountId = Managers.Network.AccountId;
loginPacket.Token = Managers.Network.Token;
Managers.Network.Send(loginPacket);
}
위는 Ping 패킷이 왔을때 바로 Pong 패킷을 보내는 코드이고 밑에는 Connected 패킷이 서버로 부터 도착했을 때 코드이다.
Login 요청 패킷을 보내는데 Id와 Token을 서버에게 보내게된다.
그렇게 하면 서버쪽에서는 다음과 같이 처리할 수 있다.
public void HandleLogin(C_Login loginPacket)
{
// 현재 플레이어가 로그인 상태에서만 로그인하는지 확인
if (ServerState != PlayerServerState.ServerStateLogin)
{
S_Banish banPacket = new S_Banish();
Send(banPacket);
return;
}
// 로그인 토큰 확인
using (SharedDbContext db = new SharedDbContext())
{
TokenDb findToken = db.Tokens.Where(a => a.AccountDbId == loginPacket.AccountId).FirstOrDefault();
if(findToken == null)
{
S_Banish banPacket = new S_Banish();
Send(banPacket);
return;
}
else
{
if(DateTime.Compare(DateTime.UtcNow, findToken.Expired) > 0)
{
S_Banish banPacket = new S_Banish();
Send(banPacket);
return;
}
}
}
LobbyPlayers.Clear();
using (AppDbContext db = new AppDbContext())
{
// 이 서버에 계정이 있는지 확인
AccountDb findAccount = db.Accounts
.Include(a => a.Players)
.Where(a => a.AccountLoginId == loginPacket.AccountId).FirstOrDefault();
// 계정이 있다.
if (findAccount != null)
{
// AccountDbId 메모리에 기억
AccountDbId = findAccount.AccountDbId;
S_Login loginOk = new S_Login() { LoginOk = 1 };
foreach (PlayerDb playerDb in findAccount.Players)
{
LobbyPlayerInfo lobbyPlayer = new LobbyPlayerInfo()
{
PlayerDbId = playerDb.PlayerDbId,
Name = playerDb.PlayerName,
Level = playerDb.Level,
ClassType = (int)playerDb.PlayerClass,
};
// 메모리에도 들고 있다
LobbyPlayers.Add(lobbyPlayer);
// 패킷에 넣어준다
loginOk.Players.Add(lobbyPlayer);
}
Send(loginOk);
// 로비로 이동
ServerState = PlayerServerState.ServerStateLobby;
}
else
{
AccountDb newAccount = new AccountDb() { AccountLoginId = loginPacket.AccountId };
db.Accounts.Add(newAccount);
bool success = db.SaveChangesEx();
if (success == false)
return;
// AccountDbId 메모리에 기억
AccountDbId = newAccount.AccountDbId;
S_Login loginOk = new S_Login() { LoginOk = 1 };
Send(loginOk);
// 로비로 이동
ServerState = PlayerServerState.ServerStateLobby;
}
}
}
만약 토큰이 유효하지 않는다면 Banish 패킷을 보내게된다. 이 패킷은 클라이언트의 접속을 강제로 끊고 클라이언트 프로그램을 종료하게된다. 즉 유효하지 않은 접근이라면 로그인을 허용하지 않는 것이다. 유효인지 판단하는 기준은 클라이언트가 로그인 요청을해 발급 받은 Token의 10분 후 까지 이다.
로그인 요청이 서버쪽으로부터 허락이된다면 클라에선 다음 코드를 통해 넘어갈 수 있다.
public static void S_LoginHandler(PacketSession session, IMessage packet)
{
S_Login loginPacket = (S_Login)packet;
Debug.Log($"LoginOk({loginPacket.LoginOk})");
var lobbyPlayers = loginPacket.Players;
foreach (var player in lobbyPlayers)
{
Managers.Network.LobbyPlayerInfos.Add(player);
}
TransitionSettings ts = Managers.Resource.Load<TransitionSettings>("Trans/LinearWipe");
TransitionManager.Instance().Transition(Define.Scene.Lobby, ts, 0);
Managers.UI.CloseAllPopupUI();
}
그 이후로는 컨텐츠적인 내용이다. 캐릭터 선택창을 만드는 부분이다.
캐릭터 선택창
이렇게 캐릭터 선택창을 구현했다. 캐릭터 선택창에서 가운데에는 현재 가지고 있는 캐릭터들이 등장하게 될것이기에 비워뒀다.
public void Setting(int index)
{
_index = index;
List<LobbyPlayerInfo> lobbyPlayerInfos = Managers.Network.LobbyPlayerInfos;
// 플레이어가 있음
if(index < lobbyPlayerInfos.Count)
{
Managers.Resource.Instantiate("Creature/Player/PlayerLobby", Playerparent);
UI_PlayerInfoCanvas_Item item = Managers.UI.MakeSubItem<UI_PlayerInfoCanvas_Item>(transform);
item.Setting(lobbyPlayerInfos[index]);
}
else
{
UI_PlayerInfoCanvas_Item item = Managers.UI.MakeSubItem<UI_PlayerInfoCanvas_Item>(transform);
item.Setting(null);
}
}
이 코드를 통해 현재 LobbyPlayer가 존재하는지에 따라 다른 모습을 보여주게끔 설계했다.
using EasyTransition;
using Google.Protobuf.Protocol;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.UI;
using UnityEngine.UIElements;
public class UI_PlayerInfoCanvas_Item : UI_Base
{
GameObject NonPlayer;
GameObject OnPlayer;
public Sprite Beginner;
public Sprite Warrior;
public Sprite Archer;
enum Images
{
PlayerClassImage,
}
enum Texts
{
PlayerNickNameText,
PlayerClassText,
PlayerLevelText,
}
public override void Init()
{
Canvas canvas = GetComponent<Canvas>();
canvas.renderMode = RenderMode.WorldSpace;
canvas.worldCamera = Camera.main;
transform.localPosition = Vector3.zero;
BindText(typeof(Texts));
NonPlayer = transform.Find("NonPlayerUIObj").gameObject;
OnPlayer = transform.Find("PlayerUIObj").gameObject;
BindImage(typeof(Images));
}
public void Setting(LobbyPlayerInfo info)
{
transform.localPosition = Vector3.zero;
if (info == null)
{
OnPlayer.SetActive(false);
}
else
{
NonPlayer.SetActive(false);
switch (info.ClassType)
{
case (int)ClassTypes.Beginner:
GetText((int)Texts.PlayerClassText).text = "초보자";
GetImage((int)Images.PlayerClassImage).sprite = Beginner;
break;
case (int)ClassTypes.Warrior:
GetText((int)Texts.PlayerClassText).text = "전사";
GetImage((int)Images.PlayerClassImage).sprite = Warrior;
break;
case (int)ClassTypes.Archer:
GetText((int)Texts.PlayerClassText).text = "궁수";
GetImage((int)Images.PlayerClassImage).sprite = Archer;
break;
}
GetText((int)Texts.PlayerLevelText).text = $"Lv. {info.Level}";
GetText((int)Texts.PlayerNickNameText).text = info.Name;
}
}
}
전체적인 모습은 밑에 영상에서 확인할 수 있다.
캐릭터 생성
캐릭터 생성에 대해 많은 고민이 있었는데 아직 Player의 DB에는 어떤 내용을 넣어야할지 감이 잡히지 않아 일단 아주 기본적인 내용만 넣고 작업을 했다. 남캐인지 여캐인지 선택하게끔하되, 아직은 남캐만 생성할 수 있게 설정하고 추후에 수정하도록 하자.
캐릭터 생성창에서 선택을 누르게된다면 닉네임 필드를 참고해 이름이 무조건 있도록 체크하게된다.
또한 제대로 닉네임을 적었다면 서버한테 캐릭터 생성요청을 하게 된다.
public void OnClickCreatePlayerMaleBtn(PointerEventData data)
{
if (isClick) return;
isClick = true;
CreatePlayer(isMale: true);
}
void CreatePlayer(bool isMale)
{
string name = GetObject((int)GameObjects.NickNameInput).GetComponent<TMP_InputField>().text;
if(name == "" || name.Length < 2)
{
Managers.UI.ShowPopupUI<UI_Confirm_Popup>().Setting("닉네임이 두글자 보다 작습니다.", () => { isClick = false; });
return;
}
else
{
C_CreatePlayer createPacket = new C_CreatePlayer();
createPacket.Name = name;
createPacket.IsMale = isMale;
Managers.Network.Send(createPacket);
isClick = false;
}
}
서버는 C_CreatePlayer 패킷이 오게되면 다음과 같이 동작한다.
public void HandleCreatePlayer(C_CreatePlayer createPacket)
{
// TODO : 이런 저런 보안 체크
if (ServerState != PlayerServerState.ServerStateLobby)
return;
using (AppDbContext db = new AppDbContext())
{
PlayerDb findPlayer = db.Players
.Where(p => p.PlayerName == createPacket.Name).FirstOrDefault();
if (findPlayer != null)
{
// 이름이 겹친다
Send(new S_CreatePlayer());
}
else
{
// DB에 플레이어 만들어줘야 함
PlayerDb newPlayerDb = new PlayerDb()
{
PlayerName = createPacket.Name,
IsMale = createPacket.IsMale,
PlayerClass = ClassTypes.Beginner,
Level = 1,
Hp = 50,
MaxHp = 50,
Mp = 5,
MaxMp = 5,
MaxAttack = 3,
MinAttack = 1,
Defense = 0,
Str = 4,
Dex = 4,
Int = 4,
Luk = 4,
Speed = 1,
Exp = 0,
StatPoint = 12,
AccountDbId = AccountDbId
};
db.Players.Add(newPlayerDb);
bool success = db.SaveChangesEx();
if (success == false)
return;
// 메모리에 추가
LobbyPlayerInfo lobbyPlayer = new LobbyPlayerInfo()
{
PlayerDbId = newPlayerDb.PlayerDbId,
Name = createPacket.Name,
Level = newPlayerDb.Level,
ClassType = (int)newPlayerDb.PlayerClass,
};
// 메모리에도 들고 있다
LobbyPlayers.Add(lobbyPlayer);
// 클라에 전송
S_CreatePlayer newPlayer = new S_CreatePlayer() { Player = new LobbyPlayerInfo() };
newPlayer.Player.MergeFrom(lobbyPlayer);
Send(newPlayer);
}
}
}
일단 닉네임이 겹치는지 확인하고 겹치지 않는다면 DB에 Player를 저장하고 그 값을 다시 클라에게 보내준다.
public static void S_CreatePlayerHandler(PacketSession session, IMessage packet)
{
S_CreatePlayer createOkPacket = (S_CreatePlayer)packet;
if (createOkPacket.Player == null)
{
Managers.UI.ShowPopupUI<UI_Confirm_Popup>().Setting("닉네임이 중복됩니다.\n 다른 닉네임을 사용해주세요.");
}
else
{
Managers.Network.LobbyPlayerInfos.Add(createOkPacket.Player);
Managers.UI.FindPopupUI<UI_CreatePlayer_Popup>().EndCreate();
}
}
클라에서는 받은 Player를 LobbyPlayer에 넣고 다시 캐릭터 선택창으로 넘어가게된다.
이로써 어느정도 캐릭터 선택창과 캐릭터 생성까지 할 수 있게 됐다.
다음 포스팅은 어떤 Object의 Spawn이나 DeSpawn, 그리고 이동이나 맵 제작을 할 것 같다.
영상
'Unity > 온라인 RPG' 카테고리의 다른 글
[Unity 3D] 캐릭터 이동 동기화 (0) | 2024.04.23 |
---|---|
[Unity 3D] Navigation을 이용한 캐릭터 이동 (0) | 2024.04.17 |
[Unity 3D] Title과 Login 구현 (UI 작업, 서버 연동) (0) | 2024.04.09 |
[Unity 2D] 대형 구조 관리 - 서버 구조 개선 작업 (0) | 2024.04.02 |
[Unity 2D] DB 연동 - Reward 와 아이템 착용 (0) | 2024.03.29 |