某游戏分析之从CSV配表到程序运行逻辑全过程

偶然发现这篇文章三个月前就挂上 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
,一般情况下该文件包含打包时所有的内部脚本。
- 加载 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 循环读取。之后还有一些对于配置信息存档的处理代码,包括设置游戏分辨率、是否全屏、音量、版本迁移、文字滚动速度等,在此不做讨论。
在另外一个
DialogManager
的Start
方法中,其根据需要加载了要使用的剧情 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>();
- 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。
-
游戏主循环
回到之前的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(); }
最后,在
DialogManager
的Update
中,根据用户输入和系统状态,依次执行行为树即可。
到这里,整个游戏从策划配表到游戏逻辑就讲完了。
学到什么
多看别人的代码总能学到新东西,这次也不例外。
- 在一些情况下,将所有用到的文件路径全部规范名称并放入一个公共类中,方便查找和使用,也利于多人协同开发。
- 在一些情况下,按需生成节点,而不是开始时把节点全部生成,这样可以避免节点跳跃时浪费中间节点的情况。但这样也有坏处,每次生成新节点可能会导致运行态的程序卡顿,要综合判断是否使用。