3D 액션 게임 만들기(3)
인벤토리의 구현을 마무리하도록 한다. 그래서 인벤토리의 한 칸을 담당하는 Inventory Slot 버튼의 스크립트를 구현한다.

멤버 변수로 현재 슬롯이 비어있는지 확인하는 isEmpty와 지정한 슬롯이 몇 번째 슬롯인지 저장하는 slotIndex, 그리고 현재 슬롯이 선택되었는지를 저장하는 isSelect를 선언한다. 인벤토리에서 아이템의 정보를 출력할 UI를 각각 정의해준다. 아이템의 아이콘, 총량 그리고 해당 슬롯이 선택되었을때 플레이어가 알 수 있도록 보여주는 이미지가 있다. 아이템을 얻었을 때 하나에 슬롯에 대해 DrawItemSlot은 아이템의 데이터로 슬롯을 그리는 것이다. 아이템의 데이터를 받아 Resources 폴더에서 스프라이트를 가져오고(Resources 폴더에 미리 아이템의 데이터에 저장된 icon에 맞는 스프라이트를 넣어두었다) 아이템의 개수도 가져와서 갱신한다. ClearSlot 함수는 슬롯의 UI들을 비활성화시키는 것이다. 그리고 ChangeAmount 함수는 개수를 입력받아서 해당 아이템의 개수가 2개 이상일 때 개수를 표시해준다. SetSelectSlot은 마우스로 아이템 슬롯을 클릭했을때 해당 아이템 슬롯이 선택되었다는 것을 표시해주는 이미지를 출력해준다. 해당 클래스의 Awake에서 UI들을 바인딩해주고 버튼이 클릭되었을때 콜백함수를 받는 AddListener 함수를 호출한다. 그리고 InventroyUI 스크립트에서 게임이 시작될 때 ItemSlot prefab으로 슬롯 오브젝트를 생성하고 플레이어의 인벤토리 정보를 기반으로 아이템을 갖고있을때 플레이어가 갖고 있는 만큼 슬롯을 그린다.

InventorySlot을 저장하는 List로 슬롯을 만들때 List에 저장하고 InventoryItemData를 저장하는 List를 선언해서 플레이어가 가진 아이템들의 정보를 리스트에 저장한다. InitSlot에서 InventorySlot 오브젝트를 생성해서 index를 붙여서 List에 저장한다. 그리고 RefreshInventoryUI 함수에서 플레이어가 가진 아이템 리스트를 전달 받아서 아이템이 있으면 슬롯을 DrawItmeSlot을 이용해서 그리고 없다면 ClearSlot으로 빈 슬롯을 저장한다. 그리고 UIManager에서 슬롯이 열렸는지 여부를 저장하는 변수를 선언하고 만약 이 값이 true라면 inventory 오브젝트를 열고 RefreshInventUI 함수를 호출해서 인벤토리의 슬롯을 그린다.
public void ShowInventory()
{
isOpenInventory = !isOpenInventory;
if (isOpenInventory)
{
inventory.LeanScale(Vector3.one, 0.7f).setEase(LeanTweenType.easeInOutElastic);
inventoryUI.RefreshInventoryUI();
}
else
{
inventory.LeanScale(Vector3.zero, 0.7f).setEase(LeanTweenType.easeInOutElastic);
}
}

위 사진은 인벤토리를 열어서 아이템이 있는 곳을 클릭했을때의 모습이다. 그리고 그 다음은 땅의 떨어진 아이템을 구현한다.

아이템은 생성될때 AddForce로 인해 오브젝트의 위쪽 방향(transform.up)으로 한 번 튀어올랐다가 내려오고 그 이후에는 위 아래로 왔다갔다하면서 회전한다. DropItem이라는 빈 오브젝트에 회전을 위한 transform 변경을 위한 RotTrans라는 오브젝트를 넣어준다 그리고 그 오브젝트에 아이템의 prefab을 넣는다.

위 스크립트는 DropItem 스크립트이다. RequireComponent로 SphereCollider와 Rigidbody를 지정해주고 Awake에서 세부 설정들을 초기화한다. 그리고 Awake에서 Rigidbody.AddForce(Vector3.up * 5.0f, ForceModeImpulse)를 통해 생성이 되었을때 위로 한번 튀도록 한다. 그리고 transform.child(0)을 통해서 회전을 다룰 오브젝트 RorTrans를 찾는다. 그리고 이 DropItem 오브젝트는 충돌 체크를 두가지와 해야하는데 하나는 바닥(Plane)에 부딪혔을 때와 하나는 캐릭터와 부딪혔을 때이다. 바닥에 부딪혔을 때는 내려오다가 y축 위치를 고정하고 회전과 위아래 움직임을 시작한다. 그리고 캐릭터와 부딪혔을 때는 캐릭터에게 습득되도록 처리한다. 그레서 Update 함수에서 위로 튀어올랐다가 내려오면서 바닥과 부딪혔때 회전과 Math.Sin 함수를 통해서 위아래로 반복적으로 움직이는 것을 구현했다. 그리고 OnTriggerEnter에서는 각각 Ground 태그의 오브젝트와 Player 태그의 오브젝트에 부딪혔을때를 다룬다. 먼저 Ground 태그의 오브젝트와 부딪혔을 때(바닥에 떨어졌을때)는 오브젝트의 Rigidbody의 gravity를 끄고 바로 멈추도록 velocity도 0으로 설정한다. 그리고 isDrop을 true로 바꾼다. 그리고 땅에 떨어져서 isDrop이 true이고 부딪힌 오브젝트의 Tag가 Player일때(플레이어와 부딪혔을때) LootingItem 함수를 호출해서 습득처리를한다.
그 다음은 TitleScene을 구현한다. TitleScene에서 Player의 데이터를 생성한다.


위 사진들은 TitleScene의 UI 구성이다. 플레이어의 정보가 없으면 첫번째 사진처럼 아이디를 입력할 수 있는 팝업 창을 띄워서 아이디를 입력하면 새로운 플레이어의 정보를 저장하고 플레이어의 정보가 저장되어서 데이터를 불러올 수 있으면 아래와 같은 화면이 나오고 화면을 클릭하면 다음 Scene으로 넘어가도록 한다. TitleScene을 꽉 채워서 버튼(EnterButton)을 하나 만들어서 투명하게 만들면 화면을 아무 곳이나 클릭했을때 버튼이 눌린것으로 판정되어서 다음 Scene으로 넘어가게된다. 그리고 이 버튼이 제일 처음으로 그려지고 다음으로 계정 데이터를 삭제하는 DeleteButton 버튼이 그려져야 한다. 반대라면 DeleteButton을 누르려고 해도 EnterButton이 더 위에 그려져서 DeleteButton을 누르지 못한다.

위 스크립트는 TitleScene 스크립트이다. InitTitleScene 함수에서 로드할 플레이어 데이터가 없다면 hasPlayerData를 false로 저장하고 데이터가 있다면 true로 바꾸고 화면 아래에 뜨는 text에 플레이어의 ID와 환영한다는 문구를 출력해준다. 그리고 화면을 다 덮는 EnterButton에 대해서 hasUserInfo가 true이면 화면이 넘어가는 AsyncLoadNextScene 함수를 호출한다. 이 함수를 정의하기 위해 먼저 GameManager 스크립트를 약간 수정한다. Scene이 넘어가는 것을 구현하기 위해 GameManager 스크립트에서 몇몇 설정을 해준다. 먼저 열거형(enum)으로 Scene의 이름을 SceneName으로 정의한다. 열거형으로 데이터들을 정의하는 것을 게임에서 자주 쓰이는데 직관적으로 뭘 가리키는지 잘 알 수 있기 때문이다.
public enum SceneName
{
IntroScene,
LoadScene,
BaseScene,
BattleScene,
BossScene,
TitleScene,
}
그리고 AsyncLoadNextScene 함수를 선언해서 다음 씬으로 넘어간다. AsyncLoadNextScene 함수는 TitleScene에서 넘어갈 다음 Scene의 이름을 받아서 저장하고 LoadScene 함수로 LoadScene으로 넘어간다.
public void AsyncLoadNextScene(SceneName nextScene)
{
nextSceneName = nextScene;
SceneManager.LoadScene(SceneName.LoadScene.ToString());
}
그래서 다음 씬으로 넘어가는 AsyncLoadNextScene 함수가 정의되었다. 이 함수를 TitleManager에서 호출하면 된다. 그리고 hasPlayerInfo가 false라면 아이디를 입력해서 데이터를 생성하는 팝업창을 띄워준다. 팝업창의 InputField에 아이디를 입력하고 ApplyButton을 누른다면 CreatePlayerData를 통해서 데이터를 생성하고 SaveData로 저장한다. 그리고 다시 InitTitleScene을 호출해서 TitleScene을 갱신한다. 그리고 GameManager에서 Singleton의 virtual 함수 DoWake를 오버로드해서 여기서 데이터가 저장될 경로 dataPath를 정의한다. 현재 dataPath로 사용되는 것은 Application.persistentDataPath로 유니티에서 지원하는 것으로 Application에 할당된 저장공간을 나타낸다.
그리고 다음 씬으로 넘어갈때 나오는 로딩창인 LoadScene을 구현한다.

위 사진이 기초적인 LoadScene의 UI이다. 3D 프로젝트에서 UI로 투명한 이미지를 쓴다면 뒤에 유니티 기본 배경이 비칠 수 있는데 MainCamera의 Inspector 창에서 ClearFlags를 SolidColor로 지정하면 유니티의 기본 배경이 아닌 단일 색깔이 배경으로 나오게 지정할 수 있다.

위 스크립트는 LoadScene의 스크립트인데 여기서 TitleScene에서 받아온 SceneManager로 넘어가는데 여기서 다음으로 넘어가는 SceneName에 맞는 TipMessage를 출력하도록 구현한다.
for (int i = 0; i < dataTable.TipMess.Count; i++)
{
if (dataTable.TipMess[i].sceneName == SceneName.BaseScene.ToString())
{
baseSceneTip.Add(dataTable.TipMess[i]);
}
if (dataTable.TipMess[i].sceneName == SceneName.BattleScene.ToString())
{
battleSceneTip.Add(dataTable.TipMess[i]);
}
if (dataTable.TipMess[i].sceneName == SceneName.BossScene.ToString())
{
bossSceneTip.Add(dataTable.TipMess[i]);
}
}
DataManager의 InitManager에서 데이터 테이블에 저장된 TipMessage과 대응하는 SceneName과 같은 TipMessage를 각각의 List에 저장한다. battleSceneTip, baseSceneTip, bossSceneTip으로 List 세개를 만든다.
private List<Tip_Entity> battleSceneTip = new List<Tip_Entity>();
private List<Tip_Entity> baseSceneTip = new List<Tip_Entity>();
private List<Tip_Entity> bossSceneTip = new List<Tip_Entity>();
그래서 Scene이 넘어갈 때 전달받은 SceneName으로 해당 씬에 맞는 Tip Message를 출력한다.
public string GetTipMessage(SceneName sceneName)
{
string message = "";
switch(sceneName)
{
case SceneName.BaseScene:
randValue = Random.Range(0, baseSceneTip.Count);
message = baseSceneTip[randValue].tipText;
break;
case SceneName.BattleScene:
randValue = Random.Range(0, battleSceneTip.Count);
message = battleSceneTip[randValue].tipText;
break;
case SceneName.BossScene:
randValue = Random.Range(0, bossSceneTip.Count);
message = bossSceneTip[randValue].tipText;
break;
default:
message = "과도한 게임은 일상생활에 지장을 초래할 수 있습니다";
break;
}
return message;
}
그래서 TitleScene에서 BaseScene으로 넘어갈때 다음과 같이 TipMessage가 나오면서 화면이 남어간다.

그리고 이제 NPC를 구현한다. NPC는 잡화 상점 NPC와 인챈트 NPC가 존재한다. NPC들에 다가가면 자동으로 잡화 상점이나 인챈트 팝업 창을 띄워준다. NPC에 대한 기본 설정은 NPCBase 스크립트로 구현한다.

RequireComponent로 SphereCollider와 Rigidbody Component를 지정해주고 Awake에서 각 Component를 설정해준다. Rigidbody.isKinematic을 true로 설정해서 외부의 힘에 영향을 받지 않는 오브젝트로 만든다. 그리고 OnTriggerEnter 함수로 Player와 닿으면 팝업 창을 띄워주고 OnTriggerExit 함수로 Player와 떨어지면 팝업 창을 닫는다. 인터페이스로 팝업을 열고 닫는 것을 함수를 선언한다.
public interface IPopup
{
public void OpenPopup();
public void ClosePopup();
}


첫 번째 사진은 잡화상점 팝업의 기본 UI이고 두 번째 사진은 잡화 상점에 들어갈 아이템 슬롯 UI이다. 잡화 상점 팝업의 Scroll View에 아이템 슬롯들이 수평적으로 나열된다. 왼쪽에는 플레이어가 갖고있는 금액과 현재 거래할 금액이 나와있고 판매, 구매 버튼을 눌러서 판매 창과 구매 창으로 넘어갈수 있다. 결정 버튼을 통해서 구매를 확정한다. 그리고 아이템 슬롯에서는 아이템 이미지가 나오고 이름과 가격 그리고 구매 또는 판매할 개수를 정할 수 있다. 먼저 잡화 상점의 구현을 위해 스크립트를 만든다. 이 스크립트는 팝업을 열고 닫는 기능, 목록 아이템의 정보를 가져오는 기능, 구매를 결정하는 기능이 있다. 그리고 UI를 설정해준다.
ItemShopPopup 스크립트의 InitPopup 함수에서는 인벤토리의 아이템 데이터를 가져와 판매 탭에서 보여줄 아이템 슬롯을 만든다. 최대 인벤토리의 개수만큼 만들고 플레이어가 갖고있는 아이템의 개수만큼 Active해서 보여준다. 그리고 판매 탭에서 보여줄 판매 아이템에 대해 슬롯을 만든다. 그리고 각각의 아이템 슬롯 리스트에 넣는다. 그리고 판매 탭 버튼, 구매 탭 버튼, 결정 버튼에 대해서 콜백 함수를 Listen하는 함수를 호출한다.
private void InitPopup()
{
inventory = GameManager.Inst.Inven;
for (int i = 0; i < inventory.MaxCount; i++)
{
if (Instantiate(slotPrefab, sellViewContent).
TryGetComponent<ItemShopSlot>(out itemShopSlot))
{
itemShopSlot.gameObject.name = $"SellSlot_{i}";
sellSlotList.Add(itemShopSlot);
}
}
for (int i = 0; i < 4; i++)
{
if (Instantiate(slotPrefab, buyViewContent).
TryGetComponent<ItemShopSlot>(out itemShopSlot))
{
itemShopSlot.gameObject.name = $"BuySlot_{i}";
buySlotList.Add(itemShopSlot);
}
}
sellTabButton.onClick.AddListener(OnClick_SellTap);
buyTabButton.onClick.AddListener(OnClick_BuyTap);
applyButton.onClick.AddListener(OnClick_Apply);
}
지금은 18개를 미리 만들고 Active를 하는 방식으로 슬롯을 구현했지만 최종적으로는 보여지는 만큼만 슬롯을 4, 5개 정도만 만들고 화면에서 슬롯이 벗어나면 새로운 아이템 데이터로 갱신해서 화면에 다시 보여지는 방식으로 구현할 수 있다.

그리고 결정 버튼을 눌렀을 때 만약에 판매 탭이 Active 되어있으면 판매 처리로 플레이어의 골드를 올려주고 아이템을 인벤토리에서 제거한다. 그리고 구매 탭이 Active 되어있으면 구매처리로 골드를 가져가고 인벤토리에 아이템을 추가한다. 구매할 아이템의 개수나 판매할 개수는 ItemSlot으로부터 받아온다.
public void OnClick_Apply()
{
if (sellView.activeSelf)
{
for (int i = inventory.CurItemCount - 1; i >= 0; i--)
{
GameManager.Inst.PlayerGold += tradeGold;
InventoryItemData itemData = new InventoryItemData();
itemData.itemID = itemID;
itemData.amount = tradeCount;
inventory.RemoveItem(itemData);
}
OnClick_SellTap();
}
else
{
totalGold = 0;
for (int i = 0; i < 4; i++)
{
totalGold += tradeGold;
}
if (totalGold <= GameManager.Inst.PlayerGold)
{
GameManager.Inst.PlayerGold -= totalGold;
for (int i = 0; i < 4; i++)
{
if (tradeCount > 0)
{
InventoryItemData itemData = new InventoryItemData();
itemData.itemID = itemID;
itemData.amount = tradeCount;
inventory.AddItem(itemData);
}
}
}
OnClick_BuyTap();
}
}
여기서 판매를 할때 인벤토리를 거꾸로 for문을 돌리면서 뒤에서부터 처리를 하는데 그 이유는 List로 구현된 InventoryData에서 순서대로 처리를 하는데 앞에서 삭제가 되면 앞으로 index가 하나씩 밀려서 아이템 하나를 건너뛰게 될 수 있다. 그러나 뒤에서부터하면 앞으로 index가 밀리더라도 이미 확인한 아이템부터 시작되어서 아이템을 건너뛸 위험이 없다.
RefreshGold 함수로 화면상의 골드를 갱신한다. 그리고 골드의 총합을 계산하는 함수(CaculateGold)나 판매 탭이나 구매 탭의 아이템 슬롯을 갱신하는 함수들은 아이템 슬롯에서 정보를 받아와야하므로 나중에 추가적으로 구현한다.
그리고 이제 아이템 슬롯을 제어하기 위한 ItemShopSlot 스크립트를 만든다. ItemShopSlot에서는 아이템의 갯수와 구매 또는 판매할 개수를 정해서 부모(ItemShopPopup)에 정보를 전달한다. 슬롯에서 구매, 판매의 총 가격을 계산해서 부모에 전달한다. 그래서 델리게이트를 사용해서 totalGold가 변화할 때마다 부모에 이벤트를 전달한다.
public delegate void TotalGoldChange();
public event TotalGoldChange OnTotalChange;
private int totalGold;
public int TotalGold
{
get => totalGold;
set
{
totalGold = value;
if (OnTotalChange != null)
{
OnTotalChange.Invoke();
}
}
}
그리고 CreateSlot 함수에서 슬롯을 만든다. 그리고 일단 Active를 false를 설정한다. 그리고 RefreshSlot 함수에서는 데이트를 받아 슬롯의 데이터를 갱신하고 데이터가 있는 슬롯은 Active를 true로 바꾼다.
public void RefreshSlot(InventoryItemData data)
{
gameObject.SetActive(true);
itemID = data.itemID;
tradeMaxCount = data.amount;
tradeCurCount = 0;
TotalGold = 0; // call delegate
if (DataManager.Inst.GetItemData(itemID,out ItemData_Entity itemInfo))
{
icon.sprite = Resources.Load<Sprite>(itemInfo.iconImg);
itemPriceText.text = itemInfo.sellGold.ToString();
tradeGold = itemInfo.sellGold;
tradeCountText.text = "0";
tradeCurCount = 0;
}
else
{
Debug.Log("ItemShopSlot.cs - RefreshSlot() - 테이블 정보 참조 실패");
}
}
ClearSlot에서는 슬롯을 쓰지 않는 슬롯을 disable로 만들고 GetSellInfo와 GetBuyInfo를 통해 해당 슬롯의 아이템 정보와 골드, 개수를 넘긴다.
public bool GetSellInfo(out int _sellItemID, out int _sellCount, out int _sellGold)
{
_sellItemID = itemID;
_sellCount = tradeCurCount;
_sellGold = totalGold;
return true;
}
public bool GetBuyInfo(out int _buyItemID, out int _buyCount, out int _buyGold)
{
_buyItemID = itemID;
_buyCount = tradeCurCount;
_buyGold = totalGold;
return true;
}
그리고 개수는 버튼을 통해서 변경하도록 한다.
