[Unity背包系统]1.脚本化对象与GUI显示
提要
- 背包系统是游戏中非常重要的一个功能,我们需要实现一个涵盖多种类型物品(比如玩家装备,BUFF物品等)的背包系统。 本节需要做的就是先将所谓的背包(Inventory)实例化出来,并且使用GUI显示背包的物品情况
- 在此之前,我们需要学会使用Unity的脚本化对象-Scriptable Object
Scriptable Object
什么是Scriptable Object
- 首先看看官方文档中的描述:
一个类,如果需要创建无需附加到游戏对象的对象时,可从该类派生。
它对仅用于存储数据的资源最有用。
- 简单来说,我们平时写的游戏脚本,都会挂载到相应的游戏对象中,比如PlayerController之类的,而ScriptableObject就是不用挂载到游戏对象的脚本,这个类的实例是作为资源文件(.asset)存在的,当我们在背包中存放物品时,这些物品就是这样的资源文件,也就是实例化的ScriptableObject
初探-ItemObject
-
下面我们直接来上手使用,首先创建一个脚本,命名为ItemObject,我们希望用它来创建各种各样不同类型的物品资源文件,注意,这里需要更改ItemObject继承的类,它不再是MonoBehaviour,而是Scriptable Object
-
这里需要思考一下,加入背包里的物品可能有这么几种类型:默认,食物,装备,所以我们可能需要区分一下,让ItemObject作为一个父类,把它们的共同点都写在父类,然后分别在写三个子类继承,为什么要这样做呢?仔细想想,食物可能有恢复的血量是多少,装备可能有防御值,速度等不同的属性,所以这里我们增加一个枚举类,表示这个物品的类型
public enum ItemType
{
Food,
Equipment,
Default,
}
public class ItemObject : ScriptableObject
{
public GameObject prefab;//这里是物品的实际对象,比如缩略图,模型等
public ItemType type;//物品的类型
[TextArea(15,20)]//让物品描述的文本框变大,可以写更多描述
public string description;
}
- 接下来,分别创建DefaultObject,FoodObject,EquipmentObject,这里可以根据不同物品自由增加属性,比如装备的防御力等,我在这就直接省略了,以后有需要再写:
public class EquipmentObject : ItemObject
{
private void Awake()
{
type = ItemType.Equipment;// 赋予对应类型
}
}
- 编写好后,重要的一步来了,要开始创建对应的资源对象了,也就是实例化这些ScriptableObject,在此之前,我们需要在刚刚的那三个Object脚本上增添这么一行
[CreateAssetMenu(fileName = "new Equipment Object", menuName = "Inventory System/Items/Equipment")]
public class EquipmentObject : ItemObject
{
private void Awake()
{
type = ItemType.Equipment;
}
}
-
这行代码的意思就是,让这个ScriptableObject添加到创建资源(右键菜单)的面板上,其中filename就是新创建出一个资源时默认显示的名字,menuName指定了一个路径,我们直接去试试就知道了:
-
显而易见,现在我们可以直接创建这些ScriptableObject的资源了,我们尝试创建一个装备(Equipment),命名为盔甲-armour
-
成功了,可以在Inspector里面更改我们刚刚设定的一些属性,如果你加入了自己的一些想法比如盔甲的防御力,也可以在这里更改
-
下面可以为每种类型的ItemObject都创建一些对应的物品资源玩玩(这里我是将刚刚的ScriptableObject脚本都放入了Scripts文件夹,这里就不强调分类存放的了,相信各位都有自己的想法 ):
存储ScriptableObject-InventoryObject
- 我们已经初步使用了ScriptableObject,并且创建了几个可以存放在背包里的物品资源,现在来具体制作一个背包,当然,其实也可以不用ScriptableObject,有许多方法实现。这里也是为了更熟悉,并且之后会制作存档背包功能,我们再次创建一个继承了ScriptableObject的脚本对象:InventoryObject,用来表示背包:
[CreateAssetMenu(fileName = "new Inventory",menuName = "Inventory System/Inventory/BackPack")]
public class InventoryObject : ScriptableObject
{
}
- 下面来思考如何表示背包中存放的物品:可以用一个List来存放,但同时也要注意,我们需要记录的不只是物品,通常在游戏中的背包,还可以看到每个物品对应的数量,这里可以换成Dictionary来存放,但是要知道,Dictionary在Unity中是不会被Unity自动序列化的,需要写一些额外的代码(接下来会用到的但可能不是现在),这里就编写一个类:InventorySlot,来记录这些相关的信息,每个物品资源都会对应一个这样的InventorySlot,其实按照字面意思就是背包中的一个物品槽:
[System.Serializable]//序列化
public class InventorySlot
{
public ItemObject item;
public int amount;
public InventorySlot(ItemObject item,int amount)
{
this.item = item;
this.amount = amount;
}//构造函数
public void AddAmount(int value)
{
amount += value;
}//增加这个物品槽物品的数量
}
- 现在可以使用List来存储了:
public List<InventorySlot> BackPack;
- 现在来考虑如何向这个背包增加指定数量的物品,第一肯定能想到的是BackPack是个List,每次只要BackPack.Add(new Inventory(item,amount))就行了,但假若有重复的物品(比如背包里已经有fish了,探索游戏的过程中有拾取到了fish),我们应该先看List里是否已经有这个物品对应的InventorySlot,如果有直接调用刚刚的AddAmount()即可,在这里就直接遍历一遍了:
(其实你可以发现AddAmount()这个方法不是必要的,这里就相当于规范一下 了)
public void AddItem(ItemObject item,int amount)
{
for(int i = 0 ; i < BackPack.Count; i ++ )
{
if(BackPack[i].item == item)
{
BackPack[i].AddAmount(amount);
return;
}
}//遍历,如果发现已经有这个物品了,就直接加上数量
BackPack.Add(new InventorySlot(item,amount));//如果没有,则调用List的Add()方法
}
- 下面是完整代码,这样我们的InventoryObject就算是写好了:
[CreateAssetMenu(fileName = "new Inventory",menuName = "Inventory System/Inventory/BackPack")]
public class InventoryObject : ScriptableObject
{
public List<InventorySlot> BackPack;
public void AddItem(ItemObject item,int amount)
{
for(int i = 0 ; i < BackPack.Count; i ++ )
{
if(BackPack[i].item == item)
{
BackPack[i].AddAmount(amount);
return;
}
}
BackPack.Add(new InventorySlot(item,amount));
}
}
[System.Serializable]
public class InventorySlot
{
public ItemObject item;
public int amount;
public InventorySlot(ItemObject item,int amount)
{
this.item = item;
this.amount = amount;
}
public void AddAmount(int value)
{
amount += value;
}
}
-
然后来创建玩家使用的背包资源,也是一样的方式,取名为PlayerInventory:
-
可以看到序列化后的BackPack,还可以直接从这个界面对BackPack进行一些增删改操作
拾取物品
- 我们已经创建好了物品,背包的资源,下面就要进行一些实际的操作了,首先在Scene里创建好玩家对象(可以直接用Cube或者你喜欢的模型代替),再创建好两个“拾取物”,接下来要给它们编写一些脚本:
- 我们先编写对玩家拾取物品的脚本:PlayerInventoryController(自己取名习惯)
public class PlayerInventoryController : MonoBehaviour
{
public InventoryObject inventory;//引入玩家的背包资源
public void PickUp(ItemObject item,int amount)
{
inventory.AddItem(item, amount);
}//拾取到物品的方法,加入到玩家的背包里面
}
-
将这个脚本挂载到Player游戏对象上,并绑定之前创建的PlayerInventory背包资源
-
接下来编写对拾取物的控制脚本,我们想让Player对象接触到拾取物时,触发PickUp方法,参数即是拾取物所代表的item,因此先将拾取物(这里我用的是Cube)的碰撞器设定为触发器:
-
然后编写脚本ItemController:
public class ItemController : MonoBehaviour
{
public ItemObject item;
private void OnTriggerEnter(Collider other)
{
if(other.gameObject.name == "Player")//也可以用Tag等,这里随便判断了,其实在这个demo里甚至不需要判断
{
other.gameObject.GetComponent<PlayerInventoryController>().PickUp(item, 1);
Destroy(this.gameObject);
}
}
}
-
挂载到拾取物上后,给它们不同的item:
-
测试后,让Player对象接触两个拾取物(没有写控制的方法的话可以在Scene中直接拖拽),可以看到拾取物消失,运行时查看PlayerInventory,可以看到BackPack中增加了对应的item。
-
不过也发现了一个问题:当退出游戏再进入时,BackPack没有清空,还保留着之前的数据,这是因为ScriptableObject的持久化能力,不过这里我们不需要,可以在退出游戏时清空BackPack,在PlayerInventoryController中增加这个方法:
private void OnApplicationQuit()
{
inventory.BackPack.Clear();
}
UI显示背包内容
制作物品资源的Prefab
-
做好了基本的资源和拾取功能,下面来用UI显示背包内容,这也包括当拾取物品后,UI需要更新显示的内容。首先创建一个Canvas,再创一个Panel作为其子物体,将其放置在屏幕右侧(这里就不做快捷键弹出背包啥的了,可以自己写)
-
下面来思考如何显示背包里的物品,这里将采用Image组件,然后没有准备素材什么的,每个物品就用一种颜色来代替,这里的操作可能有点繁杂:
-
先在Inventory Panel下创建Image组件
-
调整位置 大小等,放在我们设想好的Panel里的第一格
-
我们要改变的是颜色属性,这里设定fish这个物品是绿色
-
为了显示物品的数量,再添加TextMeshPro组件,放到中间先写上0,一会就用这个来更新物品数量
-
还记得吗,之前的itemObject里,有一个属性是Prefab,这里我们要使用的就是这个Prefab,先把Image设定为不同的颜色(刚刚有三个物品资源),分别做成Prefab(这里使用了变体Prefab,按自己方式都可以)
-
分别绑定
脚本显示并更新物品
- 现在删除之前的Image(不过我建议记下第一个格子的坐标:Pos X,Pos Y,一会要用到),来思考如何如何编写脚本:
1.游戏初始化时,要读取已有背包内容,并且立即显示当前背包的物品,也就是创建Image对象
2.在该脚本的Update()中,实时更新背包的内容,如果有新增或者物品数量改变,要及时更新(事实上可能还有删除内容,这样的话逻辑要多写一些,可以自己实现试试)。其实也可以不用每帧都执行更新背包内容的方法,我们可以使用观察者模式,当背包内容有改变时触发事件,执行更新背包内容的方法,这里可以自己尝试改写 - 不过在此之前,还要先思考物品的排列,先创建好脚本,可能要用到这些变量来控制物品排列:
//每个物体之间的间隔
public int X_SPACE_BETWEEN_ITEM;
public int Y_SPACE_BETWEEN_ITEM;
//第一个物体的起始位置
public float X_Start = -339;
public float Y_Start = 170;
//每行显示多少物体
public int NUMBER_OF_COLUMN;
- 所以在创建Image组件时,它的位置需要根据对应的物品资源在当前背包的BackPack这个List里的位置索引来决定,直接来写出GetPosition方法,返回Vector3的位置值:
public Vector3 GetPosition(int index)
{
Vector3 pos = new Vector3(X_Start + X_SPACE_BETWEEN_ITEM * (index % NUMBER_OF_COLUMN),Y_Start - Y_SPACE_BETWEEN_ITEM * (index / NUMBER_OF_COLUMN),0);
return pos;
}
- 解决好排列这个问题,现在来搞定显示与更新,这回我们使用一个Dictionary结构来表示Panel中显示的物品,它的Key是InventorySlot,Value是在Panel中生成好的Image组件,我们希望创建一个一一对应关系,这样在更新物品数量的时候,只需要对比Key是否相同,然后改变Image组件的子组件TextMeshPro的内容即可
- 注意Dictionary比较特殊,Unity对于一些类型不会进行自动序列化等操作
Dictionary<InventorySlot, GameObject> itemsDisplayed = new Dictionary<InventorySlot, GameObject>();
- 编写CreateDisplay()方法,在游戏初始化时就创建好背包内容,这是为了我们之后的存档功能铺垫:
public void CreateDisplay()
{
for(int i = 0; i < inventory.BackPack.Count; i ++ )
{
var obj = Instantiate(inventory.BackPack[i].item.prefab, Vector3.zero, Quaternion.identity, transform);//生成物品资源对应的Image,并返回给obj
obj.GetComponent<RectTransform>().localPosition = GetPosition(i);//设定位置
obj.GetComponentInChildren<TextMeshProUGUI>().text = inventory.BackPack[i].amount.ToString("n0");//最后面的ToString是设定了一下格式
itemsDisplayed.Add(inventory.BackPack[i],obj);//加入刚刚的Dictionary中,便于我们更新数量
}
}
- 编写UpdateDisplay()方法
public void UpdateDisplay()
{
for(int i = 0; i < inventory.BackPack.Count; i ++ )
{
if(itemsDisplayed.ContainsKey(inventory.BackPack[i]))
{
itemsDisplayed[inventory.BackPack[i]].GetComponentInChildren<TextMeshProUGUI>().text = inventory.BackPack[i].amount.ToString("n0");
}//如果已经有该物品存在,就直接更改显示的数量
else
{
var obj = Instantiate(inventory.BackPack[i].item.prefab, Vector3.zero, Quaternion.identity, transform);
obj.GetComponent<RectTransform>().localPosition = GetPosition(i);
obj.GetComponentInChildren<TextMeshProUGUI>().text = inventory.BackPack[i].amount.ToString("n0");
itemsDisplayed.Add(inventory.BackPack[i], obj);
}//这个else里的可以直接复制刚刚的,其实就是如果没有该物品,那就生成
}
}
- 到这里就基本结束了,可以看到拾取两个Cube后: