Unity3D游戏日记 (3):来块黄油蛋糕

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

改变道具数量

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

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

弹出菜单

在物品栏窗口中控制道具消耗的实现方式有很多,这里采用右击物品标识弹出窗口的方式,它长这样:
Unity3D游戏日记 (3):来块黄油蛋糕_第1张图片

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

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

UI (空节点,用来挂管理器脚本)
    Canvas (UI 画布)
        Column
        Collection
        ExitGame
        Selector  // 新增加的弹窗节点
            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();
        _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 可以被游戏的数据模型读取,从而复盘整个游戏。

将这个类接入数据模型,游戏的其他组件访问数据模型间接获取反序列化的结果或者启动序列化,中间还需要一些边边角角的工作。不过如果把它们全部接起来,就能看到类似下面的效果:
Unity3D游戏日记 (3):来块黄油蛋糕_第2张图片

启动游戏,我们现在有一块黄油蛋糕。
Unity3D游戏日记 (3):来块黄油蛋糕_第3张图片

控制小人跑到斜坡顶上,扔掉所有的相片,吃掉黄油蛋糕还喝光了乌龙茶。退出游戏。
Unity3D游戏日记 (3):来块黄油蛋糕_第4张图片

重新进入游戏,小人出现在上一次退出的位置。
Unity3D游戏日记 (3):来块黄油蛋糕_第5张图片

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

而在 D 盘多了一个 FormatData 的 .dat 文件,打开里面是这样的:
Unity3D游戏日记 (3):来块黄油蛋糕_第6张图片

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


参考资料

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

你可能感兴趣的:(Unity,C#)