0. 前言
这篇文章参考了麦扣老师@M_Studio的教程Unity教程:背包系统01:概览简介_哔哩哔哩_bilibili
我做了许多修改和调整。项目的完整代码我放在了这里LLLLLevi/2DPlatform: 2D平台横版战斗 (github.com)
希望可以帮到你😉
1. GUI 图形界面设置
本节知识点:
- Grid Layout Group组件:该组件用于对子对象进行网格状的排列。它可以将子对象按照指定的行数、列数以及间距进行自动布局。
我想做的是类似泰拉瑞亚的库存系统,有始终在场景中的物品栏(ToolBar),可按快捷键12345使用物品;也有需按键调出的主背包(MainInventory)。
UI层级结构如下:
SlotBackground:空物品槽的父类,物品槽在其下面生成。给他添加Grid Layout Group组件
效果如下:
主仓库要按Tab键才能唤出,代码逻辑如下(放在人物控制脚本内):
bool isOpen = false;
[SerializeField] GameObject bag;
public void OpenBag(InputAction.CallbackContext context)
{
if (context.started)
{
isOpen = !isOpen; //赋反值
bag.SetActive(isOpen);
}
}
用的是新输入系统,设置好按键后将按键绑定在Player输入系统上。
2. ScriptableObject保存物品属性
ScriptableObject是Unity中的一个特殊类,用于创建可序列化的自定义数据对象。它主要用于存储和管理在游戏中需要持久化的数据,例如游戏设置、角色属性、关卡信息等。
与其他Unity组件不同,ScriptableObject不依赖于游戏对象,因此它可以独立存在。这意味着它可以在脚本之间共享和传递,并且可以在不同场景中重复使用。
创建一个ScriptableObject对象非常简单。只需创建一个继承自ScriptableObject的脚本类,并在编辑器中创建一个新的ScriptableObject实例。
使用ScriptableObject的好处包括:
- 可以在编辑器中创建和编辑自定义数据对象,避免了手动编写和解析数据的麻烦。
- ScriptableObject的数据是可序列化的,因此可以在不同平台和场景间进行持久化保存和加载。
- 可以通过引用来共享和传递ScriptableObject对象,方便数据的复用和管理。
所谓背包系统,当然得有背包类和物品类,我们使这两个类继承自ScriptableObject,从而实现可在Unity中创建多种物品、多种仓库(如主背包、物品栏、商店)的目的。
- 物品类中定义物品的普遍拥有的属性
- 仓库类定义物品的列表
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
[CreateAssetMenu(fileName = "New Item", menuName = "Inventory/New Item")]
public class Item : ScriptableObject
{
public string itemName;
public Sprite itemImage;
public int itemHeldNum; //持有的物品数
[TextArea] //使得输入为多行文本框而不是单行
public string itemInfo; //物品介绍
public ItemType type;
}
//物品种类枚举,用于判断物品特性
public enum ItemType{
key,
food,
equipment
}
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
[CreateAssetMenu(fileName = "New Inventory", menuName = "Inventory/New Inventory")]
public class Inventory : ScriptableObject
{
public List<Item> itemList = new List<Item>();
}
在Unity右键菜单中的右键菜单中创建物品鸡腿、主仓库和物品栏:
OK,接下来就是写脚本使得当玩家碰撞到世界中的物品时,将此物品添加进主背包:
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class ItemInWorld : MonoBehaviour
{
//物品数据和要保存到的物品仓库
public Item item;
public Inventory mainInventory;
public void AddNewItem()
{
//不存在就找到最前面的一个空位然后填入物品到仓库,存在就物品数量加1
if (!mainInventory.itemList.Contains(item))
{
for (int i = 0; i < mainInventory.itemList.Count; i++)
{
if (mainInventory.itemList[i] == null)
{
mainInventory.itemList[i] = item;
break;
}
}
}
else
{
item.itemHeldNum++;
}
InventoryManager.RefreshItem(); //刷新UI的物品数
}
private void OnTriggerEnter2D(Collider2D collision)
{
//碰到玩家则添加物品
if (collision.gameObject.CompareTag("Player"))
{
AddNewItem();
Destroy(gameObject);
}
}
}
在场景中添加鸡腿(不是数据文件),将上面这个脚本挂载到鸡腿物品身上,同时添加触发器:
将MainInventory的列表大小改成你背包格子的数量:
当吃到鸡腿后,主背包的列表第一个值改变,且结束游戏后数据依旧存在。当再次运行游戏再吃鸡腿,持有的鸡腿数变为2:
3. 将物品显示在背包中
声明slot类,用于定义物品槽的属性值,如物品的属性值、物品图片、物品持有数。
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.UI;
public class Slot : MonoBehaviour
{
public int slotID; //格子的编号
public Item slotItem; //物品的属性
public Image slotImage;
public Text slotNum; //持有数
public GameObject Description;
public Text DescriptionText;
public GameObject itemInSlot;
//生成格子的数据
public void SetupSlot(Item item)
{
if(item == null)
{
itemInSlot.SetActive(false); //如果当前格子没物品数据,那就不显示该物品(不然就会显示一张白图)
return;
}
//物品数据显示
slotImage.sprite = item.itemImage;
slotNum.text = item.itemHeldNum.ToString();
DescriptionText.text = item.itemInfo;
}
}
ItemOnPointer用于当鼠标悬停时,显示物品的描述信息,挂载到Item上。
继承IPointerEnterHandler和IPointerExitHandler,用于实现鼠标悬停在格子上时显示物品的描述信息。
实现鼠标悬停的方法也蛮多的,可以看这个UGUI鼠标悬停事件-CSDN博客)的回答。
using UnityEngine.EventSystems;
using UnityEngine.UI;
public class ItemOnPointer : MonoBehaviour, IPointerEnterHandler, IPointerMoveHandler, IPointerExitHandler
{
public GameObject Description;
public Text DescriptionText;
public Vector2 vec = new Vector2(200, -130); //与鼠标的偏移值
public void OnPointerEnter(PointerEventData eventData)
{
Description.SetActive(true);
}
public void OnPointerMove(PointerEventData eventData)
{
Description.transform.position = eventData.position + vec;//物品描述框跟随鼠标
}
public void OnPointerExit(PointerEventData eventData)
{
Description.SetActive(false);
}
}
创建InventorySlot预制体,结构如下,用于生成出现在UI上,将Slot脚本挂载到声明好的InventorySlot预制体上:
其中Description要有Canvas组件:
声明InventoryManager类,用于管理库存系统的数据
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
public class InventoryManager : MonoBehaviour
{
//用单例模式 方便管理
static InventoryManager instance;
public Inventory mainInventory;
public GameObject slotParent; //格子的爹
public GameObject emptySlot; //空格子
public List<GameObject> slotsList = new List<GameObject>(); //用来存放空格子的列表
void Awake()
{
if(instance != null)
{
Destroy(this);
}
instance = this;
}
private void OnEnable()
{
RefreshItem(); //游戏开始时刷新背包
}
//刷新当前背包的物品数:先删后创等于刷新
public static void RefreshItem()
{
//根据子类的数量遍历删除
for(int i = 0; i < instance.slotParent.transform.childCount; i++)
{
if (instance.slotParent.transform.childCount == 0)
break;
Destroy(instance.slotParent.transform.GetChild(i).gameObject);
instance.slotsList.Clear(); //清空列表
}
//重新创建物品
for(int i = 0; i < instance.mainInventory.itemList.Count; i++)
{
//生成空格子且添加到空格子列表中
GameObject newSlot = Instantiate(instance.emptySlot);
//newSlot.transform.localScale = new Vector2(2, 2);
instance.slotsList.Add(newSlot);
instance.slotsList[i].transform.SetParent(instance.slotParent.transform); //静态方法的变量一定是静态值
instance.slotsList[i].GetComponent<Slot>().slotID = i; //赋予编号
instance.slotsList[i].GetComponent<Slot>().SetupSlot(instance.mainInventory.itemList[i]); //赋值生成物品的数据
}
}
}
将InventoryManager挂载到Canvas上:
效果如下:
4. 物品的拖拽与交换
原理讲解:
实现更改物品在背包中的位置和交换物品
给Item添加组件Canvas Group和Layout Element,我们逐一解释为什么添加:
我们拖拽的原理是从鼠标位置向屏幕内方向发射射线去识别物品。当我们拖拽物品的时候,物品会跟着鼠标移动嘛,所以就会使得鼠标发出的射线无法透过当前拖拽的物品,从而识别不到我们该放到哪个物品槽中。
怎么办呢?就是用这个Canvas Group的Blocks Raycasts功能,它可以暂时屏蔽挂载了这个组件的物品,使得射线可以探测它下面的UI。
在层级关系中我们的Item是位于InventorySlot的子层级下面的,所以说我们想要实现更改物品UI的位置就得和原来的Slot脱离父子关系,再去到其他的Slot成为它的儿子,从而实现换位置。那么别忘了我们SlotBackground是有Grid Layout Group的,也就是说当和原来的父级脱离关系时,有一瞬间这个Item会顺位的跑到SlotBackground下成为最后一个元素,就会有图片在背包左下角闪一下的问题。
为了解决这个小bug,我们就掏出了Layout Element中的ignore Layout去忽略布局,就这么简单。如果没听懂的话可以去看麦扣的视频:Unity教程:背包系统07:优化代码&解决bugs(也有小技巧哦)_哔哩哔哩_bilibili
下面我们就来实现它:
实现:
ItemOnDrag实现物品拖拽和交换,与显示物品的描述信息原理相似,继承IBeginDragHandler, IDragHandler, IEndDragHandler。将它挂载到Item上。
这部分代码可能会比较绕,但其实没多大事情,就是更新UI位置和mainInventory中itemList的元素位置,使其同步
using UnityEngine.EventSystems;
//挂载到物品身上,实现拖拽效果
public class ItemOnDrag : MonoBehaviour, IBeginDragHandler, IDragHandler, IEndDragHandler
{
public Transform originalParent; //记录起始的父级位置
public Inventory mainInventory;
private int curItemID;
public void OnBeginDrag(PointerEventData eventData)
{
originalParent = transform.parent;
curItemID = originalParent.GetComponent<Slot>().slotID;
transform.SetParent(transform.parent.parent); //更改层级关系为他爹的兄弟,防止渲染被挡住
transform.position = eventData.position; //位置随鼠标移动
//拖拽时控制的物品会挡住射线的穿透,而我们要根据射线碰撞到的物品进行判断,所以要关掉
GetComponent<CanvasGroup>().blocksRaycasts = false;
}
public void OnDrag(PointerEventData eventData)
{
transform.position = eventData.position;
Debug.Log(eventData.pointerCurrentRaycast.gameObject.name); //显示鼠标射线碰撞到的物体名称
}
public void OnEndDrag(PointerEventData eventData)
{
GameObject itemOnRaycast = eventData.pointerCurrentRaycast.gameObject;
Transform itemTransOnRaycast = eventData.pointerCurrentRaycast.gameObject.transform;
//当拖拽终点格子有物品时,交换两个物品的位置
if(itemOnRaycast != null)
{
if (itemOnRaycast.CompareTag("Item"))
{
//改变父级和位置
transform.SetParent(itemTransOnRaycast.parent); //itemTransOnRaycast.parent就是格子
transform.position = itemTransOnRaycast.parent.position;
//刷新itemList的物品存储位置
var temp = mainInventory.itemList[curItemID];
mainInventory.itemList[curItemID] = mainInventory.itemList[itemOnRaycast.GetComponentInParent<Slot>().slotID];
mainInventory.itemList[itemOnRaycast.GetComponentInParent<Slot>().slotID] = temp;
itemTransOnRaycast.position = originalParent.position;
itemTransOnRaycast.SetParent(originalParent);
GetComponent<CanvasGroup>().blocksRaycasts = true;
return;
}
//没有物品时射线射到的是个格子,直接赋值给这个格子
else if (itemOnRaycast.CompareTag("Slot"))
{
transform.SetParent(itemTransOnRaycast);
transform.position = itemTransOnRaycast.position;
//空格子的位置也得换
itemTransOnRaycast.Find("Item").position = originalParent.position;
itemTransOnRaycast.Find("Item").SetParent(originalParent);
mainInventory.itemList[itemOnRaycast.GetComponent<Slot>().slotID] = mainInventory.itemList[curItemID];
//变位置了的话就将原来的改为空
if (itemOnRaycast.GetComponent<Slot>().slotID != curItemID)
mainInventory.itemList[curItemID] = null;
GetComponent<CanvasGroup>().blocksRaycasts = true;
return;
}
}
//射到其他东西时回到原位置
transform.SetParent(originalParent);
transform.position = originalParent.position;
GetComponent<CanvasGroup>().blocksRaycasts = true;
}
}
效果如下: