前言:这一节论述制作控制道具使用的 UI 组件,并建立 UI 组件与程序后端联系的方法,以及一种游戏信息序列化与反序列化的方法。

改变道具数量

前一节我们定义了若干在游戏中出现的道具,关于道具的定义写在配置文件中,可以改动和增减道具的种类。而描述玩家持有何种道具,以及持有的数量,则需要额外的字段存放。在这个游戏中,使用道具会按照道具本身的价值将其兑换为游戏中的通货,这涉及到改变玩家持有的道具数量。

关于这部分功能的实现,分别从视觉展示和逻辑交互两部分入手。

弹出菜单

在物品栏窗口中控制道具消耗的实现方式有很多,这里采用右击物品标识弹出窗口的方式,它长这样:

鼠标在“黄油蛋糕”的图标上悬停并点击右键,出现弹窗提示当前锁定的物品名称,以及一系列按钮。弹窗的左下角点是鼠标点击时的位置。点击不同的按钮将触发不同的动作:使用物品,使用全部物品,关闭弹窗。

这个弹窗在编辑器中依然是一个挂在画布下的节点,内容包含底图、文字和一系列按钮。

UI (空节点,用来挂管理器脚本)Canvas (UI 画布)ColumnCollectionExitGameSelector  // 新增加的弹窗节点Panel  // 底图Content  // 这里挂了一个 Vertical Layout Group 组件Current item name  // 空节点,下面有一个 Text 组件Use  // “使用”按钮Use all  // “全部使用”按钮Cancel  // “取消”按钮

弹窗的图层在目前是最靠前的。弹窗节点 Selector 在整个 UI 中只有一个,要不要做成单例取决于个人偏好。个人倾向于将这个弹窗作为 UI 管理器脚本管辖的组件,弹窗的类中公开一些基本函数,在 UI 管理器中将这些基本函数集合入特定的事件函数,于是弹窗组件的内部实现就被隐藏了。

弹窗组件类 Selector 如下:

using UnityEngine;
using UnityEngine.UI;[RequireComponent(typeof(RectTransform))]
public class Selector : MonoBehaviour
{[HideInInspector] public Vector2 _defaultPos;RectTransform rect;float _width;float _height;float _halfWidth;float _halfHeight;public Text currentItemName;private void Awake(){rect = GetComponent<RectTransform>();_width = rect.sizeDelta.x;_height = rect.sizeDelta.y;_halfWidth = _width / 2;_halfHeight = _height / 2;_defaultPos = new Vector2(-_width, -_height);}// 设置自身位置,并保持自身不越过屏幕边缘void Position(Vector2 pos){pos.x = pos.x < _halfWidth ? _halfWidth : (pos.x + _halfWidth > Screen.width ? Screen.width - _halfWidth : pos.x);pos.y = pos.y < _halfHeight ? _halfHeight :(pos.y + _halfHeight > Screen.height ? Screen.height - _halfHeight : pos.y);rect.position = pos;Debug.Log(pos+ ", "+ Input.mousePosition);  // 调试信息,可以删}// 公开的函数传入调用函数时的鼠标位置public void SetPosition(Vector2 pos){Position(new Vector2(pos.x + _halfWidth, pos.y + _halfHeight));}// 不经过检查而设置到一个固定的位置public void InitPosition(){rect.position = _defaultPos;}
}

而为在前一节中定义的 UI 管理器 UI_Manager 类增加字段和函数:

    public Selector _selector;  // 点击物品出现选框bool _selectorRightLock = true;...// 指挥弹窗开启,并替换当前所指物品的名字信息public void OnSelectorOpen(){if (!_selectorRightLock){            _selector.SetPosition(Input.mousePosition);// CurrentItemCell 是一个 ItemCell 类的引用。// ItemCell 类表示每个物品的标识,// 当鼠标进入某个物品标识的区域时将该标识的引用赋值给 CurrentItemCell// LevelManager 是跟随玩家行动的单例类,保管玩家的位置及持有道具等信息_selector.currentItemName.text = DataModel.Instance.ItemStatement[LevelManager.Instance.CurrentItemCell.Id].name;}}// 指挥弹窗关闭public void OnSelectorClose(){_selector.InitPosition();}
...

这里控制弹窗开闭并非直接设置节点的活动性,而是直接操纵弹窗的位置:如果弹窗在屏幕之外,就不会再显示,被视为关闭。这可以保证转换位置随时是可靠的。

表示物品标识的节点上加入了 EventTrigger 组件,我们可以在上面加上事件种类和相应的函数。除了鼠标进入和离开的事件,还可以加入鼠标点击事件控制弹窗开闭。而 EventTrigger 组件中的鼠标点击会响应来自鼠标所有按键的点击,并不加以分辨,为此就要加上一个锁,保证只有某个键才能触发事件函数。

上述在 UI_Manager 类中增加的函数和字段参与实现了这一功能,布尔变量 _selectorRightLock 就是确保鼠标右击才能触发事件函数的锁。相应地,在 Update() 函数中就需要维持这个变量为真,只有当检测到鼠标右键按下时才将该变量暂时置为假。如此一来就保证了一定要在物品标识上点击右键才显示弹窗。

弹窗效果调试可靠后就可以编写与之相关联的动作了,比如……吃掉一块黄油蛋糕?


维护持有物品信息

使用游戏道具,增加游戏中的通货,一方面要记录玩家有什么道具、有多少道具,另一方面要能修改这些信息。记录这一信息可以用字典实现——前一节我们定义了游戏中出现的所有物品,而玩家持有的道具在种类上永远是这个定义的子集,那么我们要建立的字典就不需要有很复杂的结构,这个字典的键和值都可以是整数,像这样:

public Dictionary<int, int> ItemCount { get; private set; }  // 玩家持有的物品数量

这个字典其实在前一节已经提到过了,它被作为控制物品栏显示物品标识的函数参数,而现在我们要让它发挥更大的作用。这个字典可以保存在游戏的数据层,或者让它跟随玩家行动,改变持有物品的数量时会改动这个字典的内容,在每次改动这个字典时也要更新视图显示——只需要调用事先编写的事件函数即可。

维护字典的函数有获取字典的初始值和改动字典表示的物品数量,前者可以直接为字典赋予初始化信息或者从一个存档文件中读取,眼下关注后者:

...public Dictionary<int, int> ItemCount { get; private set; }  // 玩家持有的物品数量
...// 传入道具序号和变化数量,负数为减少private bool ItemDeltaCount(int itemId, int deltaCount = -1){int count = -1;if (ItemCount.TryGetValue(itemId, out count)){if (-deltaCount > count){Debug.Log(string.Format("欲消耗道具数量 {0} 大于既有 {1}", deltaCount, count));return false;}ItemCount[itemId] += deltaCount;return true;}Debug.LogError(string.Format("道具 id {0} 不存在", itemId));return false;}// 绑定按钮的事件函数public void OnClick_UseCurrentItemOnce(){int price = DataModel.Instance.ItemStatement[CurrentItemCell.Id].price;if (ItemDeltaCount(CurrentItemCell.Id, -1) &&ItemDeltaCount(0, price)){UI_Manager.Instance.OnItemListChange(ItemCount);UI_Manager.Instance.OnShowTextChange(CurrentItemCell);}}// 绑定按钮的事件函数public void OnClick_UseCurrentItemAll(){int price = DataModel.Instance.ItemStatement[CurrentItemCell.Id].price;int deltaNum = ItemCount[CurrentItemCell.Id];if (ItemDeltaCount(CurrentItemCell.Id, -deltaNum) &&ItemDeltaCount(0, price * deltaNum)){UI_Manager.Instance.OnItemListChange(ItemCount);UI_Manager.Instance.OnShowTextChange(CurrentItemCell);}}
...

解释一下这些函数的作用:这些函数与表示玩家持有物品的信息相关,函数 ItemDeltaCount() 传入数量发生改变的道具 id 和发生改变的数量,正数为增加负数为减少;下面两个函数则包装了 ItemDeltaCount(),将使用物品的动作分解为消耗一定数量的道具和按被消耗道具的总价值增加游戏通货的数量 (我将游戏中的通货也视为一种道具,它的 id 是 0)。由于之前保证了 CurrentItemCell 指向鼠标选中的物品标识,被设为公有的事件函数绑在按钮上可以可靠地消耗指定的道具。


玩家存档的序列化与反序列化

现在我们能控制一个虚拟小人在我们捏造的世界中行走奔跑,用掉身上携带的物品,但当我们结束游戏重新启动它,一切又从头来过,虚拟小人没有在虚拟世界中留下任何痕迹。

要让程序记住我们之前的操作,就要在硬盘里保存一份游戏的状态信息,游戏每次启动时会读取这些信息并将游戏复盘到保存时的样子。保存信息的动作称为序列化,而读取信息的动作称为反序列化,使用 C# 时我们可以借助内建的序列化功能实现游戏信息的序列化和反序列化。

出于结构清晰的考虑,我们最好为序列化功能单独建一个类,而我们的数据模型则包含一个该类的实例,在游戏程序的其他地方如果有需要,则透过数据模型的单例间接获得反序列化的信息或者指挥程序保存游戏信息。

下面是一个序列化游戏信息的例子。游戏存档的内容包括玩家当前所处的场景和位置,以及记录玩家持有物品数量的字典:

using System.Collections.Generic;
using System.IO;
using System.Runtime.Serialization.Formatters.Binary;
using UnityEngine;public class DataFormatter
{[System.Serializable] public class Data{public string sceneName;  // 玩家所在的场景名称,适应分多场景加载的游戏public float[] position;  // 玩家所在的坐标位置,Vector3 的替代方案public Dictionary<int, int> itemCount;// Data 类实体被赋予缺省值public Data(){sceneName = "SampleScene";position = new float[3] { 5, 0, -5 };itemCount = new Dictionary<int, int>{{ 0, 180 },{ 1, 1 },{ 2, 2 },{ 3, 4 },{ 4, 1 }};}public override string ToString(){return string.Format("sceneName = {0}, pos = ({1}, {2}, {3})", sceneName, position[0], position[1], position[2]);}}public Data dataObj;private const string path = "D:\\";private const string fileName = "FormatData.dat";public DataFormatter(){dataObj = new Data();}// 读取序列化文件为 dataObj 赋值,若序列化文件不存在,则使用缺省值并生成存档文件public void ReadFormat(){if (!File.Exists(path + fileName)){using (Stream fs = File.Open(path + fileName, FileMode.CreateNew)){BinaryFormatter bf = new BinaryFormatter();bf.Serialize(fs, dataObj);}}else{using (Stream fs = File.Open(path + fileName, FileMode.Open, FileAccess.Read)){BinaryFormatter bf = new BinaryFormatter();dataObj = bf.Deserialize(fs) as Data;  // dataObj 被覆写}}}// 写入存档信息,原来的存档内容会被覆盖public void SaveFormat(){if (File.Exists(path + fileName)){using (Stream fs = File.Open(path + fileName, FileMode.Create, FileAccess.Write)){BinaryFormatter bf = new BinaryFormatter();bf.Serialize(fs, dataObj);}}}
}

当游戏需要保存更多的元素时,只需要增加 Data 类的字段。如果改写了 Data 类的内容,改写前的存档文件将不能被成功读取。System.Serializable 特性被加载 Data 类前,这表示 Data 类可以通过序列化文件被创建。

在 DataFormatter 类有两个 string 常量,它们分别表示存储存档文件的路径及文件名。简单起见我将存档位置放在了硬盘的根目录下,更好的方案是在一些约定俗成的路径放置一个专门的文件夹来安放存档文件,如 C 盘的 ProgramData 文件夹下建立一个与游戏项目同名的文件夹安放存档。

序列化过程使用了 BinaryFormatter 类的功能,要使用它需要引入 “System.Runtime.Serialization.Formatters.Binary”,Serialize() 和 Deserialize() 分别是序列化与反序列化函数。SaveFormat() 实现了存档动作,可以将这个函数放在玩家所在的 gameObject 被销毁时执行;ReadFormat() 在游戏开始时执行,它为字段 dataObj 赋值 (或者如果存档文件不存在,使用一组默认值),拥有实体的 dataObj 可以被游戏的数据模型读取,从而复盘整个游戏。

将这个类接入数据模型,游戏的其他组件访问数据模型间接获取反序列化的结果或者启动序列化,中间还需要一些边边角角的工作。不过如果把它们全部接起来,就能看到类似下面的效果:

启动游戏,我们现在有一块黄油蛋糕。

控制小人跑到斜坡顶上,扔掉所有的相片,吃掉黄油蛋糕还喝光了乌龙茶。退出游戏。

重新进入游戏,小人出现在上一次退出的位置。

物品也保持在被消耗的状态。

而在 D 盘多了一个 FormatData 的 .dat 文件,打开里面是这样的:

当然,序列化的方式并不唯一。我们还可以将存档信息按照特定的格式编辑成文本储存和读取,这样更加直观,但也降低了篡改存档文件的难度。


参考资料

C# 读写文件的功能:
https://docs.microsoft.com/zh-cn/dotnet/standard/io/how-to-read-and-write-to-a-newly-created-data-file

关于 FileMode 的解释:
https://docs.microsoft.com/zh-cn/dotnet/api/system.io.filemode?view=netframework-4.7.2#System_IO_FileMode_Create

System.Serializable 的作用:
https://blog.csdn.net/tracyly1029/article/details/7072508

Unity3D游戏日记 (3):来块黄油蛋糕相关推荐

  1. 我的Unity3D学习日记-06(自己动手制作FlappyBird)

    自从上次跟着敲了官方示例拾荒者之后,开始对Unity制作2D游戏感兴趣了起来,虽然本文标题叫做Unity3D学习日记.但是Unity其实本来名字里是没有3D这俩字的--很有名的雨血前传 蜃楼就是一个使 ...

  2. Unity3D游戏开发之网络游戏服务器架构设计培训

    下面我们开始今天的Unity3D游戏开发技能培训. 我们专业培养"游戏主程",挑战20W年薪,初期学习Unity3D培训目标:让U3D初学者可以更快速的掌握U3D技术,自行制作修改 ...

  3. Unity3D 游戏引擎之脚本实现模型的平移与旋转(六)

    Unity3D 游戏引擎之脚本实现模型的平移与旋转 雨松MOMO原创文章如转载,请注明:转载至我的独立域名博客雨松MOMO程序研究院,原文地址:http://www.xuanyusong.com/ar ...

  4. Unity3D游戏-愤怒的小鸟游戏源码和教程(二)

    Unity愤怒的小鸟游戏教程(二) 本文提供全流程,中文翻译. Chinar坚持将简单的生活方式,带给世人! (拥有更好的阅读体验 -- 高分辨率用户请根据需求调整网页缩放比例) AngryEva游戏 ...

  5. android+Unity3D游戏开发之简单的物体运动

    android+Unity3D游戏开发之简单的物体运动 其实这篇也是转载的,真的感觉对于我们初学者来说很不错的,不信你看看嘛;原创链接:http://bbs.9ria.com/thread-98192 ...

  6. Unity3D 游戏引擎之IOS高级界面发送消息与Unity3D消息的接收(九)

    Unity3D 游戏引擎之IOS高级界面发送消息与Unity3D消息的接收 雨松MOMO原创文章如转载,请注明:转载自雨松MOMO的博客原文地址:http://blog.csdn.net/xys289 ...

  7. 从一点儿不会开始——Unity3D游戏开发学习(一)

    一些废话 我是一个windows phone.windows 8的忠实粉丝,也是一个开发者,开发数个windows phone应用和两个windows 8应用.对开发游戏一直抱有强烈兴趣和愿望,但奈何 ...

  8. Unity3D 游戏引擎之平面小球重力感应详解【转】

    http://blog.csdn.net/xys289187120/article/details/6969333       手机重力感应应该对大多数开发者并不陌生,在新一代智能手机Android  ...

  9. Unity3d 游戏中集成Firebase 统计和Admob广告最新中文教程

    之前写过俩相关的教程,最近发现插件官方更新了不少内容,所以也更新一篇Firebase Admob Unity3d插件的教程,希望能帮到大家. Firebase Admob Unity3d插件是一个Un ...

最新文章

  1. mysql err 1349_MySQL 视图 第1349号错误解决方法
  2. push计算机语言,数组的操作push,pop,shift,unshift详解
  3. 邢不行python资源_邢不行—数字货币python量化投资
  4. 理论基础 —— 线性表
  5. 特征筛选5——距离相关系数筛选特征(单变量筛选)
  6. 反欺诈的这几个重点内容值得您关注
  7. selenium2 webdriver要点理解
  8. python官网的sdk下载详细步骤-Python SDK(beta)
  9. 【干货】如何打造高质量的NLP数据集
  10. IntelliJ IDEA创建Java-Web项目
  11. 启动orcal服务和监听的命令的一种方式
  12. 开源APP源代码、游戏源代码
  13. python 自动下载网页链接_用python做一个网页自动下载脚本
  14. 无刷直流电机学习笔记5
  15. 达芬奇导入gif(含 AE 和 PR)
  16. 带数据库html5游戏教程,html5学习之旅-html5的简易数据库开发(18)-H5教程
  17. 挑选电脑免费加密软件特别注意哪些?
  18. 流失用户召回方法策略,教你如何挽回流失用户
  19. 好像记得有个人喜欢我
  20. 华为云服务器EulerOS镜像源设置方法

热门文章

  1. Android应用程序访问linux驱动第三步:实现并向系统注册Service
  2. C# Win10识别网络盘问题
  3. 【每天学点管理】——如何避免时间浪费,管理者必须具备的时间管理方法
  4. 修改git默认的编辑器
  5. drive下载 synology_synology drive安卓下载-drive 安卓版v2.2.0-PC6安卓网
  6. c语言用循环语句画红旗,C语言 飘动的红旗(要有旗杆)
  7. Centos7的yum使用国内源阿里源163源等提高下载速度
  8. Redis异常:JedisConnectionException: All sentinels down, cannot determine where is mymaster master is
  9. hive over窗口函数的使用
  10. Windows7挂载NFS服务