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);
// 获取当前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;
}
}
}
}
2. 将自定义的GM工具界面注册进GF Debuger窗口:
GF.Debugger.RegisterDebuggerWindow("GM", new GM.GMConsoleWindow());
效果: