[.Net Core] MMO 컨텐츠 구현(DB,대형구조,라이브) - DB 연동 - 1

Yijun Jeon·2022년 11월 25일
0
post-thumbnail

Inflearn - '[C#과 유니티로 만드는 MMORPG 게임 개발 시리즈] Part9: MMO 컨텐츠 구현 (DB, 대형구조, 라이브 준비)'를 스터디하며 정리한 글입니다.


DB 연동

기존 MMO_Game_Mini 프로젝트 최종 결과물에서 DB 연동 시작

  • Server 산하 DB 관련 폴더, 파일 생성
  • EntityFramework 관련 NuGet Package 다운로드
  1. Microsoft.EntityFrameworkCore.SqlServer
  2. Microsoft.EntityFrameworkCore.Tools
  3. Microsoft.Extensions.Logging.Console

DataModel

DataModel.cs

 [Table("Account")]
    public class AccountDb
    {
        // 자동으로 PK로 설정
        public int AccountDbId { get; set; }
        public string AccountName { get; set; }
        // 1:m 관계
        public ICollection<PlayerDb> Players { get; set; }
    }

    [Table("Player")]
    public class PlayerDb
    {
        public int PlayerDbId { get; set; }
        public string PlayerName { get; set; }
        // m:1 관계
        public AccountDb Account { get; set; }
    }

AppDbContext

AppDbContext.cs

 public class AppDbContext : DbContext
    {
        public DbSet<AccountDb> Accounts { get; set; }
        public DbSet<PlayerDb> Players { get; set; }


        // console 로깅 추가
        static readonly ILoggerFactory _logger = LoggerFactory.Create(builder => { builder.AddConsole(); });

        string _connectionString = @"Data Source=(localdb)\MSSQLLocalDB;Initial Catalog=GameDB;Integrated Security=True;Connect Timeout=30;Encrypt=False;TrustServerCertificate=False;ApplicationIntent=ReadWrite;MultiSubnetFailover=False";

        protected override void OnConfiguring(DbContextOptionsBuilder options)
        {
            options
                .UseLoggerFactory(_logger)
                // Config 초기화 안됐을 때 오류 방지
                .UseSqlServer(ConfigManager.Config == null ? _connectionString : ConfigManager.Config.connectionString);
        }

        protected override void OnModelCreating(ModelBuilder builder)
        {
            // 이름을 이용해서 빠르게 찾을 수 있게 인덱스 추가
            builder.Entity<AccountDb>()
                .HasIndex(a => a.AccountName)
                .IsUnique();

            // 이름을 이용해서 빠르게 찾을 수 있게 인덱스 추가
            builder.Entity<PlayerDb>()
                .HasIndex(p => p.PlayerName)
                .IsUnique();
        }
    }

연결 문자열 - SQL.Server 개체 탐색기 -> 속성

코드로 하드코딩하는 것이 아니라 config 파일로 따로 빼내서ConfigManager.cs에서 추출하는 방식을 써야함

config.json

{
  "dataPath": "../../../../../Client/Assets/Resources/Data",
  "connectionString": "Data Source=(localdb)\\MSSQLLocalDB;Initial Catalog=GameDB"
}

실행

Program.cs

static void Main(string[] args)
{
	...
	// DB
	InitializeDB(forceReset: false);
			
	using (AppDbContext db = new AppDbContext())
    {
		db.Accounts.Add(new AccountDb() { AccountName = "TestAccount" });
		db.SaveChanges();
    }
    ...
}


접속

로그인 하는 부분은 그때그때 데이터를 요청할 때 처리하면 되기 때문에 웹서버로 하는 경우가 많음

접속 로직

  1. 서버에서 클라이언트에서 서버쪽에 붙었다는 사실을 알려줌
    -> S_Connected
  2. 클라에서 로그인하고 싶다는 의사를 서버로 보냄
    -> C_Login
  3. 서버에서 DB를 확인하여 로그인 가능한지 알려줌
    -> S_Login

서버

패킷 설계

Protocol.proto

enum MsgId {
  S_ENTER_GAME = 0;
  S_LEAVE_GAME = 1;
  S_SPAWN = 2; // 다른 플레이어, 몬스터 스폰
  S_DESPAWN = 3;
  C_MOVE = 4;
  S_MOVE = 5;
  C_SKILL = 6;
  S_SKILL = 7;
  S_CHANGE_HP = 8;
  S_DIE = 9;
  S_CONNECTED = 10;
  C_LOGIN = 11;
  S_LOGIN = 12;
}

message S_Connected{

}

message C_Login{
	string uniqueId = 1;
}

message S_Login{
	string loginOk = 1;
}

접속

Server.ClientSession.cs

public override void OnConnected(EndPoint endPoint)
{
	Console.WriteLine($"OnConnected : {endPoint}");

    // 클라에게 연결 됐다고 알려줌
    {
		S_Connected connectedPacket = new S_Connected();
		Send(connectedPacket);
    }
    ...
}

로그인

PacketHandler.cs

public static void C_LoginHandler(PacketSession session, IMessage packet)
{
	C_Login loginPacket = packet as C_Login;
	ClientSession clientSession = session as ClientSession;

    Console.WriteLine($"UniqueId({loginPacket.UniqueId})");

	// TODO : 이런 저런 보안 체크

	// 유효한 아이디인지 확인
	using (AppDbContext db = new AppDbContext())
    {
		AccountDb findAccount = db.Accounts
			.Where(a => a.AccountName == loginPacket.UniqueId).FirstOrDefault();

		if(findAccount != null)
        {
			S_Login loginOk = new S_Login() { LoginOk = 1 };
			clientSession.Send(loginOk);
        }
		else
        {
			// 아이디가 없으면 생성해 줌
			AccountDb newAccount = new AccountDb() { AccountName = loginPacket.UniqueId };
			db.Accounts.Add(newAccount);
			db.SaveChanges();

			S_Login loginOk = new S_Login() { LoginOk = 1 };
			clientSession.Send(loginOk);
		}
    }
}

클라이언트

접속

PacketHandler.cs

public static void S_ConnectedHandler(PacketSession session, IMessage packet)
{
    Debug.Log("S_ConnectedHandler");
    C_Login loginPacket = new C_Login();
    // 시스템 별로 유니크한 아이디 할당해줌
    // 로컬에서 같은 머신으로 테스트 할 때는 추가 조치 필요
    loginPacket.UniqueId = SystemInfo.deviceUniqueIdentifier;
    Managers.Network.Send(loginPacket);
}

로그인

PacketHandler.cs

public static void S_LoginHandler(PacketSession session, IMessage packet)
{
    S_Login loginPacket = new S_Login();
    Debug.Log($"LoginOk({loginPacket.LoginOk})");
}

개선할 점

현재 코드는 해킹의 위험이 많음

  1. 동시에 다른 사람이 같은 UniqueId를 보낸다면?
    -> 같은 Id의 계정을 DB에 Add 하려다가 오류가 나게 됨
  2. 같은 패킷을 여러 번 악의적으로 계속 보낸다면
    -> DB 접속에는 시간이 많이 걸리기 때문에 서버가 오류남
  3. 쌩뚱맞은 타이밍에 그냥 로그인 패킷을 보낼 경우
    -> 생각지도 못한 버그가 발생할 수도 있음
  • 일반적으로 로그인에 대해서도 상태를 하나 관리를 해주면 좋음

Player 연동

클라이언트에서 로그인 단계, 로비에서 캐릭터 선택 단계, 게임에 들어가는 단계 등이 구현이 되어있다고 가정하고 서버에서 연동 작업

구현 내용

  • 클라가 로그인에 성공하면 로비로 들어감
  • 로비에서 본인이 보유 중인 플레이어를 선택할 수 있음
    -> 플레이어의 정보는 이름, 스탯 확인 가능
  • 로비에서 플레이어를 생성할 수 있음
  • 플레이어를 선택하면 게임 입장

서버

패킷 설계

Protocol.proto

enum MsgId {
  ...
  C_ENTER_GAME = 13;
  C_CREATE_PLAYER = 14;
  S_CREATE_PLAYER = 15;
}

// 서버 접속 상태
enum PlayerServerState{
	SERVER_STATE_LOGIN = 0;
	SERVER_STATE_LOBBY = 1;
	SERVER_STATE_GAME = 2;
}

message S_Login{
	int32 loginOk = 1;
	repeated LobbyPlayerInfo players = 2; // 보유 중인 플레이어들
}

// 로비에서 플레이어 생성
message C_CreatePlayer{
	string name = 1;
}

// 생성된 플레이어 전송
message S_CreatePlayer{
	LobbyPlayerInfo player = 1;
}

message C_EnterGame{
	string name = 1; // 플레이어의 이름
}

// 로비에서만 보이는 플레이어의 정보
message LobbyPlayerInfo{
	string name = 1;
	StatInfo statInfo = 2;
}

DB

  • 데이터 모델링 재정의
  • PlayerDb 필요 정보 업데이트
    DataModel.cs
[Table("Player")]
public class PlayerDb
{
    public int PlayerDbId { get; set; }
    public string PlayerName { get; set; }

    // m:1 관계
    [ForeignKey("Account")]
    public int AccountDbId { get; set; }
    public AccountDb Account { get; set; }

    // StatInfo와 동일하게 Stat 정보
    public int Level { get; set; }
    public int Hp { get; set; }
    public int MaxHp { get; set; }
    public int Attack { get; set; }
    public float Speed { get; set; }
    public int TotalExp { get; set; }
}

로그인

기존 C_LoginHandler() 에서 작업하던 내용은 C_MoveHandler() 처럼 GameRoom에서 처리해주는 방식으로 바꿔줘야 함

partial : 하나의 클래스를 다른 이름의 파일로 분할해서 사용 가능

  • 로그인 처리 내용을 ClientSession_PreGame.cs로 이전

ClientSession_PreGame.cs

// PreGame - Game에 들어가기 이전 상태 관리
public partial class ClientSession : PacketSession
{
	// 메모리에 저장되는 플레이어들
	public List<LobbyPlayerInfo> LobbyPlayers { get; set; } = new List<LobbyPlayerInfo>();
	public int AccountDbId { get; private set; }
    public void HandlerLogin(C_Login loginPacket)
    {
		// TODO : 이런 저런 보안 체크
		if (ServerState != PlayerServerState.ServerStateLogin)
			return;

		// 두 번 호출될 경우 오류 방지를 위해 초기화
		LobbyPlayers.Clear();

		// 유효한 아이디인지 확인
		using (AppDbContext db = new AppDbContext())
		{
			AccountDb findAccount = db.Accounts
				.Include(a => a.Players)
				.Where(a => a.AccountName == loginPacket.UniqueId).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()
					{
						Name = playerDb.PlayerName,
						StatInfo = new StatInfo()
						{
							Hp = playerDb.Hp,
							MaxHp = playerDb.MaxHp,
							Level = playerDb.Level,
							TotalExp = playerDb.TotalExp,
							Attack = playerDb.Attack,
                			Speed = playerDb.Speed
                        }

					};
					// 서버 메모리에도 저장
					LobbyPlayers.Add(lobbyPlayer);

					// 패킷에 넣어줌
					loginOk.Players.Add(lobbyPlayer);
                }
				Send(loginOk);

				// 로비로 이동
				ServerState = PlayerServerState.ServerStateLobby;
			}
			else
			{
				// 아이디가 없으면 생성해 줌
				AccountDb newAccount = new AccountDb() { AccountName = loginPacket.UniqueId };
				db.Accounts.Add(newAccount);
				db.SaveChanges(); // ToDO : Exception

				// AccountDbId 메모리에 기억
				AccountDbId = newAccount.AccountDbId;

				S_Login loginOk = new S_Login() { LoginOk = 1 };
				Send(loginOk);

				// 로비로 이동
				ServerState = PlayerServerState.ServerStateLobby;
			}
		}
	}
}

플레이어 생성

ClientSession_PreGame.cs

public void HandlerCreatePlayer(C_CreatePlayer createPacket)
{
	// 로비에서만 생성 가능
	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
        {
			// 1레벨 스탯 정보 추출
			StatInfo stat = null;
			DataManager.StatDict.TryGetValue(1, out stat);

			// DB에 플레이어 생성
			PlayerDb newPlayerDb = new PlayerDb()
			{
				PlayerName = createPacket.Name,
				Level = stat.Level,
				Hp = stat.Hp,
				MaxHp = stat.MaxHp,
				Attack = stat.Attack,
				Speed = stat.Speed,
				TotalExp = 0,
				AccountDbId = AccountDbId,
			};
			db.Players.Add(newPlayerDb);
			db.SaveChanges(); // TODO : ExceptionHandling

			LobbyPlayerInfo lobbyPlayer = new LobbyPlayerInfo()
			{
				Name = createPacket.Name,
				StatInfo = new StatInfo()
				{
					Hp = stat.Hp,
					MaxHp = stat.MaxHp,
					Level = stat.Level,
					TotalExp = 0,
					Attack = stat.Attack,
					Speed = stat.Speed
				}

			};
			// 서버 메모리에도 저장
			LobbyPlayers.Add(lobbyPlayer);

			// 클라에 전송
			S_CreatePlayer newPlayer = new S_CreatePlayer() { Player = new LobbyPlayerInfo() };
			newPlayer.Player.MergeFrom(lobbyPlayer);

			Send(newPlayer);
		}
    }
}
  • 찰나의 순간에 같은 이름으로 계정이 생성된 경우 예외 처리로 생성 실패를 알려줘야 함

게임 입장

  • 기존 ClientSession.OnConnected()에서 입장하고 MyPlayer를 가라로 설정해주는 부분 옮겨서 수정해줌

ClientSession_PreGame.cs

public void HandlerEnterGame(C_EnterGame enterGamePacket)
{
	// 로비에서만 게임 입장 가능
	if (ServerState != PlayerServerState.ServerStateLobby)
		return;

	LobbyPlayerInfo playerInfo = LobbyPlayers.Find(p => p.Name == enterGamePacket.Name);
	if (playerInfo == null)
		return;

	// 기존 입장 & MyPlayer setting 부분
	MyPlayer = ObjectManager.Instance.Add<Player>();
	{
		MyPlayer.Info.Name = playerInfo.Name;
		MyPlayer.Info.PosInfo.State = CreatureState.Idle;
		MyPlayer.Info.PosInfo.MoveDir = MoveDir.Down;
		MyPlayer.Info.PosInfo.PosX = 0;
		MyPlayer.Info.PosInfo.PosY = 0;
		MyPlayer.Stat.MergeFrom(playerInfo.StatInfo);
		MyPlayer.Session = this;
	}

	ServerState = PlayerServerState.ServerStateGame;

	// 1번방에 플레이어 입장
	GameRoom room = RoomManager.Instance.Find(1);
	room.Push(room.EnterGame, MyPlayer);
}

클라이언트

로그인 수신

PacketHandler.cs

// 로그인 OK + 캐릭터 목록 받음
public static void S_LoginHandler(PacketSession session, IMessage packet)
{
    S_Login loginPacket = (S_Login)packet;
    Debug.Log($"LoginOk({loginPacket.LoginOk})");

    // TODO : 로비 UI에서 캐릭터 보여주고, 선택할 수 있도록

    // 플레이어가 없는 경우 생성
    if(loginPacket.Players == null || loginPacket.Players.Count == 0)
    {
        C_CreatePlayer createPacket = new C_CreatePlayer();
        createPacket.Name = $"Player_{Random.Range(0, 10000).ToString("0000")}";
        Managers.Network.Send(createPacket);
    }
    else
    {
        // 일단 무조건 첫 번째 플레이어로 접속
        LobbyPlayerInfo info = loginPacket.Players[0];
        C_EnterGame enterGamePacket = new C_EnterGame();
        enterGamePacket.Name = info.Name;
        Managers.Network.Send(enterGamePacket);
    }

}

플레이어 생성 수신

PacketHandler.cs

public static void S_CreatePlayerHandler(PacketSession session, IMessage packet)
{
    S_CreatePlayer createOkPlayer = (S_CreatePlayer)packet;

    // 플레이어 생성 재송신
    if(createOkPlayer.Player == null)
    {
        C_CreatePlayer createPacket = new C_CreatePlayer();
        createPacket.Name = $"Player_{Random.Range(0, 10000).ToString("0000")}";
        Managers.Network.Send(createPacket);
    }
    else
    {
        C_EnterGame enterGamePacket = new C_EnterGame();
        enterGamePacket.Name = createOkPlayer.Player.Name;
        Managers.Network.Send(enterGamePacket);
    }
}

결과

같은 클라에서 접속하면 같은 Player Name의 Player가 할당됨



HP 연동

전투가 일어나서 HP가 깎였을 때 DB에 저장하고 다시 불러오는 작업이 필요함

의문점

  1. 피가 깎일 때마다 전부 DB에 접근할 필요가 있을까?
    -> DB는 항상 최소한으로 접근해야 함
    -> 플레이어가 방을 나갈 때 비로소 DB에 저장

서버

기존의 LobbyPlayerInfo 에서 플레이어의 DB Id를 저장해 줄 필요가 있음

Protocol.proto

// 로비에서만 보이는 플레이어의 정보
message LobbyPlayerInfo{
	int32 playerDbId = 1;
	string name = 2;
	StatInfo statInfo = 3;
}

Player.cs

public int PlayerDbId { get; set; }
 
public void OnLeaveGame()
{
    // DB 연동
    using (AppDbContext db = new AppDbContext())
    {
        // DB 접근 2번 - get, set
        PlayerDb playerDb = db.Players.Find(PlayerDbId);
        playerDb.Hp = Stat.Hp;
        db.SaveChanges();
        
        VS

        // DB 접근 1번 - set
        PlayerDb playerDb = new PlayerDb();
        playerDb.PlayerDbId = PlayerDbId;
        playerDb.Hp = Stat.Hp;

        db.Entry(playerDb).State = EntityState.Unchanged;
        db.Entry(playerDb).Property(nameof(playerDb.Hp)).IsModified = true;
        db.SaveChanges();
    }
}
  • 같은 클라이언트에서 HP가 깎이고 다시 접속해도 깎인 HP 상태가 유지됨

예외 처리

기존 플레이어 생성 부분의 db.SaveChanges()는 같은 이름으로 찰나에 생성될 경우 등에 대비하여 예외 처리가 필요
-> Extension 메소드로 try~catch 구문 버전으로 정의

Extensions.cs

// 일반적인 Extension 메소드의 문법
public static bool SaveChangesEx(this AppDbContext db)
{
    try
    {
        db.SaveChanges();
        return true;
    }
    catch
    {
        return false;
    }
}
  • 기존 db.SaveChanges()로 처리하던 부분들
db.SaveChanges();

->

// ExceptionHandling
bool success = db.SaveChangesEx();
if (success == false)
	return;

개선

기존 코드는 정보가 날아갈 위험과 전반적인 코드 흐름을 막을 위험이 있음

  1. 서버가 다운되면 아직 저장되지 않은 정보는 날아감
    1). 플레이어가 방을 나갈 때만 DB에 저장하기 때문

  2. 전반적인 코드 흐름을 막아서 굉장히 지체됨
    1). 어떤 Serializer 안에서 GameRoom과 같은 공용적인 부분에서 DB를 직접 호출하는 것은 큰 문제가 될 수 있음
    2). DB에 저장하는 부분에서 조금이라도 지체가 된다면 다른 실시간 전투 부분도 줄줄이 지체가 됨
    -> 다른 유저들에게도 영향

개선 방법

  1. 비동기(Async) 방법 사용?
  2. 다른 쓰레드로 DB 일감을 던져 버리면 되지 않을까?
    -> 다른 쓰레드로 일감을 던지고 그 일감이 끝났을 때 완료되었다는 통보를 받아서 다시 실행

기존 방식 - 서빙 직원이 서빙을 하다 말고 카드를 받아서 결제까지 하고 다시 돌아오는 느낌

개선 방식 - 결제를 담당하는 직원이 따로 있어 서빙 담당은 카드를 전달해주고 결제가 되면 영수증만 돌려주면 되는 느낌

DbTransaction

JobSerializer를 사용해서 서버의 패킷들을 큐에 저장하여 관리하던 방법을 DB에도 사용

  • Player.OnLeaveGame()에서는 DbTransaction의 메소드를 호출해 주기만 함

Player.cs

public void OnLeaveGame()
{
    // DB 연동
    DbTransaction.SavePlayerStatus_Step1(this, Room);
}

lambda 방식

// SingleTon
public static DbTransaction Instance { get; } = new DbTransaction();

// Me (GameRoom) -> You (DB) -> Me (GameRoom)
// lambda 방식
public static void SavePlayerStatus_AllInOne(Player player, GameRoom room)
{
    if (player == null || room == null)
        return;

    // Me (GameRoom)
    PlayerDb playerDb = new PlayerDb();
    playerDb.PlayerDbId = player.PlayerDbId;
    playerDb.Hp = player.Stat.Hp;

    // You
    Instance.Push(() =>
    {
        using (AppDbContext db = new AppDbContext())
        {
            db.Entry(playerDb).State = EntityState.Unchanged;
            db.Entry(playerDb).Property(nameof(playerDb.Hp)).IsModified = true;
            bool success = db.SaveChangesEx();

            if (success)
            {
                // Me
                room.Push(() =>
                {
                    Console.WriteLine($"Hp Saved({playerDb.Hp})");
                });
            }
        }
    });
}

Step by Step 방식

// Step by Step 방식
public static void SavePlayerStatus_Step1(Player player, GameRoom room)
{
    if (player == null || room == null)
        return;

    // Me (GameRoom)
    PlayerDb playerDb = new PlayerDb();
    playerDb.PlayerDbId = player.PlayerDbId;
    playerDb.Hp = player.Stat.Hp;

    // Me (GameRoom) -> You (Db)
    Instance.Push<PlayerDb, GameRoom>(SavePlayerStatus_Step2,playerDb,room);
}

public static void SavePlayerStatus_Step2(PlayerDb playerDb, GameRoom room)
{
    using (AppDbContext db = new AppDbContext())
    {
        db.Entry(playerDb).State = EntityState.Unchanged;
        db.Entry(playerDb).Property(nameof(playerDb.Hp)).IsModified = true;
        bool success = db.SaveChangesEx();

        // You (Db) -> Me (GameRoom)
        if (success)
        {
            room.Push(SavePlayerStatus_Step3, playerDb.Hp);
        }
    }
}

public static void SavePlayerStatus_Step3(int hp)
{
    Console.WriteLine($"Hp Saved({hp})");
}

요즘 게임 서버의 트렌드는 하나의 서버로 모두 관리하는 원서버 방식

서버 분산 : DB 관련 저장 서버를 따로 관리하여 서버에서 DB 서버로 또 패킷을 주고 받기도 함

서버 분산의 단점 : 서버끼리 연동하는 과정이 많이 번거로움


Item 연동

EnterGame으로 게임에 들어가는 순간에 아이템을 로딩하여 정보를 볼 수 있게 작업

서버

패킷 설계

Protocol.proto

enum MsgId {
  ...
  S_ITEM_LIST = 16;
}

// 아이템 종류
enum ItemType{
	ITEM_TYPE_WEAPON = 0;
	ITEM_TYPE_ARMOR = 1;
	ITEM_TYPE_CONSUMABLE = 2;
}
...
// 무기 종류
enum WeaponType{
	WEAPON_TYPE_NONE = 0;
	WEAPON_TYPE_SWORD = 1;
	WEAPON_TYPE_BOW = 2;
}
// 방어구 종류
enum ArmorType{
	ARMOR_TYPE_NONE = 0;
	ARMOR_TYPE_HELMET = 1;
	ARMOR_TYPE_ARMOR = 2;
	ARMOR_TYPE_BOOTS = 3;
}
// 소비재 종류
enum ConsumableType{
	CONSUMABLE_TYPE_NONE = 0;
	CONSUMABLE_TYPE_POTION = 1;
}
...
message S_ItemList{
	repeated ItemInfo items = 1;
}

message ItemInfo{
	int32 itemDbId = 1;
	int32 templateId = 2;
	int32 count = 3;
	int32 slot = 4;
}

DB

DataModel.cs

[Table("Player")]
public class PlayerDb
{
	...
    // 1:m 관계
    public ICollection<ItemDb> Items { get; set; }
}

[Table("Item")]
public class ItemDb
{
    public int ItemDbId { get; set; }
    public int TemplateId { get; set; } // 아이템 구분 Id
    public int Count { get; set; } // 보유 수
    // ex) 0~10 : 착용중인 아이템, 11~40 : 보유중인 아이템, 40~ :창고에 있는 아이템...
    public int Slot { get; set; } // 인벤토리 슬롯 넘버

    [ForeignKey("Onwer")]
    public int? OwnerDbId { get; set; }
    public PlayerDb Owner { get; set; }
}

클래스 모델링

DB 외에 아이템의 인메모리 데이터도 관리하기 위해 인벤토리와 담길 객체 필요
-> Inventory, Item class 정의

Inventory.cs

public class Inventory
{
    public Dictionary<int, Item> _items = new Dictionary<int, Item>();

    public void Add(Item item)
    {
        _items.Add(item.ItemDbId, item);
    }

    public Item Get(int itemDbId)
    {
        Item item = null;
        _items.TryGetValue(itemDbId, out item);
        return item;
    }

    public Item Find(Func<Item, bool> condition)
    {
        foreach(Item item in _items.Values)
        {
            if (condition.Invoke(item))
                return item;
        }
        return null;
    }
}

Inventory에서 lock을 걸지 않아도 괜찮을까?

  • Inventory 자체가 플레이어에 속함
  • 플레이어는 GameRoom에서 관리됨
  • GameRoom은 한 쓰레드만 실행되도록 처리돼 있음
    -> lock 따로 걸지 않아도 됨

Item.cs

public class Item
{
    public ItemInfo Info { get; } = new ItemInfo();

    public int ItemDbId
    {
        get { return Info.ItemDbId; }
        set { Info.ItemDbId = value; }
    }

    public int TemplateId
    {
        get { return Info.TemplateId; }
        set { Info.TemplateId = value; }
    }

    public int Count
    {
        get { return Info.Count; }
        set { Info.Count = value; }
    }

    public ItemType ItemType { get; private set; }
    // 아이템이 겹칠 수 있는지
    public bool Stackable { get; protected set; }

    public Item(ItemType itemType)
    {
        ItemType = ItemType;
    }
    
    public static Item MakeItem(ItemDb itemDb)
    {
        Item item = null;

        ItemData itemData = null;
        DataManager.ItemDict.TryGetValue(itemDb.TemplateId, out itemData);

        if (itemData == null)
            return null;

        switch(itemData.itemType)
        {
            case ItemType.Weapon:
                item = new Weapon(itemDb.TemplateId);
                break;
            case ItemType.Armor:
                item = new Armor(itemDb.TemplateId);
                break;
            case ItemType.Consumable:
                item = new Consumable(itemDb.TemplateId);
                break;
        }
        if(item != null)
        {
            item.ItemDbId = itemDb.ItemDbId;
        }
        return item;
    }
}

Item 클래스 또한 Item class 에서 모든 종류를 포함하는 것이 아닌 종류 별로 Item을 상속받아서 따로 정의

Item.cs

public class Weapon : Item
{ 
    public WeaponType WeaponType { get; private set; }
    public int Damage { get; private set; }
    public Weapon(int templateId) : base(ItemType.Weapon)
    {
        Init(templateId);
    }

    void Init(int templateId)
    {
        ItemData itemData = null;
        DataManager.ItemDict.TryGetValue(templateId, out itemData);
        if (itemData.itemType != ItemType.Weapon)
            return;

        WeaponData data = (WeaponData)itemData;
        {
            TemplateId = data.id;
            Count = 1;
            WeaponType = data.weaponType;
            Damage = data.damage;
            Stackable = false;
        }

    }
}
public class Armor : Item;
public class Consumable : Item;

데이터 모델링

Data.Contents.cs

[Serializable]
public class ItemData
{
    // template id
    public int id;
    // 다국적 지원일 경우 언어에 맞게 언어 Id도 존재해야 함
    public string name;
    public ItemType itemType;
}

public class WeaponData : ItemData
{
    public WeaponType weaponType;
    public int damage;
}

public class ArmorData : ItemData
{
    public ArmorType armorType;
    public int defence;
}

public class ConsumableData : ItemData
{
    public ConsumableType consumableType;
    public int maxCount;
}
  • 로더 또한 3개로 따로 따로 만들어야 할까?

특정 templateId의 아이템을 가져오고 싶을 때 그 아이템의 3종류 중 어느 것인지는 모름
-> 로더는 ItemData를 담는 하나의 딕셔너리로 구성하는 것이 좋음

Data.Contents.cs

// Item 로더
[Serializable]
public class ItemLoader : ILoader<int, ItemData>
{
    public List<WeaponData> weapons = new List<WeaponData>();
    public List<ArmorData> armors = new List<ArmorData>();
    public List<ConsumableData> consumables = new List<ConsumableData>();

    public Dictionary<int, ItemData> MakeDict()
    {
        Dictionary<int, ItemData> dict = new Dictionary<int, ItemData>();
        foreach (ItemData item in weapons)
        {
            item.itemType = ItemType.Weapon;
            dict.Add(item.id,item);
        }
        foreach (ItemData item in armors)
        {
            item.itemType = ItemType.Armor;
            dict.Add(item.id, item);
        }
        foreach (ItemData item in consumables)
        {
            item.itemType = ItemType.Consumable;
            dict.Add(item.id, item);
        }
        return dict;
    }
}
  • 데이터 매니저 설정

DataManager.cs

public class DataManager
{
    public static Dictionary<int, StatInfo> StatDict { get; private set; } = new Dictionary<int, StatInfo>();
    public static Dictionary<int, Data.Skill> SkillDict { get; private set; } = new Dictionary<int, Data.Skill>();
    public static Dictionary<int, Data.ItemData> ItemDict { get; private set; } = new Dictionary<int, Data.ItemData>();

    public static void LoadData()
    {
        StatDict = LoadJson<Data.StatData, int, StatInfo>("StatData").MakeDict();
        SkillDict = LoadJson<Data.SkillData, int, Data.Skill>("SkillData").MakeDict();
        ItemDict = LoadJson<Data.ItemLoader, int, Data.ItemData>("ItemData").MakeDict();
    }
    ...
}

아이템 로딩 & 송신

  • 플레이어 입장 시 선택한 MyPlayer의 아이템을 DB에서 불러오고 그 목록을 클라에게 전달

ClientSession_PreGame.HandlerEnterGame()

MyPlayer = ObjectManager.Instance.Add<Player>();
{
	...
	// 아이템 목록을 갖고 옴
	using (AppDbContext db = new AppDbContext())
	{
		List<ItemDb> items = db.Items
			.Where(i => i.OwnerDbId == playerInfo.PlayerDbId)
			.ToList();

		foreach(ItemDb itemDb in items)
        {
			// 메모리 인벤토리
			Item item = Item.MakeItem(itemDb);
			if(item != null)
            {
				MyPlayer.Inven.Add(item);

				// 클라 인벤토리
				ItemInfo info = new ItemInfo();
				info.MergeFrom(item.Info);
				itemListPacket.Items.Add(info);
			}
        }
	}
	// 클라에게 아이템 목록 전달
	Send(itemListPacket);
}

클라이언트

데이터 모델링

정의하고 싶은 아이템의 데이터들을 ItemData.json에 저장

ItemData.json

{
  "weapons": [
    {
      "id": "1",
      "name": "견습생의 검",
      "weaponType": "Sword",
      "damage": "10"
    },
    {
      "id": "2",
      "name": "견습생의 활",
      "weaponType": "Bow",
      "damage": "5"
    }
  ],
  "armors": [
    {
      "id": "100",
      "name": "뼈 갑옷",
      "armorType": "Armor",
      "defence": "15"
    },
    {
      "id": "101",
      "name": "뼈 투구",
      "armorType": "Helmet",
      "defence": "5"
    }
  ],
  "consumables": [
    {
      "id": "200",
      "name": "HP 물약",
      "consumableType": "Potion"
    }
  ]
} 

아이템 수신

  • 일단 테스트를 위해 받은 아이템들의 아이템 Id와 개수를 출력

PacketHandler.cs

public static void S_ItemListHandler(PacketSession session, IMessage packet)
{
    S_ItemList itemList = (S_ItemList)packet;

    foreach(ItemInfo item in itemList.Items)
    {
        Debug.Log($"{item.TemplateId} : {item.Count}");
    }

}

0개의 댓글