给游戏加个好用酷炫的控制台

前言

掐指一算,已经鸽了差不多一年了hhh

之前做的游戏发售后就又回到成都重新开始打工了,由于公司项目实在太忙,自己的新想法也迟迟没有开动。最近在整理一些框架性质的东西,抽出一部分拿来分享记录一下。

控制台是干啥的

CS大家都玩过,在游戏里按下~键(就是键盘左上角数字1左边的那个),就会看到CS的控制台了,像这样:

fig.1

当然不只CS,很多游戏都有自己的控制台,而且大多都是用~键开启的。在控制台里,我们可以输入一系列交互式的指令,来实现某些特定的操作,比如输出调试信息啊,改变设置啊,作弊啊之类的。

如何自己做一个控制台

效果展示

国际惯例,我们的控制台也用~键开启,再按一次关闭,在游戏的任何阶段均可呼出。按下去之后就是见证奇迹(并不)的时刻。。

fig.2

可以看到,我们的控制台界面除了左侧的命令窗口,右边还有超过一半的区域显示了几个图表,我这里设置的是FPS,帧生成时间,内存使用情况三个,游戏的实时运行情况一目了然。而且就算关闭这个界面后,数据也还会一直更新,只是不渲染而已。

另外关于渲染效率,整个图表不含刻度文字只有一个DrawCall,而且从数据收集到网格重建渲染图表都是无GC的。CPU消耗最高的地方在重构网格,我这里设置的是最多显示200个历史样本,乘以3个图表,所以每次重建网格都会生成大量的顶点,如果在手机这种性能比较差的平台,可以根据实际情况适当减少更新频率和历史样本上限。

控制台部分

这部分没什么技术难度,就是当InputField控件触发提交事件时,处理命令字符串,把这个字符串分割成命令名cmd:string和参数列表paras:string[]。根据命令名找到对应的处理方法进行处理,并返回输出信息。

其中有一步要“根据命令名找到对应的处理方法”,我是这样设计的:

抽象结构

首先定义一个接口,规定所有的命令实现均需要实现这个接口

1
2
3
4
5
6
public interface IDebugCommand
{
string CommandDescription { get; }
void Execute(string[] paras, StringBuilder output);
void ShowHelp(StringBuilder output);
}
成员名作用
CommandDescription用于获取命令的简短描述
Execute()给定参数,执行该命令,output用于返回输出信息,将会显示到控制台中
ShowHelp()显示该命令的帮助信息,output用于返回输出信息

具体命令的实现

马上实现一个最基本的帮助命令试试:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
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");
}
}

这里把所有的提示性字符串都硬编码在代码里了,其实是不方便做本地化或者文本统一管理的,不过这里为了方便就先这样写。

注册和调用

然后我们需要在在游戏初始化的时候注册:

1
2
3
4
public Dictionary<string, IDebugCommand> DictCmdImplementations;
// ===以上是声明部分===
DictCmdImplementations.Add("help", new CmdHelp());
// more commands...

之后就可以根据命令名和参数列表来执行对应的实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
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)来标记需要注册的类,还附带了一个参数作为命令名作为字典的键方便注册。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public sealed class DebugCommandAttribute : Attribute
{
public string CmdName { get; }

public DebugCommandAttribute(string cmdName)
{
CmdName = cmdName;
}
}

/// ===分割线===

[DebugCommand("help")]
public class CmdHelp : IDebugCommand
{
// 略
}

然后把之前初始化的代码(就那些一句一句注册的)改掉,换成用反射来找到所有包含DebugCommandAttribute属性的类,实例化出一个对象并加入到字典中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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);
}

最终效果

fig.3

大概就是这样子,主要就是为了方便扩展了,需要增删改一项命令直接操作对应的文件就可以了,一定程度上减少了我们的维护成本。

图表部分

图表部分是我们这次的重点,其重中之重在于对UGUI的扩展,或者说自定义UI组件的实现。我们就拿其中一个单独的图表出来说:

fig.4

一个图表包含以下几个要素:

  1. 坐标轴
  2. 数据折线图
  3. 坐标轴上的数字

扩展UGUI

我们打算用UGUI的框架来扩展一个自定义的UI控件,首先创建一个新的类,并继承自GraphicGraphic是所有可以显示在屏幕上的UI元素的基类。

1
2
3
4
5
public class TrackGraph : Graphic
{
protected override void OnPopulateMesh(VertexHelper vh)
{}
}

从上面可以看到,这里的重点在于重写OnPopulateMesh方法。这个方法的作用是重建网格,比如说一个Image组件由至少两个三角形网格组成,通过设置合适的uv来实现各种填充效果。我们这个图表暂时还不涉及外部纹理,所以暂时还不需要操心uv的问题。

我们会用到VertexHelper类中的这个方法来添加网格:VertexHelper.AddUIVertexQuad(UIVertex[])。参数是四个元素的数组,每个元素表示一个顶点信息,其实从名字可以看出来这里就是添加了一个四边形。

为了绘制有一定宽度的线,我们可以把这些线都视为一个个的长条形状的四边形,每条线段绘制一个四边形。现在我们来讨论上面说的图表中的元素的前两个:坐标轴和数据折线图。如果分的再细一点,可以变成这样:

  1. 坐标轴(XY)线段
  2. 坐标轴尽头的箭头
  3. 坐标轴上的刻度
  4. 数据折线图

下面是该组件的绘制流程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
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就是我们折线图绘制的最终数据了,关于怎么去生成,我这里用倒推的顺序介绍如下几个步骤:

  • 最后一步是计算出这些坐标点
    • X(i)的值为1f * i / MaxSample,MaxSample是X轴上的最大样本数
    • 找到整组数据中Y值的最大值和最小值,就可以把实际y值映射到0到1的范围了
    • 另外,List中的每个元素表示数据点,x和y都是0到1范围的图表内的实际坐标,当然也没有做强限制,如果y值大于0仍然可以绘制出来,只不过会超出控件的尺寸范围,如果不想这样,可以把这个控件改成继承自MaskableGraphic,Unity就会进行裁剪,把内容限制在自己的框框里了。

附上部分核心代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
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
    • 每过一段时间添加一项原始统计数据,如果队列已满则移除最早的一个

附上部分核心代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
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

参考文献

本文参考了这篇博客,感谢博主的分享。

尚未实现的想法

  1. 按上箭头显示历史输入
  2. 按tab键自动补全
  3. 非一次性的交互式命令:现在的命令都是一次性输出结果的,我希望可以在输出过程中可以继续获取用户输入,根据输入来决定接下来的逻辑

最终目标:像操作shell一样通过命令来操作或者调试我们的游戏hhh

下期预告

我好想月更甚至半月更呀orz还不是懒癌+没人催更

不过最近公司项目终于没有那么忙了,之后的话先把更多自己总结的框架性实用工具挑几个有代表性的拿出来介绍一下吧,之后等具体游戏的架子搭好之后再开始写开发日志之类的东西