内容简介:掐指一算,已经鸽了差不多一年了hhh之前做的游戏发售后就又回到成都重新开始打工了,由于公司项目实在太忙,自己的新想法也迟迟没有开动。最近在整理一些框架性质的东西,抽出一部分拿来分享记录一下。CS大家都玩过,在游戏里按下
前言
掐指一算,已经鸽了差不多一年了hhh
之前做的游戏发售后就又回到成都重新开始打工了,由于公司项目实在太忙,自己的新想法也迟迟没有开动。最近在整理一些框架性质的东西,抽出一部分拿来分享记录一下。
控制台是干啥的
CS大家都玩过,在游戏里按下 ~
键(就是键盘左上角数字1左边的那个),就会看到CS的控制台了,像这样:
当然不只CS,很多游戏都有自己的控制台,而且大多都是用 ~
键开启的。在控制台里,我们可以输入一系列交互式的指令,来实现某些特定的操作,比如输出调试信息啊,改变设置啊,作弊啊之类的。
如何自己做一个控制台
效果展示
国际惯例,我们的控制台也用 ~
键开启,再按一次关闭,在游戏的任何阶段均可呼出。按下去之后就是见证奇迹(并不)的时刻。。
可以看到,我们的控制台界面除了左侧的命令窗口,右边还有超过一半的区域显示了几个图表,我这里设置的是FPS,帧生成时间,内存使用情况三个,游戏的实时运行情况一目了然。而且就算关闭这个界面后,数据也还会一直更新,只是不渲染而已。
另外关于渲染效率,整个图表不含刻度文字只有一个DrawCall,而且从数据收集到网格重建渲染图表都是无GC的。CPU消耗最高的地方在重构网格,我这里设置的是最多显示200个历史样本,乘以3个图表,所以每次重建网格都会生成大量的顶点,如果在手机这种性能比较差的平台,可以根据实际情况适当减少更新频率和历史样本上限。
控制台部分
这部分没什么技术难度,就是当 InputField
控件触发提交事件时,处理命令字符串,把这个字符串分割成命令名 cmd:string
和参数列表 paras:string[]
。根据命令名找到对应的处理方法进行处理,并返回输出信息。
其中有一步要“根据命令名找到对应的处理方法”,我是这样设计的:
抽象结构
首先定义一个接口,规定所有的命令实现均需要实现这个接口
public interface IDebugCommand { string CommandDescription { get; } void Execute(string[] paras, StringBuilder output); void ShowHelp(StringBuilder output); }
成员名 | 作用 |
---|---|
CommandDescription | 用于获取命令的简短描述 |
Execute() | 给定参数,执行该命令,output用于返回输出信息,将会显示到控制台中 |
ShowHelp() | 显示该命令的帮助信息,output用于返回输出信息 |
具体命令的实现
马上实现一个最基本的帮助命令试试:
public class CmdHelp : IDebugCommand { public string CommandDescription => "显示帮助"; public void Execute(string[] paras, StringBuilder output) { var allCmd = DebugHelper.Instance.DictCmdImplementations; if (paras.Length == 0) { output.Append("所有支持的命令:\n"); foreach (var item in allCmd) { output.Append(item.Key); output.Append(':'); output.Append(item.Value.CommandDescription); output.Append('\n'); } } else { if (allCmd.TryGetValue(paras[0], out var cmd)) { cmd.ShowHelp(output); } else { output.Append($"找不到命令:{paras[0]}\n"); } } } public void ShowHelp(StringBuilder output) { output.Append("help: 显示帮助\n"); output.Append("当没有参数时,列出所有支持的命令\n"); output.Append("有参数时列出第一个参数表示的命令的帮助信息\n"); } }
这里把所有的提示性字符串都硬编码在代码里了,其实是不方便做本地化或者文本统一管理的,不过这里为了方便就先这样写。
注册和调用
然后我们需要在在游戏初始化的时候注册:
public Dictionary<string, IDebugCommand> DictCmdImplementations; // ===以上是声明部分=== DictCmdImplementations.Add("help", new CmdHelp()); // more commands...
之后就可以根据命令名和参数列表来执行对应的实现
private readonly StringBuilder _outputBuffer; // ===以上是声明部分=== var cmdStr = "这里是从输入框中获取的原始字符串"; var splited = cmdStr.Split(' '); if (splited.Length < 1) { Logger.LogWarning(DebugLogModule, "Invalid command"); return; } var cmd = splited[0].ToLower(); var paras = new string[splited.Length - 1]; Array.Copy(splited, 1, paras, 0, paras.Length); // 把原始字符串拆成命令名和参数列表 if (!DictCmdImplementations.TryGetValue(cmd, out var cmdImpl)) { holder.AddTextToConsole($"<color=red>Unknown Command:</color> {cmd}\n"); } else { _outputBuffer.Clear(); cmdImpl.Execute(paras, _outputBuffer); // 在UI上显示结果 holder.AddTextToConsole(_outputBuffer.ToString()); }
其中 holder
是对应的UI控制器类,这里UI的具体实现就不在本次的讨论范围了。
实现自动注册
然后我们发现一个问题,现在我们每写一个新的命令就必须不能忘记往代码里加 Add("apple", new Apple())
这样的注册语句,所以我们用反射来找到所有实现了 IDebugCommand
接口的类,一次性自动注册所有的命令,岂不美哉?
为了方便,这里新加一个属性(AttrIbute)来标记需要注册的类,还附带了一个参数作为命令名作为字典的键方便注册。
public sealed class DebugCommandAttribute : Attribute { public string CmdName { get; } public DebugCommandAttribute(string cmdName) { CmdName = cmdName; } } /// ===分割线=== [DebugCommand("help")] public class CmdHelp : IDebugCommand { // 略 }
然后把之前初始化的代码(就那些一句一句注册的)改掉,换成用反射来找到所有包含 DebugCommandAttribute
属性的类,实例化出一个对象并加入到字典中。
var allTypes = GetType().Assembly.GetTypes(); foreach (var item in allTypes) { var attr = item.GetCustomAttribute(typeof(DebugCommandAttribute)) as DebugCommandAttribute; if (attr == null) { continue; } var cmdObj = Activator.CreateInstance(item) as IDebugCommand; if (cmdObj == null) { return; } DictCmdImplementations.Add(attr.CmdName, cmdObj); }
最终效果
大概就是这样子,主要就是为了方便扩展了,需要增删改一项命令直接操作对应的文件就可以了,一定程度上减少了我们的维护成本。
图表部分
图表部分是我们这次的重点,其重中之重在于对UGUI的扩展,或者说自定义UI组件的实现。我们就拿其中一个单独的图表出来说:
一个图表包含以下几个要素:
- 坐标轴
- 数据折线图
- 坐标轴上的数字
扩展UGUI
我们打算用UGUI的框架来扩展一个自定义的UI控件,首先创建一个新的类,并继承自 Graphic
。 Graphic
是所有可以显示在屏幕上的UI元素的基类。
public class TrackGraph : Graphic { protected override void OnPopulateMesh(VertexHelper vh) {} }
从上面可以看到,这里的重点在于重写 OnPopulateMesh
方法。这个方法的作用是重建网格,比如说一个 Image
组件由至少两个三角形网格组成,通过设置合适的uv来实现各种填充效果。我们这个图表暂时还不涉及外部纹理,所以暂时还不需要操心uv的问题。
我们会用到 VertexHelper
类中的这个方法来添加网格: VertexHelper.AddUIVertexQuad(UIVertex[])
。参数是四个元素的数组,每个元素表示一个顶点信息,其实从名字可以看出来这里就是添加了一个四边形。
为了绘制有一定宽度的线,我们可以把这些线都视为一个个的长条形状的四边形,每条线段绘制一个四边形。现在我们来讨论上面说的图表中的元素的前两个:坐标轴和数据折线图。如果分的再细一点,可以变成这样:
- 坐标轴(XY)线段
- 坐标轴尽头的箭头
- 坐标轴上的刻度
- 数据折线图
下面是该组件的绘制流程
public List<Vector2> TrackData; // 数据源,后面有详细说明 private UIVertex[] _vCache = new UIVertex[4]; // 用于传给vh的参数,做一个缓存避免重复分配内存 protected override void OnPopulateMesh(VertexHelper vh) { vh.Clear(); // 清空之前的顶点信息 var size = GetPixelAdjustedRect(); // 计算控件经过各种RectTransform变形后的实际尺寸 // 下面绘制X轴 var left = Vector2.zero; var right = Vector2.right * size.width; GetLineQuad(ref _vCache, left, right, 5f); vh.AddUIVertexQuad(_vCache); // 箭头略,大致思路是,箭头就是一个三角形,这里在其中一条边上再添加一个顶点,视为四边形加入VertexHelper中 // 具体实现方式可参考参考文献中的文章 // Y轴略,同X轴的绘制方式 // Y轴上的刻度也是一条一条的线段,和绘制坐标轴的方法是一样的 // 下面绘制数据折线图 if (TrackData.Count > 1) { for (var i = 1; i < TrackData.Count; ++i) { // 计算每相邻两个顶点连成的线段,计算出四边形顶点信息,加入VertexHelper var start = new Vector2(TrackData[i - 1].x * width, TrackData[i - 1].y * height); var end = new Vector2(TrackData[i].x * width, TrackData[i].y * height); GetQuad(ref _vCache, start, end, 2f); vh.AddUIVertexQuad(_vCache); } } // 这里可以存在多个TrackData,实现在同一图表上的多条折线,像开头示例里左下角的图表 } private void GetLineQuad(ref UIVertex[] result, Vector2 start, Vector2 end, float lineWidth) { // 这个方法根据支线的两个点来计算出四边形的四个顶点 // 这里为节省篇幅修改了代码排版,并不规范 var dis = Vector2.Distance(startPos, endPos); var y = lineWidth * 0.5f * (endPos.x - startPos.x) / dis; var x = lineWidth * 0.5f * (endPos.y - startPos.y) / dis; if (y <= 0) y = -y; else x = -x; result[0].position = new Vector3(startPos.x + x, startPos.y + y); result[1].position = new Vector3(endPos.x + x, endPos.y + y); result[2].position = new Vector3(endPos.x - x, endPos.y - y); result[3].position = new Vector3(startPos.x - x, startPos.y - y); for (var i = 0; i < 4; i++) result[i].color = Color.white; }
数据源
上面代码中的TrackData就是我们折线图绘制的最终数据了,关于怎么去生成,我这里用倒推的顺序介绍如下几个步骤:
- 最后一步是计算出这些坐标点
1f * i / MaxSample MaskableGraphic
附上部分核心代码:
public static void CheckNewAndRepaint(IDataProvider track, TrackGraph graph) { // IDataProvider就是我们收集到的原始数据,下面会有介绍 // TrackGraph是我们上面介绍的自定义UI组件(折线图) // 这个方法需要在Update方法里每帧调用 if (!track.HasNewData) return; // 没有新数据的情况,不做处理 track.HasNewData = false; // 重置"有新数据"的标识 var minY = track.MinY; var maxY = track.MaxY; // 获取原始数据的最大和最小值 var samples = track.Samples; var points = graph.TrackData; if (points == null) { points = new List<Vector2>(track.MaxSample); graph.TrackData = points; } for (var i =0; i < samples.Count; ++i) { points.Add(new Vector2(1f * i / track.MaxSample, (samples[i] - minY) / (maxY - minY))); } graph.SetVerticesDirty(); }
这里要注意的是每次更新后需要手动调用 SetVerticesDirty()
方法设置脏标记,以便Unity重新调用我们上面的 OnPopulateMesh()
来生成图表的网格数据。
- 往前一步,生成原始数据
IDataProvider
附上部分核心代码:
public interface IDataProvider { string Name { get; } bool HasNewData { get; set; } uint MaxSample { get; } float MinY { get; } float MaxY { get; } // 接口实现者可以自由决定最大值和最小值,可以通过Samples里面的值来计算,也可以直接用常量 List<float> Samples { get; } void OnUpdateFrame(float dt); // 注意这里的dt,如果是Unity的Time.deltaTime,要记得乘上Time.timeScale才是真实时间 } // 这里用FPS的统计图表来举例 // 每0.5s计算一次此时段内的平均fps public class FpsTrack : IDataProvider { public string Name => "FpsTrack"; public bool HasNewData { get; set; } public uint MaxSample { get; set; } = 200; // 最大样本数,根据实际情况可调整 public float MinY => 0; public float MaxY => 60; // 这里使用固定的最大最小值 public List<float> Samples { get; } = new List<float>(); private float _timer; private readonly float _interval; private uint _sampleCount; private float _totalFps; public FpsTrack() { _timer = 0; _interval = 0.5f; _sampleCount = 0; _totalFps = 0; } public void OnUpdateFrame(float dt) { // 计算0.5s内的平均FPS _sampleCount += 1; _totalFps += 1 / dt; _timer += dt; if (_timer < _interval) return; _timer -= _interval; if (_sampleCount > 0) { Samples.Add(_totalFps / _sampleCount); if (Samples.Count > MaxSample) { Samples.RemoveAt(0); } } _sampleCount = 0; _totalFps = 0; HasNewData = true; } }
附加内容
至此我们已经实现了图表的大部分内容,还差文字部分,也就是Y轴上的刻度值,这个我暂时还没法跟折线图搞在同一个控件里,就在外面创建了几个Text控件来显示刻度,虽然有点简陋不过确实挺好用的233
参考文献
本文参考了 这篇博客 ,感谢博主的分享。
尚未实现的想法
- 按上箭头显示历史输入
- 按tab键自动补全
- 非一次性的交互式命令:现在的命令都是一次性输出结果的,我希望可以在输出过程中可以继续获取用户输入,根据输入来决定接下来的逻辑
最终目标:像操作 shell 一样通过命令来操作或者调试我们的游戏hhh
下期预告
我好想月更甚至半月更呀orz还不是懒癌+没人催更
不过最近公司项目终于没有那么忙了,之后的话先把更多自己总结的框架性实用 工具 挑几个有代表性的拿出来介绍一下吧,之后等具体游戏的架子搭好之后再开始写开发日志之类的东西
以上就是本文的全部内容,希望本文的内容对大家的学习或者工作能带来一定的帮助,也希望大家多多支持 码农网
猜你喜欢:- 让控制台支持 ANSI 转义序列,输出下划线、修改颜色或其他控制
- Cloudera Manager 之四(管理控制台)
- MLSQL 控制台预览版 推出啦
- frp 控制台监控dashboard配置
- 超好用的C#控制台应用模板
- 为何把日志打印到控制台很慢?
本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们。