偶然发现这篇文章三个月前就挂上 TODO 列表了,一直没写,这次把它补上。本文主要分析了某款 Galgame 从策划配表到程序运行起来的整体流程。

热更新资源与 CSV 表

Unity 代码热更分Lua派和ILRuntime派,而资源热更大部分是AssetBundle。我要分析的这款 Galgame 也支持热更,不过只用到了资源热更,游戏内的主逻辑是不变的。

既然游戏内主逻辑不变,那有人就要问了,剧情热更是如何实现的呢?很简单,把配好的 CSV 表热更下来,读表生成动态的剧情行为树,主逻辑执行行为树就好了。

下面是该游戏AssetBundle中的剧情表结构:

人物,文本,CV,命令,命令,命令,命令,命令,命令
,,,activemusic:no=0 file=BGM\终结 name=终结,,,,,
,,,setflag:jump,,clearfg:,stopbgm:,bg:name=BG_1 path=black entereffect=fade entertime=5,waittime: time=2
男孩,“怎么了?为什么突然问这事?”,,,,,,,
//省略一大堆
,,,stopbgm:,bg:name=BG_1 path=black entereffect=fade entertime=1,waittime:time=7,,,
,,,jump:storage=01 target=jump,,,,,

游戏剧情按照 CSV 表顺序依次执行,同时根据表里的自定义命令,可以跳转执行位置或读取其他剧情表,达到剧情分支的效果。

解析模块分析

我使用 dnSpy 分析Assembly-CSharp.dll,一般情况下该文件包含打包时所有的内部脚本。

  1. 加载 AB 包
    游戏拥有一个单例类 GlobalData,在其 Awake 方法中,会读取资源列表文件info.txt并尝试解析,之后尝试加载所有 AssetBundle 资源:

    if (File.Exists(SystemConfig.AB_PATH + "/info"))
    {
        string[] array = ((TextAsset)AssetBundle.LoadFromFile(SystemConfig.AB_PATH + "/info").LoadAsset("Assets/Resources/info.txt")).text.Split(
            new char[]
            {
                '\n'
            });
        try
        {
            foreach (string text in array)
            {
                string key = text;
                this.SHAsset.Add(key, AssetBundle.LoadFromFile(SystemConfig.AB_PATH + "/" + text));
            }
        }
        catch (Exception ex)
        {
            Debug.LogError(ex.Data);
        }
    }
    

    资源列表以每行一个的形式存储了所有需要加载的 AssetBundle 包

    background
    audio
    event
    [略]
    scripts
    

    读取该包后,按照换行符\n切割,然后用 foreach 循环读取。

    之后还有一些对于配置信息存档的处理代码,包括设置游戏分辨率、是否全屏、音量、版本迁移、文字滚动速度等,在此不做讨论。

    在另外一个DialogManagerStart方法中,其根据需要加载了要使用的剧情 CSV 脚本:

    if (string.IsNullOrEmpty(GlobalData.Instance.UserData.Dialog.ScriptFileName))
    {
        GlobalData.Instance.UserData.Dialog.ScriptFileName = Utility.GetInitScriptFile();
        GlobalData.Instance.UserData.Dialog.LineNum = SystemConfig.InitialLine;
        GlobalData.Instance.UserData.Dialog.LoadScript(GlobalData.Instance.UserData.Dialog.ScriptFileName);
        this.ProcessCommand(null);
        this.isSkipModeSet = false;
        return;
    }
    GlobalData.Instance.UserData.Dialog.LoadScript(GlobalData.Instance.UserData.Dialog.ScriptFileName);
    this.LoadElements();
    if (SceneController.OldSceneName.ToLower().Equals("videoplayer") || SceneController.OldSceneName.ToLower().Equals("srpg"))
    {
        this.ProcessCommand(null);
    }
    

    其中ScriptFileName存储的是表名,在新游戏状态下,该名为空,则读取GetInitScriptFile()返回的表名,GetInitScriptFile会读取config.txt,返回的结果是 00,即第一张表。

    在读取存档时,ScriptFileName会被赋值为存档时使用的脚本名(章节脚本为多个独立 csv,章节剧情热更解锁)。

    UserData.Dialog.LoadScript即为加载剧情 csv 的方法,其将 CSV 按行切割存储到 List 中。

    list = Regex.Split(Utility.GetTextAsset(Utility.GetScriptPath(path)).text, "\r\n").ToList<string>();
    
  2. CSV 转换执行节点
    执行节点类似于常用的行为树结构,我们都知道行为树有三种控制节点:选择、序列、并行,这游戏里用的最多的是序列节点和选择节点。

    上文中的代码调用了ProcessCommand方法,该方法即为开始执行节点的方法,同时其还负担将 CSV 转换为节点的任务。

    if (cmdData == null)
    {
        List<DialogCommand> currentLineCommands = this.getCurrentLineCommands();
        cmdData = this.GetCommandData(currentLineCommands);
    }
    

    getCurrentLineCommands中,调用了 String 转节点的控制方法:

    dialogCommands = DialogHelper.columnMapping(script[GlobalData.Instance.UserData.Dialog.LineNum]);
    

    columnMapping中,先读取columns.csv获得表头定义,然后根据表头定义将当前行的文字转换为叶子节点或控制节点:

    string[] strArrays = Utility.GetTextAsset(SystemConfig.COLUMN_MAPPING).text.Replace("\r\n", "").Split(new char[] { ',' });
    string[] strArrays1 = lineScript.Split(new char[] { ',' });
    

    首先将该行中每列的数据类型和文本数据使用一个DialogCommand存储:

    public class DialogCommand
    {
        public DialogCommandType CommandType;
        public string Content;
    }
    

    之后,将该行所有的DialogCommand根据类型合并到一个ScriptData中,这里的ScriptData是行为树节点的基类。

    public class ScriptData
    {
        public string Name;
        public string Text;
        public string CVPath;
        public Dictionary<string, string> translation = new Dictionary<string, string>();
        public List<ScriptAction> ActionList = new List<ScriptAction>();
    }
    

    转换过程很简单,掠过,这里说一下 ActionList 的意义。

    游戏若想正常执行,必须包含一些特殊的操作命令,例如选择对话框、确认对话框、更换背景、更换前景人物、播放背景音乐、设置标志位、跳转剧情等,它们一定程度上控制了游戏剧情逻辑,并且能够控制整棵行为树执行。为了让策划能直接配表,这些功能以自定义命令的形式写入 CSV,而读取时,将其转换成不同的 ScriptAction。

  3. 游戏主循环
    回到之前的ProcessCommand,这里在获取了当前步骤的ScriptData后,直接执行里面的 Action。

    foreach (ScriptAction scriptAction in cmdData.ActionList)
    {
        scriptAction.ExecuteCMD(this);
    }
    

    Action 有许多重写的子类,诸如 BGMAction、FadeBGAction 等,它们都是为了实现上文中说的操作命令。

    之后执行ShowDialog方法,该方法会设置名字、当前对话等 Galgame 必要信息,同时播放 CV 语音等。

    this.DialogBox.transform.Find("Name").Find("Text").gameObject.GetComponent<Text>().text = TextManager.ReplaceUserName(cmdData.Name);
    if (!string.IsNullOrEmpty(cmdData.CVPath))
    {
        GlobalSoundPlayer.Instance.PlayCastVoice(Utility.GetAudioClip(Utility.GetCastVoicePath(cmdData.CVPath)), false, 0f);
    }
    else if (GlobalSoundPlayer.Instance.isCVPlaying() && GlobalData.Instance.UserData.SysEnv.IsVoiceBreak)
    {
        GlobalSoundPlayer.Instance.StopCastVoice();
    }
    

    最后,在DialogManagerUpdate中,根据用户输入和系统状态,依次执行行为树即可。

到这里,整个游戏从策划配表到游戏逻辑就讲完了。

学到什么

多看别人的代码总能学到新东西,这次也不例外。

  • 在一些情况下,将所有用到的文件路径全部规范名称并放入一个公共类中,方便查找和使用,也利于多人协同开发。
  • 在一些情况下,按需生成节点,而不是开始时把节点全部生成,这样可以避免节点跳跃时浪费中间节点的情况。但这样也有坏处,每次生成新节点可能会导致运行态的程序卡顿,要综合判断是否使用。
梓喵出没博客(azimiao.com)版权所有,转载请注明链接:https://www.azimiao.com/6993.html
欢迎加入梓喵出没博客交流群:313732000

我来吐槽

*

*