Unity3D游戏日记 (2):缝一个背囊

前言:在写下这些文字前,我假定我的读者对 C# 本身和 Unity 引擎已经有初步的了解,但对组件的内容和特定设计的方法论一无所知。多数概念都将被解释,包括专业术语。

从配置文件声明游戏中存在的物品

前一节我们把一个人物扔进了虚拟世界中,并且让她像个提线木偶一样受人操控。单让人物出现在镜头下未免单调,而整个世界看起来也空荡荡的,我们需要为这个世界加点料——比如,可以捡起来的物品。但在实现可拾取物品之前,定义游戏中会出现什么样的物品是必要的。

游戏可以看作是某种人与计算机之间的互动系统,把这样的系统分解为“数据模型、控制器、外观行为”等几个模块是常见的思路。顺着这个思路想下去,为游戏世界声明存在的物品,得到的“物品百科”就应该存放在数据模型里。数据模型应当是一个单例类,其在整个游戏的生命周期内都应当存活并可以在任何地方被访问。

public class DataModel
{
    private static DataModel _pInstance;
    public static DataModel Instance
    {
        get
        {
            if (_pInstance == null)
            {
                _pInstance = new DataModel();
            }
            return _pInstance;
        }
    }

    private DataModel()
    {
        // 在这里初始化字段
    }

    /* 在下面加入初始化信息,如载入游戏是角色所在的场景和位置,
     * 以及从配置文件拉取游戏中的物品定义
     * 数据模型应当仅存放“只读”的内容,在游戏运行时只向游戏中的对象提供信息
     */
}

游戏运行时要保存关于游戏中物品的定义,一个理想的数据结构是字典 (可以理解为使用关键字作为索引的数组)。而关于物品的信息记载在外部的文本文件中 (类似的要从外部拉取信息到游戏,也可能使用数据库),在游戏准备就绪前需要读取文件并将文件记录的信息写入字典,这一步被称为“反序列化”。

在上述 DataModel 类中,反序列化的动作会在其构造函数中进行。很容易想到反序列化分两步:读取文件获取信息和将信息写入字段。要顺利执行这些步骤,文件的格式需要事前约定,常用于类似工作的文件格式有:

  • .csv 文件 (使用半角逗号或者半角分号分隔的文本文件,可被 Excel 作为表格文件读写);

  • .json 文件 (也被称为 Json,使用键值对存放信息);

  • .txt 文件 (表格形式的文本文件,不多见。作为表格时,一般使用制表符分隔,可以用 Excel 创建或者读取)。

    上述文件都可以用记事本读写。

    下面是用于示例的 Json 文件:

{
    "array":[
        {
            "id":0,
            "name":"人性",
            "price":1,
            "text":"一个单位的人性"
        },
        {
            "id":1,
            "name":"黑色发圈",
            "price":1,
            "text":"具有弹性,能扩张到两倍长。"
        },
        {
            "id": 2,
            "name": "风化的相片",
            "price": 2,
            "text": "画面糊了。"
        },
        {
            "id": 3,
            "name": "可燃乌龙茶",
            "price": 10,
            "text": "某个潜水社团特产的乌龙茶,可以被点燃,社团成员用其招待后辈。"
        },
        {
            "id": 4,
            "name": "黄油蛋糕",
            "price": 5,
            "text": "身为亡者的黄金妖精会用黄油蛋糕款待凯旋的战士,庆祝其胜利或幸存的事实。"
        }
    ]
}

Unity 中有自带的 JsonUtility 类可以将读取的 Json 流转化为事先写好的序列化类的实例,需要保证类的字段与 Json 文件的字段一一对应 (也有第三方插件为 Unity 提供读写 Json 的功能支持)。以上述的 Json 文件为例:

using UnityEngine;

public class ItemJsonLoader
{
    /*
     * 从 Json 配置文件中拉取信息
     * 参与声明游戏中存在的物品
     */

    // 传入 Json 文件无后缀的文件名
    public static ItemArray ReadItemData(string fileName)
    {
        var rawJsonText = Resources.Load(fileName);
        if (rawJsonText == null)
        {
            Debug.Log(string.Format("File \"{0}\" is not exists!", fileName));
            return null;
        }
        return JsonUtility.FromJson(rawJsonText.text);
    }
}

[System.Serializable] public class ItemType
{
    /*
     * 表示游戏中物品的信息
     * 参与序列化/反序列化
     */

    public int id;
    public string name;
    public int price;
    public string text;

    public override string ToString()  // 重写 ToString() 用于生成调试信息
    {
        return string.Format("\"id:\"{0}, \"name:\"{1}, \"price\":{2}, \"text\":{3}",
            id, name, price, text);
    }
}

[System.Serializable] public class ItemArray
{
    public ItemType[] array;
}

参与反序列化的类,“可序列化”特性 (Serializable) 是必需的。

在前述的 DataModel 类中,加入函数:

// 传入 Json 文件名,这个函数在 DataModel 的构造函数中被调用
// ItemStatement 是关于物品 id 和描述物品的 ItemType 类的字典
void BuildItemStatement(string fileName)
    {
        if (ItemStatement == null)
        { 
            ItemStatement = new Dictionary<int, ItemType>(); 
        }
        var items = ItemJsonLoader.ReadItemData(fileName);
        if (items == null)
        {
            Debug.LogError("Bad readJson");
            return;
        }
        foreach (var single in items.array)
        {
            ItemStatement[single.id] = single;
        }
    }

这个函数在 DataModel 的构造函数中调用,确保第一时间反序列化文件。函数传入要读取的文件名称作为参数。

Unity 自带的 JsonUtility 不能直接转化 Json 文件中的“数组” (用方括号包括的部分),作为替代方式,用一个键作为这个数组的索引,间接地读入数组,所以读取文件的函数 ReadItemData() 返回的是包含物品信息数组的 ItemArray 类。

值得注意的是 ItemJsonLoader 类中的静态函数,它使用了 Resources 类的函数寻找并读入文件,这意味着你要在工程中建一个名叫 Resources 的文件夹,并将 Json 文件放在那里面才能被程序找到。

欲测试是否正确读取并反序列化文件,可以在脚本中写入测试信息并挂到游戏中的物体上,形如:

using UnityEngine;

public class SerializeLog : MonoBehaviour
{
    private void Start()
    {
        var dic = DataModel.Instance.ItemStatement;
        foreach (var single in dic)
        {
            Debug.Log(single.Value);
        }
    }
}

如果文件被正确读取,就会显示物品信息。否则会出现错误信息及提示。


简单 UI,菜单开闭行为

在定义游戏中可能出现的物品之后,我们还需要一个物品栏来存放我们的战利品。物品栏包含在用户界面 (User interface, UI) 内,而我们需要一个简单的用户界面。

在开始这一步之前不妨先构思一下自己内心中的用户界面是什么样的,它需要包含哪些要素。哪怕是最简单的设计也需要制作者事前心中有数。

制作一个简单的界面,它在按下某个键 (比如 Tab 键) 后被呼出,(且不管呼出界面具体是菜单飞入画面还是单纯出现) 而被呼出的最顶层的菜单上还有按键,按下按键开启对应的功能,如切换到物品栏,或者弹出结束游戏的确认框。那么现在这个界面在 Unity 中的节点关系可能是这样的:

UI (空节点,用来挂管理器脚本)
    Canvas (UI 画布)
        Column (逻辑上最顶层的菜单,子节点下有按钮)
        Collection (物品栏节点)
        ExitGame (提示确认游戏结束的框体,至少应包含提示文字及确定/取消按钮)

参与控制 UI 中各组件开闭的控制器应放在 UI 的根节点上,由它控制组件是否显示在画面上。而用户的输入可能来自键盘,呼入或者呼出界面;或者点击某些界面菜单上的按钮。确保事件被响应,就要为每一个设计下被响应的操作事件连接对应的函数。

最容易想到的具有共性的行为就是菜单的开启和关闭,可以使用接口 (语法上的) 或者类作为控制每个单元的接口 (逻辑上的),形如:

using UnityEngine;

public class UI_Cell : MonoBehaviour
{
    // 控制 UI 节点开闭,影响鼠标显示及镜头锁定

    public bool IsActive
    {
        get
        {
            return gameObject.activeSelf;
        }
        set
        {
            gameObject.SetActive(value);
            OnSetAcitve();
        }
    }

    void OnSetAcitve()
    {
        // 可以增加在开启或者关闭菜单时附加的行为
    }

    // UI 组件单元开启/关闭显示的函数,可以改写以改变开启/关闭的形式
    public void Open()
    {
        IsActive = true;
    }

    public void Close()
    {
        IsActive = false;
    }

    public void Switch()
    {
        IsActive = !IsActive;
    }
}

上面这个脚本分别绑在了 Column、ExitGame 和 Collection 三个节点上。

它们在 Unity 界面里看上去像这样 (Collection 节点没有显示),Column 和 ExitGame 分别包含两个按钮:
Unity3D游戏日记 (2):缝一个背囊_第1张图片

而在根节点上可以放置一个管理 UI 的单例类:

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;

public class UI_Manager : MonoBehaviour
{
    /*
     * 控制 UI 表现,执行 UI 事件
     */

    private static UI_manager instance = null;
    public static UI_Manager Instance 
    { 
        get { return instance; }
        set { } 
    }

    public UI_Cell column;  // 选项菜单
    public UI_Cell exitGame;  // 结束游戏的提示框
    public UI_Cell collection;  // 物品栏节点,子节点为图标栏及说明文字栏

    private void Awake()
    {
        if (instance == null)
        {
            Instance = this;
            DontDestroyOnLoad(gameObject);
        }
        else
        {
            DestroyImmediate(this);
        }
    }

    // Use this for initialization
    void Start()
    {
        column.Close();
        exitGame.Close();
        collection.Close();
    }

    // Update is called once per frame
    void Update()
    {
        if (Input.GetKeyDown(KeyCode.Tab))
        {
            if (column.IsActive)
            {
                if (exitGame.IsActive) { exitGame.Close(); }
            }
            if (collection.IsActive)
            {
                collection.Close();
            }
            column.Switch();
        }
    }

    public void OnClick_ExitGame()
    {
#if UNITY_EDITOR
        UnityEditor.EditorApplication.isPlaying = false;
#else
        Application.Quit();
#endif
    }

    public void OnClick_Collection()
    {
        collection.Open();
        column.Close();
    }
}

按下 Tab 键时主菜单被呼出,主菜单上的两个按键绑定对应节点的 Open() 或者 Close() 函数,或者在 UI_Manager 中定义的函数。

如在游戏中呼出主菜单:
Unity3D游戏日记 (2):缝一个背囊_第2张图片

“结束游戏”按钮在 Unity 编辑器中设置绑定了 ExitGame 节点上 UI_Cell 脚本的 Open() 函数,点击这个按钮就会弹出对话框提示是否结束游戏:
Unity3D游戏日记 (2):缝一个背囊_第3张图片

而弹出的对话框又有两个按钮,分别绑定控制结束游戏的命令与关闭该对话框的函数。


在UI 框体中显示物品

有了简单的 UI 原型,就可以接入物品栏了。按下”收集物“按钮将显示物品栏并隐藏主菜单。

在这里,点击 “收集物”按钮的话将弹出物品栏界面:
Unity3D游戏日记 (2):缝一个背囊_第4张图片

……美术资源和审美都是好东西,然而这两样我都没有。这个物品栏已经包含了描述道具需要的基本元素,左侧作为物品的标识,由一张半透明底图和文字组成,右侧是说明文字,当鼠标移动到某个物品的标识上就会显示对应的说明文字,包含物品和兑换的价值信息。

要事先这样的界面,需要制作界面本身的显示效果,并建立界面与后端脚本之间的联系。

显示效果

物品栏界面 (前述的 Collection 节点下的内容) 的结构如下:

Collection
    Scroll View (物品标识)
        Viewport
            Content
                IconCell (物品标识的预制体,用于后续实例化)
                    Image (底图)
                    Text (物品名)
    Scroll View (说明文字)
        Viewport
            Content
                Text (说明文字的文本组件)

Collection 节点下作为子节点的两个 Scroll View 组件可以在 Unity 编辑器中直接创建。Scroll View 的大小基本就是组件在 UI 上的大小,所见即所得。刚创建好的 Scroll View 包含两个滚动条,这里全部去掉了,出于操作灵活性考虑可以选择留着。

子节点 Viewport,即“视口”,决定了这个组件显示内容的范围。Viewport 包含 Mask 组件,在 Viewport 以外范围的图形和文字将被遮盖。
Unity3D游戏日记 (2):缝一个背囊_第5张图片

如果之前选择去掉滚动条,之后显示时下方和右方依然会留下一段空白。在 Viewport 中设置显示区域可以去掉或者增加留白。

Scroll View 下的 Content (内容) 节点才是安放显示所需物件的位置。显示物品说明文字的 Text 组件或者显示物品标识的预制体都在对应的 Content 节点下。

说明文字和物品标识要有拖动效果,并能确保它们组织在视口内、有规则地排列,在 Content 节点上增加 Content Size Fitter 和 一种 Layout Group 组件。这些组件将控制 Content 的子节点成员的大小和位置,根据设置,前者将为子节点物件的尺寸指定一个适应视口大小的值 (适应长度、宽度或皆有),后者会规定多个子节点的排列方式及每个子节点物件的尺寸、间距等。

显示效果制作并调整后就可以建立界面与脚本间的联系了。

脚本控制

说明文字可以直接控制组件替换其中的文字,而物品标识需要专门的脚本改变单个标识代表的内容。这里的思路是把单个标识当成一张可以擦写的白纸,只需要为人物持有的物品准备足够多的“白纸”再为它们写入对应物品的标记,并且可以在持有物品的数量和种类发生变动时及时变更。

每个物品标识都是由同一个预制体生成的复制体,在预制体上加入脚本,根据游戏道具包含的字段设计脚本。

using UnityEngine;
using UnityEngine.UI;

public class ItemCell : MonoBehaviour
{
    /*
     * 控制每个物品标识包含的物品信息
     */

    public int Id { get; set; }  // 物品的 id
    public int Count { get; set; }  // 持有数量

    public Text selfText;  // 描述物品名的 Text 组件,从编辑器拖放

    private void OnEnable()
    {
        OnCellChange(Id, Count);
    }

    // 当前标识的内容发生改变时调用的函数
    public void OnCellChange(int id, int count)
    {
        ItemType item = null;
        // ItemStatement 是一个自定义的字典,作为游戏中存在物品的定义
        if (DataModel.Instance.ItemStatement.TryGetValue(id, out item))
        {
            Id = id;
            Count = count;
            selfText.text = item.name;
        }
    }

    public override string ToString()
    {
        ItemType item = null;
        if (DataModel.Instance.ItemStatement.TryGetValue(Id, out item))
        {
            return string.Format("数量:{0}\n价值:{1}人性,总计{2}人性\n{3}",
            Count,
            item.price,
            item.price * Count,
            item.text);
        }
        return name;
    }

    public void OnPointerEnter()
    {
        // OnShowTextChange 是自定义函数,
        // 根据当前实体的 Id 和 Count 字段改变说明文字的内容
        UI_Manager.Instance.OnShowTextChange(this);
    }

    public void OnPointerExit()
    {
        UI_Manager.Instance.OnShowTextChange(null);
    }

    public void OnPointerClick()
    {
        // 可以加入点击事件,实现弹出小窗兑换、使用或者丢弃物品等功能
    }
}

在前述的 UI_Manager 类中加入字段和函数:

...
    public RectTransform iconScrollContent;  // 盛放物品标识的 Content 节点

    public Text showText;  // 显示物品说明信息的 Text 组件

    // 鼠标没有悬停在物品标识上说明文字的默认值
    public string showTextdefault = "default text";  

    public GameObject itemCellPrefab;  // 物品标识的预制体
    List itemsBuffer;

...
    // 说明文字发生改变时调用的函数,传入一个已经写入物品信息的标识实体
    public void OnShowTextChange(ItemCell item = null)
    {
        if (item == null)
        {
            showText.text = showTextdefault;
            return;
        }
        else if (!DataModel.Instance.ItemStatement.ContainsKey(item.Id))
        {
            showText.text = "Not exist.";
            return;
        }
        showText.text = item.ToString();
    }

    public void OnItemListChange(Dictionary<int, int> newItemCount)
    {
        /*
         * 每次持有物品列表发生变化时调用,传入一个新的物品 id 与数量的映射
         */

        if (itemsBuffer == null) { itemsBuffer = new List(); }
        int index = 0;
        foreach (var single in newItemCount)
        {
            if (single.Value == 0) { continue; }

            GameObject obj = null;
            if (index < itemsBuffer.Count)
            {
                obj = itemsBuffer[index];
            }
            else
            {
                obj = Instantiate(itemCellPrefab, iconScrollContent);
                itemsBuffer.Add(obj);
            }
            obj.SetActive(true);

            ItemCell item = obj.GetComponent();
            item.OnCellChange(single.Key, single.Value);

            index++;
        }
        for (int i = index; i < itemsBuffer.Count; i++)
        {
            itemsBuffer[i].SetActive(false);
        }
    }

...

这里说明一下 OnItemListChange() 函数的功能:这个函数传入一个玩家持有物品的字典,字典表示为物品 id 和持有数量的键值对,这个字典应当存放在与玩家相关的单例类中。每次持有物品的情况发生变化就调用一次该函数,更新物品栏界面。函数连接复制物品标识实体所需的预制体,以及一个列表,用于存储这些复制体的缓冲区。函数会遍历传入的字典,根据字典中已有的物品更新物品标识的内容。如果没有足够的物品标识实体,就创建更多的复制体保存在缓冲区里。最后函数会隐藏多余的物品标识。

调试时,在初始化阶段 (如脚本的 Start() 函数中) 为玩家持有物品的字典加入键值对,如果字典信息正确且脚本和界面正确联系,就能在物品栏界面上看到加入的物品。


扩展:事件驱动编程

前述的脚本通过互相之间调用函数实现组件之间的合作,而调用函数的时机往往是在程序状态发生变化的时候,这很适合事件管理的形式。而脚本中也使用了“管理器”命名一些单例类。

作为一种程序设计思考方式,将游戏体系发生的变化当作事件,在事件触发时调用函数,这比时刻监听变量的变化来得简明高效。当体系更加复杂时,可采用规范化的事件管理器和监听器作为框架,这将使代码膨胀,但也会降低维护难度。

事件管理可以使用接口 (虚基类) 或委托 (函数指针或者回调函数) 实现。它们的共同点是需要一个单例类作为管理器并连接所有受其指挥的组件——如果使用接口实现,组件继承监听器类;如果使用委托实现,组件要将事件发生时调用的函数加入管理器的委托列表,在每个组件初始化时要建立自身同管理器的联系,即注册。而游戏中的任何组件都可以触发事件,在触发事件时只需要向管理器提交事件 (调用管理器中的函数,传入参数如事件种类、提交事件的组件和改变值),管理器会令受其控制的组件做出反应。

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