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

栏目: 后端 · 发布时间: 5年前

内容简介:掐指一算,已经鸽了差不多一年了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组件的实现。我们就拿其中一个单独的图表出来说:

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

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

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

扩展UGUI

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

public class TrackGraph : Graphic
{
    protected override void OnPopulateMesh(VertexHelper vh)
    {}
}

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

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

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

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

下面是该组件的绘制流程

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

参考文献

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

尚未实现的想法

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

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

下期预告

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

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


以上就是本文的全部内容,希望本文的内容对大家的学习或者工作能带来一定的帮助,也希望大家多多支持 码农网

查看所有标签

猜你喜欢:

本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们

网络空间导论

网络空间导论

李良荣、方师师 / 复旦大学出版社 / 2018-6-1 / 38

在互联网蓬勃发展的今天,新闻传播学科该往何处去?在长达半个多世纪的深入研究后,李良荣教授及其团队给出了答案:从“小新闻”走向“大传播”,并撰写了这本开创性的教材:《网络空间导论》。 在本书中,互联网被定义为“空间”——持续演进的数字化现实。为了深刻把握网络空间的内涵,本书提供了六个维度的观察:技术应用、组织架构、政治经济、媒介文化、网络素养、安全治理,并以大胆且富有建设性的观点重新定义了新闻......一起来看看 《网络空间导论》 这本书的介绍吧!

在线进制转换器
在线进制转换器

各进制数互转换器

XML、JSON 在线转换
XML、JSON 在线转换

在线XML、JSON转换工具

RGB HSV 转换
RGB HSV 转换

RGB HSV 互转工具