【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开头的命令都会在列表中显示出来。

[CommandPrefix("GM.玩家.")]
public class PlayerEntity
{
}

2. 把变量或方法作为命令行,Command("命令名字", "命令用法说明"):

[Command("移动速度", "float类型,默认值10")]
private float moveSpeed = 10f;
[Command("添加敌人", "参数int,创建敌人个数")]
internal void AddEnemies(int v)

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

1. QuantumRegistry.RegisterObject()和QuantumRegistry.DeregisterObject()注册或取消注册,然后通过Command("命令名字", "命令描述", MonoTargetType.Registry)添加命令:

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工具交互界面:

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);// 获取当前TextEditorTextEditor 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;}}}
}

2. 将自定义的GM工具界面注册进GF Debuger窗口:

GF.Debugger.RegisterDebuggerWindow("GM", new GM.GMConsoleWindow());

效果:

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.rhkb.cn/news/406450.html

如若内容造成侵权/违法违规/事实不符,请联系长河编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

【蓝桥杯冲刺省一,省一看这些就够了-C++版本】蓝桥杯STL模板及相关练习题

蓝桥杯历年省赛真题 点击链接免费加入题单 STL map及其函数 map<key,value> 提供一对一的数据处理能力&#xff0c;由于这个特性&#xff0c;它完成有可能在我们处理一对一数据的时候&#xff0c;在编程上提供快速通道。map 中的第一个值称为关键字(key)&#xff0c;…

以前嗤之以鼻,现在逐字学习!缠论量化代码大公开!|邢不行

这是邢不行第 113 期量化小讲堂的分享 作者 | 邢不行、密斯锌硒 一千个人眼中有一千个哈姆雷特&#xff0c;我们只是尽可能的去量化我们理解的部分缠论的思路。 我们过往在文章中多次聊过技术指标&#xff0c;如MACD、KDJ等等&#xff0c;也聊过一些K线形态&#xff0c;如跳…

C语言 | Leetcode C语言题解之第354题俄罗斯套娃信封问题

题目&#xff1a; 题解&#xff1a; int cmp(int** a, int** b) {return (*a)[0] (*b)[0] ? (*b)[1] - (*a)[1] : (*a)[0] - (*b)[0]; }int maxEnvelopes(int** envelopes, int envelopesSize, int* envelopesColSize) {if (envelopesSize 0) {return 0;}qsort(envelopes, …

简历系统

TOC springboot0745简历系统 第1章 绪论 1.1背景及意义 随着社会的快速发展&#xff0c;计算机的影响是全面且深入的。人们生活水平的不断提高&#xff0c;日常生活中人们对简历系统方面的要求也在不断提高&#xff0c;需要工作的人数更是不断增加&#xff0c;使得简历系统…

论文解读:LONGWRITER: UNLEASHING 10,000+ WORD GENERATION FROM LONG CONTEXT LLMS

摘要 现象&#xff1a;当前的大预言模型可以接受超过100,000个tokens的输入&#xff0c;但是却难以生成超过2000个token的输出。 原因&#xff1a;监督微调过程(SFT)中看到的样本没有足够长的样本。 解决方法&#xff1a; Agent Write&#xff0c;可以将长任务分解为子任务&a…

Java CompletableFuture:你真的了解它吗?

文章目录 1 什么是 CompletableFuture&#xff1f;2 如何正确使用 CompletableFuture 对象&#xff1f;3 如何结合回调函数处理异步任务结果&#xff1f;4 如何组合并处理多个 CompletableFuture&#xff1f; 1 什么是 CompletableFuture&#xff1f; CompletableFuture 是 Ja…

Coze插件发布!PDF转Markdown功能便捷集成,打造你的专属智能体

近日&#xff0c;TextIn开发的PDF转Markdown插件正式上架Coze。 在扣子搜索“pdf转markdown”&#xff0c;或在Coze搜索“pdf2markdown” 即可找到插件&#xff0c;在你的专属智能体中便捷使用文档解析功能。 如果想测试解析插件在你需要的场景下表现如何&#xff0c;可以直接…

后端开发刷题 | 合并k个已排序的链表

描述 合并 k 个升序的链表并将结果作为一个升序的链表返回其头节点。 数据范围&#xff1a;节点总数 0≤n≤5000&#xff0c;每个节点的val满足 ∣val∣<1000 要求&#xff1a;时间复杂度 O(nlogn) 示例1 输入&#xff1a; [{1,2,3},{4,5,6,7}] 返回值&#xff1a; …

【数据结构】二叉树的深度理解

&#x1f36c;个人主页&#xff1a;Yanni.— &#x1f308;数据结构&#xff1a;Data Structure.​​​​​​ &#x1f382;C语言笔记&#xff1a;C Language Notes 前言 在之前学习了二叉树的基本概念&#xff0c;但二叉树有着更深入知识理解&#xff0c;这篇文章可以帮助大…

使用Obsidian实现Anki快速制卡

文章目录 前言准备双双启用遇到问题查看是什么问题解决问题 开始使用使用前的一些设置快速制卡 前言 我现在使用 Anki 的同时也使用 Obsidian&#xff0c;正好可以通过插件来让这两个十分好用的软件实现联动。 在 Obsidian 中实现 Anki 的快速制卡。 准备 首先要在这两个软…

原型制作 | 歌词与进度条的位置呼应

在之前的案例里面咱们做过了歌词滚动的效果&#xff0c;具体效果是这样的&#xff0c;点击播放按钮&#xff0c;歌词开始滚动&#xff1b;点击暂停按钮&#xff0c;歌词停止滚动&#xff0c;再次点击播放&#xff0c;歌词会继续滚动&#xff1b;一直播放直到结束&#xff0c;歌…

java常见面试题汇总

&#x1f30f;个人博客主页&#xff1a;意疏-CSDN博客 希望文章能够给到初学的你一些启发&#xff5e; 如果觉得文章对你有帮助的话&#xff0c;点赞 关注 收藏支持一下笔者吧&#xff5e; 阅读指南&#xff1a; 开篇说明一、封装 继承 多态1.封装2.继承3.多态 二、什么是重载…

【前端】VUE 在线运行 模拟器 通过字符串动态渲染页面 可以灵活使用

【前端】VUE2 在线运行 模拟器 通过字符串动态渲染页面 可以灵活使用 <template><div><!-- 这里是动态组件--><component :is"component"></component><!-- 这里是动态组件--><br /><br /><br />可…

Gitlab添加ssh秘钥download项目

1. 查看 、设置 git 用户名和邮箱 git config user.namegit config user.email git config --global user.name 用户名git config --global user.email 邮箱 2. 查看本地是否有ssh秘钥 秘钥默认放在C:\Users\用户\.ssh\目录下&#xff0c;进入这个目录&#xff0c;如果有 …

elasticsearch的高亮查询三种模式查询及可能存在的问题

目录 高亮查询使用介绍 高亮参数 三种分析器 可能存在的查询问题 fvh查询时出现StringIndexOutOfBoundsException越界 检索高亮不正确 参考文档 高亮查询使用介绍 Elasticsearch 的高亮&#xff08;highlight&#xff09;可以从搜索结果中的一个或多个字段中获取突出显…

社区维修平台

TOC springboot0751社区维修平台 第一章 绪 论 1.1背景及意义 系统管理也都将通过计算机进行整体智能化操作&#xff0c;对于社区维修平台所牵扯的管理及数据保存都是非常多的&#xff0c;例如住户管理、社区公告管理、维修工管理、维修订单管理、接单信息管理、订单信息管…

谁将解锁储能的未来?迈威通信邀您共探EESA储能展的秘密

九月金秋&#xff0c;硕果盈枝&#xff0c;迈威通信诚挚邀请您共赴一场科技盛宴——第三届EESA储能展&#xff0c;时间锁定9月2日至4日&#xff0c;地点&#xff1a;国家会展中心(上海)&#xff0c;一场关于绿色能源、智慧储能的梦幻之旅即将启航&#xff01; 您准备好迎接未来…

利用大型语言模型协作提升甲状腺结节超声诊断的一致性和准确性| 文献速递-基于深度学习的癌症风险预测与疾病预后应用

Title 题目 Collaborative Enhancement of Consistency and Accuracy in US Diagnosis of Thyroid Nodules Using Large Language Models 利用大型语言模型协作提升甲状腺结节超声诊断的一致性和准确性 Background 背景 Large language models (LLMs) hold substantial …

Redis内存管理

Redis使用Jemalloc(默认编译)来进行内存的管理&#xff1a; Jemalloc将内存分成许多不同的区域&#xff0c;每个区域成为arena&#xff0c;areana之间相互独立。Jemalloc通过创建多个arena来减少线程申请内存的操作冲突。一般arena数量为cpu数量*4. arena以chunk为单位向操作…

FPGA 综合笔记

仿真时阻塞赋值和非阻塞赋值 Use of Non-Blocking Assignment in Testbench : Verilog Use of Non-Blocking Assignment in Testbench : Verilog - Stack Overflow non-blocking assignment does not work as expected in Verilog non-blocking assignment does not work a…