游戏逻辑与指令化
任何游戏中都必然存在一定的行为逻辑,比如RPG游戏中与地图物体或人物的交互,动作游戏中使用技能击中敌人等等,这些逻辑通常情况下都会有自己的实现代码,即便是可视化工具制作,也会在背后自动生成相关的逻辑代码。
而这些逻辑代码会随着游戏规模以及内部机制复杂度的上升而快速膨胀,使用成熟的编程模式对其进行重构确实可以让代码结构变得清晰可读,也能方便后续的拓展,但这种做法并没有从实际上降低代码量。
比如说十分典型的指令模式,它以及它的变体很适合用来封装游戏中的一些固定逻辑,以动作游戏中的攻击逻辑为例,当玩家操作人物展开攻击时,整个攻击过程的逻辑代码至少会包含动画播放,攻击范围检测,目标类型判定,施加攻击效果,等待动画结束等步骤。
如果将这些逻辑代码全部写在人物的控制器中,那么随着人物动作和武器的增多,这些逻辑代码会迅速膨胀到难以阅读和维护的地步,想象一下鬼泣中人物那五花八门的武器动作以及攻击方式,将代码平铺在控制器中绝对是个糟糕的做法。
指令模式可以将一种攻击方式的所有逻辑代码封装起来,使用时只需要调用指令类的一个方法即可完成,当然这只是指令模式最理论化的用法,在实际场景下单一的方法调用可能无法完成功能需求,因此往往会考虑在指令模式的基础上进行修改和拓展。
一种可能的方法便是制定一套攻击行为框架,比如将人物的攻击行为拆分为前中后三个阶段,在此基础上进行串接和调度;然后依据这套框架来制作人物的每个攻击动作或者技能,控制器本身只需要根据玩家的输入指令获取到对应的行为指令对象进行调用即可。
实际开发项目中的指令模式会比上面的描述要复杂和多变,但无论如何,这样的做法终究只是对已有逻辑代码的重新整理和结构优化,在某些特定的需求场景下也会水土不服乃至寸步难行。
字节码和指令集
字节码通常情况下是指一条包含了执行代码,操作数据等信息在内的二进制代码,虽然在它之下有更为抽象的能直接被硬件接受和执行的机器码,但字节码本身往往也是不可读的。
过去的游戏开发者们在游戏系统中实现了类似的功能,即通过向游戏系统输入特定的代码来调用相应的逻辑,一条字节码可以表示调用一个单一的功能方法,比如说增加多少HP或者得到某种物品之类的,曾经一度在游戏中十分流行的“作弊码”在某种程度上也是这一类功能的直接体现,即“将系统功能映射到一条指令中”。
而这条指令本身究竟是字符串还是数字还是二进制代码就见仁见智了。
指令集的概念则源自中央处理器面对越来越复杂的硬件指令流程时做出的改进,处理器开发者们将许多硬件指令预先编译成集合,然后为中央处理器增加一个指令“翻译器”,由此便能将原本复杂冗长的机器指令代码映射成比较简短的二进制指令。
这个概念换到游戏系统中,与之前的字节码结合起来看,其实就是指结合多条字节码实现较为复杂的功能,并且为这个功能分配“标签”用于识别和执行。
结合这两样东西,一个虚拟机的雏形就已经出现,但这种虚拟机并不是非常方便使用,因为它本身太过特殊了,从字节码到指令集都是专门为一个游戏设计的,而且多数时候这种虚拟机运行的代码本身也被编进了游戏中或者需要玩家进行手动输入,因此其存在感往往并不突出。
在实践中,字节码虚拟机或者指令虚拟机往往都离不开对字节码和指令的识别与解析,最简单粗暴的方法自然是用Switch-Case分支逻辑来处理这些识别和解析过程,但这样的做法可能无法适应功能集比较庞大的虚拟机实现,因此往往还是需要采用类似指令模式这类编程模式来优化代码结构。
脚本虚拟机
脚本语言的出现是计算机编程技术的一大里程碑,它彻底改变了传统编程语言所必须的“编写,编译,连接,执行”四大流程,解释型运行方式能让脚本语言以非常便捷的方式被编写和使用。
早期的脚本语言常用于某些批量工作或者对计算机内的程序进行控制,但随着脚本语言本身以及解释器的发展,这种方便快捷的语言渐渐走上了更大舞台;脚本语言通常都有简单、易学、易用的特性,目的则是希望能让程序员快速完成程序的编写工作,并且直接部署运行。
在游戏中引入脚本是个老生常谈的话题,即便在单机游戏中存在脚本虚拟机的游戏也并不罕见,更别提网络游戏里随处可见的脚本语言代码了;手机游戏发展到今天,脚本语言在其中扮演的重要角色更是不必多言,但凡提到LUA立刻就想起热更新也可以说是手游业界带来的现象。
和过去的字节码或者指令虚拟机一样,脚本虚拟机最重要也是最核心的功能便是提供一套可以被外部使用和编辑的逻辑接口,让游戏的逻辑过程可以被更加灵活方便地配置。
若以一个RPG游戏为例,在不使用脚本虚拟机的时候,人物与场景内各个物体的交互功能往往需要分别编写处理代码,而且一旦编写完成后就无法更改,即便通过类型归纳的方式将相同的物体统的处理逻辑统一起来,在修改交互逻辑的时候依然会遭遇到各种各样的障碍。
而如果采用了脚本虚拟机的方案,那么只需要为每个物体绑定一个脚本文件,将具体的触发逻辑放入脚本中即可;通过虚拟机运行脚本代码来实现各个物体的交互功能,想修改任何物体的交互逻辑只需要找到对应的脚本文件进行修改即可,也可以非常方便地进行功能替换等操作。
至于具体用何种脚本语言其实并不重要,只要能达成需求即可。
如果考虑相对常见且成熟的LUA脚本虚拟机方案,那么使用功能相对更加完备的LUA脚本代码框架是不错的选择,但同时也要考虑到这些功能完备的LUA框架本身的复杂程度和学习门槛,如果仅仅是希望将LUA脚本代码作为一种连接外部脚本和游戏系统逻辑的工具,那么并不一定需要用到那么完备的框架。
比如可以使用LuaInterface库作为虚拟机的核心组件,自行封装一个简单可用的LUA虚拟机,该开源库的地址为 https://github.com/Jakosa/LuaInterface
获取到所有代码之后,用Visual Studio打开解决方案并进行构建即可,构建结束后会得到两个DLL文件,名称分别为KopiLua和LuaInterface,前者为C#语言编写的LUA虚拟机核心,后者则是对该虚拟机的封装。
由于LuaInterface完全使用C#语言编写,因此生成DLL之后可以被直接导入Unity中用于不同版本的游戏,而不会像lualib库那样需要根据不同平台使用不同的库文件。
完成DLL导入之后便可以直接在项目中使用
using LuaInterface; public Lua machine; machine = new Lua(); // 新建Lua虚拟机实例 machine.DoString("Lua Code"); // 执行Lua代码 machine.DoFile("filePath"); // 执行Lua文件
当然这样的做法只能简单地运行一些LUA代码,如果想使用LUA代码调用C#中的方法,那么必须先将指定的类加载到LUA虚拟机里,用法如下
machine.DoString("luanet.load_assembly('Assembly-CSharp')"); // 让Lua虚拟机加载程序集 machine.DoString("Class = luanet.import_type('ClassPath')"); // 获取指定类型 machine.DoString("API = luanet.import_type('ApiPath')"); // 获取指定静态类 machine.DoString("instance = Class()"); // 创建对象 machine.DoString("API.Function()"); // 调用静态方法
为了方便使用这套脚本虚拟机,可以对LuaInterface进行再次封装,代码如下
/// <summary> /// 基于LuaInterface封装的Lua虚拟机 /// </summary> public class LuaMachine : MonoBehaviour { public const string NAME_MACHINE_MAIN = "Main"; public static int EXEC_BLOCK_PER_FRAME = 32; public static LuaMachine Machine { get; private set; } public Lua Main { get; private set; } public bool Running { get; set; } protected Dictionary<string, LuaState> machineStates; protected HashSet<string> assembliesToLoad; protected HashSet<TypeName> typesToLoad; protected List<string> customPreloadCodeLines; protected StringBuilder preloadCodeBuilder; protected bool preloadCodeChanged = true; protected Action<LuaExecuteBlock> blockCompiler; protected Queue<LuaExecuteBlock> executeBlockQueue; public Lua this[string name] { get { return machineStates[name].state; } } private void Awake() { preloadCodeBuilder = new StringBuilder(); machineStates = new Dictionary<string, LuaState>(); Main = new Lua(); Machine = this; machineStates[NAME_MACHINE_MAIN] = new LuaState(Main); assembliesToLoad = new HashSet<string>(); assembliesToLoad.Add("Assembly-CSharp"); typesToLoad = new HashSet<TypeName>(); customPreloadCodeLines = new List<string>(); executeBlockQueue = new Queue<LuaExecuteBlock>(); OnAwake(); } protected virtual void OnAwake() { } public virtual void LoadAssembly(string name) { assembliesToLoad.Add(name); preloadCodeChanged = true; } public virtual void LoadType(string path) { int pIndex = path.LastIndexOf('.'); string name = path.Substring(pIndex + 1); typesToLoad.Add(new TypeName(path, name)); preloadCodeChanged = true; } public virtual void LoadType(string path, string name) { typesToLoad.Add(new TypeName(path, name)); preloadCodeChanged = true; } public virtual void AppendCustomPreloadCode(string line) { customPreloadCodeLines.Add(line); preloadCodeChanged = true; } public virtual void CreateLuaMachineState(string name) { if (!string.IsNullOrEmpty(name)) { if (!machineStates.ContainsKey(name)) { machineStates[name] = new LuaState(new Lua()); } } } public virtual void Initialize(string name = NAME_MACHINE_MAIN) { CompilePreloadCode(); if (!string.IsNullOrEmpty(name)) { if (machineStates.ContainsKey(name)) { if (!machineStates[name].isPreload) { machineStates[name].state.DoString(preloadCodeBuilder.ToString()); machineStates[name].isPreload = true; } } } } public virtual void ResetMachine(string name = NAME_MACHINE_MAIN) { CompilePreloadCode(); if (!string.IsNullOrEmpty(name)) { if (machineStates.ContainsKey(name)) { machineStates[name].state.Dispose(); machineStates[name].state = new Lua(); machineStates[name].state.DoString(preloadCodeBuilder.ToString()); machineStates[name].isPreload = true; } } } protected virtual void CompilePreloadCode() { if (preloadCodeChanged) { preloadCodeBuilder.Clear(); foreach (string asm in assembliesToLoad) { preloadCodeBuilder.AppendLine($"luanet.load_assembly('{asm}')"); } // --- 程序集加载完毕 foreach (TypeName type in typesToLoad) { preloadCodeBuilder.AppendLine($"{type.typeName} = luanet.import_type('{type.typePath}')"); } // --- 类型加载完毕 foreach (string line in customPreloadCodeLines) { preloadCodeBuilder.AppendLine(line); } preloadCodeChanged = false; } } private void Start() { Running = true; OnStart(); } protected virtual void OnStart() { } private void Update() { if (Running) { int execBlockCount = 0; while (executeBlockQueue.Count > 0) { try { LuaExecuteBlock block = executeBlockQueue.Dequeue(); if (machineStates.ContainsKey(block.machineName)) { Lua l = machineStates[block.machineName].state; object[] ret; if (block.isFile) { ret = l.DoFile(block.blockContent); } else { ret = l.DoString(block.blockContent); } block.callback?.Invoke(ret); } } catch (Exception e) { Debug.LogError($"LuaMachineError: {e.Message}\n{e.StackTrace}"); } execBlockCount++; if (execBlockCount >= EXEC_BLOCK_PER_FRAME) { break; } } } OnUpdate(); } protected virtual void OnUpdate() { } public virtual void ExecuteLuaFile(string filePath, Action<object[]> callback = null) { LuaExecuteBlock block = new LuaExecuteBlock(true); block.blockContent = filePath; block.callback = callback; blockCompiler?.Invoke(block); executeBlockQueue.Enqueue(block); } public virtual void ExecuteLuaCode(string codeBlock, Action<object[]> callback = null) { LuaExecuteBlock block = new LuaExecuteBlock(); block.blockContent = codeBlock; block.callback = callback; blockCompiler?.Invoke(block); executeBlockQueue.Enqueue(block); } public virtual void ExecuteLuaCode(string[] lines, Action<object[]> callback = null) { StringBuilder codeBuilder = new StringBuilder(); LuaExecuteBlock block = new LuaExecuteBlock(); foreach (string l in lines) { codeBuilder.AppendLine(l); } block.blockContent = codeBuilder.ToString(); block.callback = callback; blockCompiler?.Invoke(block); executeBlockQueue.Enqueue(block); } public virtual void ExecuteLuaCode(List<string> lines, Action<object[]> callback = null) { StringBuilder codeBuilder = new StringBuilder(); LuaExecuteBlock block = new LuaExecuteBlock(); foreach (string l in lines) { codeBuilder.AppendLine(l); } block.blockContent = codeBuilder.ToString(); block.callback = callback; blockCompiler?.Invoke(block); executeBlockQueue.Enqueue(block); } /// <summary> /// Lua虚拟机的结构封装,提供一个预加载的标志 /// </summary> public class LuaState { public Lua state; public bool isPreload = false; public LuaState(Lua l) { state = l; } } /// <summary> /// Lua执行块,提供一个统一的编译标准模块 /// </summary> public class LuaExecuteBlock { public bool isFile; public string machineName; public string blockContent; public Action<object[]> callback; public LuaExecuteBlock(bool file = false) { isFile = file; machineName = NAME_MACHINE_MAIN; } } protected struct TypeName { public string typePath; public string typeName; public TypeName(string path, string name) { typePath = path; typeName = name; } } }
封装好之后LUA虚拟机会相对更加方便使用,但LuaInterface的功能支持比较有限,因此它并不能很好地适配一些C#的数据结构,能在两边畅通无阻的数据通常而言只有数字和字符串,复杂的数据结构只能通过LuaTable这种键值对类型的数据结构来传输。
总结
无论是字节码虚拟机也好,指令虚拟机也好,亦或者脚本虚拟机也好,使用它们的根本目的还是在于构建一条能从游戏系统外部干涉游戏本身执行逻辑的通道,使得游戏系统本身可以在不进行修改和编译的前提下表现不同的逻辑过程。
虽然说手机网络游戏利用了虚拟机的这一特点使其成为了代码热更新的主流解决方案,但脚本虚拟机本身绝不应当只为热更新而存在,它对游戏的开发以及功能拓展有着更为深刻的影响。
暂无关于此日志的评论。