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());
效果: