【Unity】通用GM QA工具 运行时数值修改 命令行 测试工具

GM工具使用:

GM工具通常用于游戏运行时修改数值(加钱/血量)、解锁关卡等,用于快速无死角测试游戏。一个通用型GM工具对于游戏项目是非常实用且必要的,但通用不能向易用妥协,纯命令行GM门槛太高,对QA不友好。

这类运行时命令行工具实现原理很简单,主要是通过给变量或方法添加Attribute标识,然后通过反射获取被标记的变量或方法,命令触发通过反射为变量赋值或Invoke方法。

此类工具免费或付费的已经泛滥了,不推荐浪费时间重复造轮子。

  1. 免费开源的Log显示工具,也嵌入了命令行功能。由于GF有更好用的Debuger窗口了,所以没选择它:https://github.com/yasirkula/UnityIngameDebugConsole

  2. Quantum Console, 收费,AssetStore上好评最多,但强行绑定了一个UGUI界面,无解耦。这里我是想直接扩展进GF Debuger窗口,方便使用,因此需要修改插件源码:Quantum Console | Utilities Tools | Unity Asset Store

感兴趣的话直接AssetStore搜"command console",免费的也有很多。

我就不浪费时间筛选,直接选择购买好评较多的Quantum Console进行整改。

Quantum Console用法:

Quantum C默认只对继承MonoBehavior的脚本有效,应该是因为需要反射获取所有类型速度太慢,初始化时会卡顿。

对于继承自MonoBehavior的脚本直接通过以下Attribute标记命令即可:

  1. 命令行前缀,CommandPrefix("GM."):

相当于给命令行分组,比如把所有命令行标记个前缀叫"GM.", 那么输入"GM"时所有GM开头的命令都会在列表中显示出来。

cs 复制代码
[CommandPrefix("GM.玩家.")]
public class PlayerEntity
{
}
  1. 把变量或方法作为命令行,Command("命令名字", "命令用法说明"):
cs 复制代码
[Command("移动速度", "float类型,默认值10")]
private float moveSpeed = 10f;
cs 复制代码
[Command("添加敌人", "参数int,创建敌人个数")]
internal void AddEnemies(int v)

对于非MonoBehavior脚本需要手动调用注册命令接口,将该类型添加到需要反射扫描的名单里:

  1. QuantumRegistry.RegisterObject()和QuantumRegistry.DeregisterObject()注册或取消注册,然后通过Command("命令名字", "命令描述", MonoTargetType.Registry)添加命令:
cs 复制代码
public class PlayerDataModel : DataModelBase
{
    protected override void OnCreate(RefParams userdata)
    {
        QuantumRegistry.RegisterObject(this);
    }
    protected override void OnRelease()
    {
        QuantumRegistry.DeregisterObject(this);
    }
    [Command("金币", "玩家金币数量", MonoTargetType.Registry)]
    public int Coins;
}

将Quantum C扩展进GF:

由于GF解耦做得非常好了,我们只需要自定义类实现GameFramework.Debugger.IDebuggerWindow接口就可以写自己的GUI界面和功能了。

  1. 扩展Debuger菜单栏,编写GM工具交互界面:
cs 复制代码
using System.Collections.Generic;
using UnityEngine;
using GameFramework;
using GameFramework.Debugger;
using System;
using Cysharp.Threading.Tasks;
using GM.Utilities;
using System.Threading.Tasks;
using System.Reflection;
using System.Linq;
namespace GM
{
    public class GMConsoleWindow : IDebuggerWindow
    {
        const string LogCommand = "{0}";
        const string LogSuccess = "<color=#2BD988>{0}</color>";
        const string LogFailed = "<color=#F22E2E>{0}</color>";
        const string InputFieldCtrlID = "Input";
        private int m_MaxLine = 100;
        private int m_MaxRecordInputHistory = 30;
        private Queue<GMLogNode> m_LogNodes;
        private LinkedList<string> m_InputHistoryList;
        private LinkedListNode<string> m_CurrentHistory = null;
        string m_InputText;
        string m_PreInputText;
        bool m_InputFocused;
        bool m_InputChanged;
        Vector2 m_ScrollPosition = Vector2.zero;
        Vector2 m_FilterScrollPosition = Vector2.zero;
        SuggestionStack m_CommandsFilter;
        SuggestorOptions m_FilterOptions;
        Rect inputRect = default;
        bool m_LogAppend;
        bool m_MoveCursorToEnd;
        GUIStyle m_CommandsFilterBtStyle;
        private readonly Type m_VoidTaskType = typeof(Task<>).MakeGenericType(Type.GetType("System.Threading.Tasks.VoidTaskResult"));
        private List<System.Threading.Tasks.Task> m_CurrentTasks;
        private List<IEnumerator<ICommandAction>> m_CurrentActions;

        public void Initialize(params object[] args)
        {
            if (!QuantumConsoleProcessor.TableGenerated)
            {
                QuantumConsoleProcessor.GenerateCommandTable(true);
            }
            m_InputHistoryList = new LinkedList<string>();
            m_LogNodes = new Queue<GMLogNode>();
            m_CurrentTasks = new List<System.Threading.Tasks.Task>();
            m_CurrentActions = new List<IEnumerator<ICommandAction>>();
            m_CommandsFilter = new SuggestionStack();
            m_FilterOptions = new SuggestorOptions()
            {
                CaseSensitive = false,
                CollapseOverloads = true,
                Fuzzy = true,
            };
        }

        public void OnDraw()
        {
            if (m_CommandsFilterBtStyle == null)
            {
                m_CommandsFilterBtStyle = new GUIStyle(GUI.skin.button)
                {
                    alignment = TextAnchor.MiddleLeft
                };
            }
            GUILayout.BeginVertical();
            {
                m_ScrollPosition = GUILayout.BeginScrollView(m_ScrollPosition, "box");
                {
                    foreach (var logNode in m_LogNodes)
                    {
                        GUILayout.Label(logNode.LogMessage);
                    }
                    GUILayout.EndScrollView();
                }
                if (m_LogAppend)
                {
                    m_LogAppend = false;
                    m_ScrollPosition = new Vector2(0, float.MaxValue);
                }

                GUILayout.BeginHorizontal();
                {
                    GUI.enabled = QuantumConsoleProcessor.TableGenerated;
                    GUI.SetNextControlName(InputFieldCtrlID);
                    m_InputText = GUILayout.TextField(m_InputText);
                    if (Event.current.type == EventType.Repaint)
                    {
                        inputRect = GUILayoutUtility.GetLastRect();
                        if (m_MoveCursorToEnd)
                        {
                            m_MoveCursorToEnd = false;
                            MoveInputCursorToEnd();
                        }
                    }

                    m_InputFocused = (GUI.GetNameOfFocusedControl() == InputFieldCtrlID);
                    m_InputChanged = m_InputText != m_PreInputText;
                    if (m_InputChanged)
                    {
                        m_PreInputText = m_InputText;
                        m_CommandsFilter.UpdateStack(m_InputText, m_FilterOptions);
                    }
                    if (GUILayout.Button("Execute", GUILayout.Width(60)))
                    {
                        ExecuteCommand(m_InputText);
                    }
                    if (GUILayout.Button("Clear", GUILayout.Width(60)))
                    {
                        ClearLogs();
                    }
                    GUILayout.EndHorizontal();
                }
                GUILayout.EndVertical();
                if (m_InputFocused && m_CommandsFilter.TopmostSuggestionSet != null)
                {
                    if (Event.current.type == EventType.Repaint)
                    {
                        float maxHeight = GUILayoutUtility.GetLastRect().height - inputRect.height - 5f;
                        inputRect.height = Mathf.Clamp(m_CommandsFilter.TopmostSuggestionSet.Suggestions.Count * 30, maxHeight * 0.5f, maxHeight);
                        inputRect.position -= Vector2.up * (inputRect.height + 5f);
                    }
                    if (m_InputChanged)
                    {
                        m_FilterScrollPosition = Vector2.zero;
                    }
                    GUILayout.BeginArea(inputRect);
                    m_FilterScrollPosition = GUILayout.BeginScrollView(m_FilterScrollPosition, "box", GUILayout.ExpandWidth(true), GUILayout.ExpandHeight(true));
                    {
                        GUILayout.BeginVertical(GUILayout.ExpandHeight(true));
                        {
                            foreach (var item in m_CommandsFilter.TopmostSuggestionSet.Suggestions)
                            {
                                if (GUILayout.Button(item.FullSignature, m_CommandsFilterBtStyle))
                                {
                                    m_MoveCursorToEnd = true;
                                    var fragments = m_InputText.Split(' ');
                                    if (fragments.Length >= 2)
                                    {
                                        m_InputText = string.Empty;
                                        for (int i = 0; i < fragments.Length - 1; i++)
                                        {
                                            m_InputText = Utility.Text.Format("{0}{1}{2}", m_InputText, i == 0 ? string.Empty : " ", fragments[i]);
                                        }
                                        m_InputText = Utility.Text.Format("{0} {1}", m_InputText, item.PrimarySignature);
                                    }
                                    else
                                    {
                                        m_InputText = item.PrimarySignature;
                                    }
                                }
                            }
                            GUILayout.EndVertical();
                        }
                        GUILayout.EndScrollView();
                    }
                    GUILayout.EndArea();
                }
            }
        }
        /// <summary>
        /// 输入框游标移动到尾部
        /// </summary>
        private void MoveInputCursorToEnd()
        {
            GUI.FocusControl(InputFieldCtrlID);

            // 获取当前TextEditor
            TextEditor editor = (TextEditor)GUIUtility.GetStateObject(typeof(TextEditor), GUIUtility.keyboardControl);
            if (editor != null)
            {
                editor.cursorIndex = m_InputText.Length;
                editor.selectIndex = m_InputText.Length;
            }
        }

        public void OnEnter()
        {
            QuantumRegistry.RegisterObject<GMConsoleWindow>(this);
        }

        public void OnLeave()
        {
            QuantumRegistry.DeregisterObject<GMConsoleWindow>(this);
        }

        public void OnUpdate(float elapseSeconds, float realElapseSeconds)
        {
            if (m_InputFocused)
            {
                if (Input.GetKeyDown(KeyCode.Return) || Input.GetKeyDown(KeyCode.KeypadEnter))
                    ExecuteCommand(m_InputText);

                if (Input.GetKeyDown(KeyCode.DownArrow))
                {
                    SelectInputHistory(false);
                }
                else if (Input.GetKeyDown(KeyCode.UpArrow))
                {
                    SelectInputHistory(true);
                }
            }

            TasksUpdate();
            ActionsUpdate();
        }

        public void Shutdown()
        {

        }
        private void SelectInputHistory(bool upOrdown)
        {
            if (m_InputHistoryList.Count == 0) return;
            m_MoveCursorToEnd = true;
            if (upOrdown)
            {
                if (m_CurrentHistory == null || m_CurrentHistory.Previous == null)
                {
                    m_InputText = m_InputHistoryList.Last.Value;
                    m_CurrentHistory = m_InputHistoryList.Last;
                    return;
                }
                m_InputText = m_CurrentHistory.Previous.Value;
                m_CurrentHistory = m_CurrentHistory.Previous;
            }
            else
            {
                if (m_CurrentHistory == null || m_CurrentHistory.Next == null)
                {
                    m_InputText = m_InputHistoryList.First.Value;
                    m_CurrentHistory = m_InputHistoryList.First;
                    return;
                }
                m_InputText = m_CurrentHistory.Next.Value;
                m_CurrentHistory = m_CurrentHistory.Next;
            }
        }
        private void AppendLog(GMLogType logType, string logMessage)
        {
            m_LogNodes.Enqueue(GMLogNode.Create(logType, logMessage));
            while (m_LogNodes.Count > m_MaxLine)
            {
                ReferencePool.Release(m_LogNodes.Dequeue());
            }
            m_LogAppend = true;
        }
        [Command("clear", "清空GM日志", MonoTargetType.Registry)]
        private void ClearLogs()
        {
            m_LogNodes.Clear();
            m_ScrollPosition = Vector2.zero;
        }

        private void ExecuteCommand(string cmd, bool recordHistory = true)
        {
            if (string.IsNullOrWhiteSpace(cmd)) return;
            if (recordHistory) RecordInputHistory(cmd);
            AppendLog(GMLogType.Command, cmd);
            m_InputText = string.Empty;

            try
            {
                var commandResult = QuantumConsoleProcessor.InvokeCommand(cmd);
                if (commandResult != null)
                {
                    if (commandResult is IEnumerator<ICommandAction> enumeratorTp)
                    {
                        m_CurrentActions.Add(enumeratorTp);
                        ActionsUpdate();
                    }
                    else if (commandResult is IEnumerable<ICommandAction> enumerableTp)
                    {
                        m_CurrentActions.Add(enumerableTp.GetEnumerator());
                        ActionsUpdate();
                    }
                    else if (commandResult is UniTask task)
                    {
                        m_CurrentTasks.Add(task.AsTask());
                    }
                    else if (commandResult.GetType().Name == "UniTask`1")
                    {
                        var asTaskGenericMethod = typeof(UniTaskExtensions).GetMethods(BindingFlags.Public | BindingFlags.Static).FirstOrDefault(item => item.Name == "AsTask" && item.IsGenericMethod);
                        Type uniTaskType = commandResult.GetType();
                        Type genericArgument = uniTaskType.GetGenericArguments()[0];
                        MethodInfo genericMethod = asTaskGenericMethod.MakeGenericMethod(genericArgument);
                        Task taskT = (Task)genericMethod.Invoke(null, new object[] { commandResult });
                        m_CurrentTasks.Add(taskT);
                    }
                    else
                    {
                        var resultType = commandResult.GetType();
                        if (resultType == typeof(string) || resultType.IsPrimitive)
                        {
                            AppendLog(GMLogType.Success, commandResult.ToString());
                        }
                        else
                        {
                            AppendLog(GMLogType.Success, Utility.Json.ToJson(commandResult));
                        }
                    }
                }
            }
            catch (System.Reflection.TargetInvocationException e)
            {
                AppendLog(GMLogType.Failed, e.Message);
            }
            catch (Exception e)
            {
                AppendLog(GMLogType.Failed, e.Message);
            }

        }
        private void RecordInputHistory(string cmd)
        {
            if (m_InputHistoryList.Count > 0 && m_InputHistoryList.First.Value == cmd) return;
            m_InputHistoryList.AddFirst(cmd);
            m_CurrentHistory = m_InputHistoryList.Last;
            while (m_InputHistoryList.Count > m_MaxRecordInputHistory)
            {
                m_InputHistoryList.RemoveLast();
            }
        }
        private void TasksUpdate()
        {
            for (int i = m_CurrentTasks.Count - 1; i >= 0; i--)
            {
                if (m_CurrentTasks[i].IsCompleted)
                {
                    if (m_CurrentTasks[i].IsFaulted)
                    {
                        foreach (Exception e in m_CurrentTasks[i].Exception.InnerExceptions)
                        {
                            AppendLog(GMLogType.Failed, e.Message);
                        }
                    }
                    else
                    {
                        Type taskType = m_CurrentTasks[i].GetType();
                        if (taskType.IsGenericTypeOf(typeof(Task<>)) && !m_VoidTaskType.IsAssignableFrom(taskType))
                        {
                            System.Reflection.PropertyInfo resultProperty = m_CurrentTasks[i].GetType().GetProperty("Result");
                            object result = resultProperty.GetValue(m_CurrentTasks[i]);
                            string log = Utility.Json.ToJson(result);
                            AppendLog(GMLogType.Success, log);
                        }
                    }

                    m_CurrentTasks.RemoveAt(i);
                }
            }
        }
        private void ActionsUpdate()
        {
            for (int i = m_CurrentActions.Count - 1; i >= 0; i--)
            {
                IEnumerator<ICommandAction> action = m_CurrentActions[i];

                try
                {
                    if (action.Execute() != ActionState.Running)
                    {
                        m_CurrentActions.RemoveAt(i);
                    }
                }
                catch (Exception e)
                {
                    m_CurrentActions.RemoveAt(i);
                    AppendLog(GMLogType.Failed, e.Message);
                    break;
                }
            }
        }
        private enum GMLogType
        {
            Command,
            Success,
            Failed
        }
        /// <summary>
        /// 日志记录结点。
        /// </summary>
        private sealed class GMLogNode : IReference
        {
            private GMLogType m_LogType;
            private string m_LogMessage;

            /// <summary>
            /// 初始化日志记录结点的新实例。
            /// </summary>
            public GMLogNode()
            {
                m_LogType = GMLogType.Failed;
                m_LogMessage = null;
            }

            /// <summary>
            /// 获取日志类型。
            /// </summary>
            public GMLogType LogType
            {
                get
                {
                    return m_LogType;
                }
            }

            /// <summary>
            /// 获取日志内容。
            /// </summary>
            public string LogMessage
            {
                get
                {
                    return m_LogMessage;
                }
            }

            /// <summary>
            /// 创建日志记录结点。
            /// </summary>
            /// <param name="logType">日志类型。</param>
            /// <param name="logMessage">日志内容。</param>
            /// <returns>创建的日志记录结点。</returns>
            public static GMLogNode Create(GMLogType logType, string logMessage)
            {
                GMLogNode logNode = ReferencePool.Acquire<GMLogNode>();
                logNode.m_LogType = logType;
                switch (logType)
                {
                    case GMLogType.Success:
                        logNode.m_LogMessage = Utility.Text.Format(LogSuccess, logMessage);
                        break;
                    case GMLogType.Failed:
                        logNode.m_LogMessage = Utility.Text.Format(LogFailed, logMessage);
                        break;
                    default:
                        logNode.m_LogMessage = Utility.Text.Format(LogCommand, logMessage);
                        break;
                }
                return logNode;
            }

            /// <summary>
            /// 清理日志记录结点。
            /// </summary>
            public void Clear()
            {
                m_LogType = GMLogType.Failed;
                m_LogMessage = null;
            }
        }
    }
}
  1. 将自定义的GM工具界面注册进GF Debuger窗口:
cs 复制代码
GF.Debugger.RegisterDebuggerWindow("GM", new GM.GMConsoleWindow());

效果:

相关推荐
向宇it10 小时前
【unity小技巧】unity 什么是反射?反射的作用?反射的使用场景?反射的缺点?常用的反射操作?反射常见示例
开发语言·游戏·unity·c#·游戏引擎
Heaphaestus,RC12 小时前
【Unity3D】获取 GameObject 的完整层级结构
unity·c#
芋芋qwq12 小时前
Unity UI射线检测 道具拖拽
ui·unity·游戏引擎
tealcwu12 小时前
【Unity服务】关于Unity LevelPlay的基本情况
unity·游戏引擎
大眼睛姑娘15 小时前
Unity3d场景童话梦幻卡通Q版城镇建筑植物山石3D模型游戏美术素材
unity·游戏美术
鹿野素材屋19 小时前
Unity Dots下的动画合批工具:GPU ECS Animation Baker
unity·游戏引擎
小春熙子1 天前
Unity图形学之着色器之间传递参数
unity·游戏引擎·技术美术·着色器
虾球xz1 天前
游戏引擎学习第15天
学习·游戏引擎
Java Fans1 天前
在Unity中实现电梯升降功能的完整指南
unity·游戏引擎
GrimRaider2 天前
[Unity]TileMap开发,TileMap地图缝隙问题
unity·游戏引擎·tilemap