目录
前言
最近在将Godot项目重写至Unity,其中有一个功能是类似于CMD控制台的功能。同样的网上搜罗后也没有发现相关实现。拙笔一篇。
功能需求
- 我希望控制台是一个单独的元素,可以被快速的添加到任何需要的地方。
- 我希望可以实现所有基础功能:
- 命令反馈输出
- prompt提示符
- 命令历史/提示
- 自定义颜色/格式
窗体用到了上一章的 Unity UI Tookite:实现窗体模板
基础逻辑实现——输入输出分离
在开始之前,需明确:
- 用户发送出的文本和系统输出的文本绝不能再次被用户编辑。
- 用户不能干涉系统的输出过程。
由于在UI Tookite中实现文本输入相当复杂,本元素将基于Input Field
实现。
首先观察InputField
的结构,发现用户的文本输入由内部的一个TextElement
处理。
使用代码对InputField
元素进行布局编辑,查看功能是否依然正常:
自定义元素TerminalField继承自VisualElement
TerminalField(){
//生成一个inputField
var _inputBox = new TextField { label = "", };
//取得它的原始容器
var originArea = _inputBox.Q<VisualElement>("unity-text-input");
//将其容器修改为垂直布局
originArea.style.flexDirection = FlexDirection.Column;
//获取处理输入的TextElement
var _input = originArea.Q<TextElement>();
//添加一个新的TextElement
var newField = new TextElement(){ text = "New Field" };
//添加到容器中
originArea.Add(_newField);
//把原始输入框排序到新添加的元素之后
newField.SendToBack();
Add(_inputBox);
}
执行结果,新添加的TextElement
被添加到了前面,新加入的TextElement
不会响应鼠标操作。也不影响原TextElement
正常工作。
这点符合预期,从TextField源代码中也能知晓这一点,在TextField
构造时这些关键元素已经被初始化且存储了引用,所以修改元素布局不会影响原始逻辑。
仅限单行模式下的InputElement元素
所以此元素的大致结构为:
我们可以基于这点完善整个逻辑。首先建立自定义元素,让其继承自VisualElement
。
与上一章一样,首先定义元素变量
。
public class TerminalField : VisualElement
{
public new class UxmlFactory : UxmlFactory<TerminalField,UxmlTraits> {}
public new class UxmlTraits : VisualElement.UxmlTraits
{
//先不定义
...
}
private readonly TextElement _historyField; //历史输出区域
private readonly TextElement _promptField; //提示符区域
private readonly TextElement _inputField; //输入区域
private readonly TextElement _suggestionField; //命令提示区域
private readonly TextField _inputBox; //主窗体
private readonly VisualElement _inputRowArea; //底部输入区域
public TerminalField ()
{
//输入框(本体)
_inputBox = new TextField { label = "", };
_inputBox.AddToClassList("terminal");
//命令帮助区域
_suggestionField = new TextElement
{
enableRichText = true,
style = { display = DisplayStyle.None,color = Color.gray}
};
//提示符区域
_promptField = new TextElement() { text = ">" };
_promptField.AddToClassList("terminal-text");
//历史输出区域
_historyField = new TextElement
{
//开启富文本解析
enableRichText = true,
//设置可被选择 允许用户选中复制
selection = { isSelectable = true }
};
_historyField.AddToClassList("terminal-text");
//底部输入容器(聚合提示符与输入区域TextElement)
_inputRowArea = new VisualElement
{
style = { flexDirection = FlexDirection.Row }
};
//读取InputField的原始容器
var originArea = _inputBox.Q<VisualElement>("unity-text-input");
_inputField = originArea.Q<TextElement>();
_inputField.AddToClassList("terminal-text");
//修改其样式 按列排布
originArea.style.flexDirection = FlexDirection.Column;
originArea.Add(_historyField);
originArea.Add(_inputRowArea);
//聚合提示符与输入区域
_inputRowArea.Add(_promptField);
_inputRowArea.Add(_inputField);
//在末尾加入命令提示区域
originArea.Add(_suggestionField);
Add(_inputBox);
//载入USS文件
styleSheets.Add(Resources.Load<StyleSheet>("Style/TerminalField"));
}
}
虽然可以让
TerminalField
继承自InputField
的基类BaseField<>
,TextInputBaseField<>
,但会导致UxmlFactory
,UxmlTraits
不可用,且依然需要手动处理文本输入相关的功能
定义uxml属性
并完善基础逻辑:
public class TerminalField : VisualElement
{
//定义C# 属性,并分配基础逻辑
public string Prompt
{
get => _promptField.text;
set => _promptField.text = value;
}
public string HistoryText
{
get => _historyField.text;
set => _historyField.text = value;
}
public string InputText
{
get => _inputBox.value;
set => _inputBox.value = value;
}
private bool _disableInput;
public bool DisableInput
{
get => _disableInput;
set
{
if (_disableInput == value)return;
_disableInput = value;
_inputRowArea.style.display = value ? DisplayStyle.None : DisplayStyle.Flex;
}
}
//定义UXML属性
public new class UxmlTraits : VisualElement.UxmlTraits {
private readonly UxmlStringAttributeDescription _prompt = new()
{
name = "prompt",
defaultValue = ">"
};
private readonly UxmlStringAttributeDescription _history = new()
{
name = "history-text",
defaultValue = ""
};
private readonly UxmlStringAttributeDescription _input = new()
{
name = "input-text",
defaultValue = ""
};
private readonly UxmlBoolAttributeDescription _disableInput = new()
{
name = "disable-input",
defaultValue = false
};
public override void Init(VisualElement ve, IUxmlAttributes bag, CreationContext cc)
{
base.Init(ve, bag, cc);
var ate = ve as TerminalField;
Debug.Assert(ate != null, nameof(ate) + " != null");
ate.Prompt = _prompt.GetValueFromBag(bag,cc);
ate.HistoryText = _history.GetValueFromBag(bag,cc);
ate.InputText = _input.GetValueFromBag(bag,cc);
ate.DisableInput = _disableInput.GetValueFromBag(bag,cc);
}
}
//其他代码
....
}
先不处理样式相关的问题,目前的效果如下:
现在只需要像对普通的InputField
那样绑定事件,和容易就能做到输入输出分离:
//按下回车的操作
private void OnExecute()
{
var text = InputText;
//清空输入框内容
InputText = "";
//如果当前输出区域为空,那么就不换行
if (HistoryText.Length>0)
HistoryText += "\n";
//附加到输出区域
//注意我们在这里使用noparse标签,表示永远不要解析用户手动输入的标签
//所有的TextElement都支持富文本解析
HistoryText += $"{Prompt}<noparse>{text}</noparse>";
}
//输入按键事件
private void OnKeyDown(KeyDownEvent e)
{
switch (e.keyCode)
{
case KeyCode.Return:
{
OnExecute();
//和web一样,PreventDefault用于阻止此事件的默认行为。
e.PreventDefault();
break;
}
default:
break;
}
}
TerminalField(){
//其他代码
...
//注册回调
_inputBox.RegisterCallback<KeyDownEvent>(OnKeyDown);
}
可以注意到,回车后会丢失焦点,可绑定按键松开KeyUpEvent
事件,在其中调用Focus()
:
private void OnKeyUp(KeyUpEvent evt)
{
//防止按下回车或Tab丢失焦点
if (evt.keyCode is KeyCode.Return or KeyCode.Tab)
{
_inputField.Focus();
}
}
//在构造函数中调用:
_inputBox.RegisterCallback<KeyUpEvent>(OnKeyUp);
至此,最基础的功能已经实现。
接下来开始逐步实现子功能,我将在最后处理样式。
逻辑实现——命令解析/历史指令
这部分较为简单,主要是对输入框的内容进行处理。
首先定义变量用于存储每条指令的输入历史,以及当前指令相关的信息。
//输入历史 用与支持上下键切换历史指令
private readonly List<string> _commandHistory = new();
private int _currentCommandIndex = -1;
//当前正在执行的指令行/指令/参数数组
private string _currentCommandLine; //未被分割的原始指令字符串
private string _currentCommand; //当前指令
private string[] _commandArgs; //当前指令的参数(将指令行由空格分割)
切换历史指令
首先是切换历史命令:
为了实现上下键切换历史命令,我们更改OnKeyDown
方法,添加对上下键的判断逻辑:
//移动输入框的光标到指定位置
private void MoveCursor(int pos)
{
//尽量不要使用cursorIndex 进行修改,这会导致不必要的范围选中。
// _inputField.selection.cursorIndex = InputText.Length;
_inputField.selection.SelectRange(pos, pos);
}
//按住键的逻辑
private void OnKeyDown(KeyDownEvent e)
{
switch (e.keyCode)
{
case KeyCode.Return:
{
OnExecute();
//阻止此事件的默认行为
e.PreventDefault();
break;
}
case KeyCode.UpArrow: //⬆️
{
if (_currentCommandIndex > 0)
_currentCommandIndex--;
else
return;
var newCmd = _commandHistory[_currentCommandIndex];
InputText = newCmd;
MoveCursor(newCmd.Length);
//阻止此事件的默认行为
e.PreventDefault();
break;
}
case KeyCode.DownArrow: //⬇️
{
if (_currentCommandIndex < _commandHistory.Count - 1)
_currentCommandIndex++;
else
return;
var newCmd = _commandHistory[_currentCommandIndex];
InputText = newCmd;
MoveCursor(newCmd.Length);
//阻止此事件的默认行为
e.PreventDefault();
break;
}
}
}
注意在上面代码中包含一个工具函数MoveCursor
,用于移动当前的光标位置,之后也会用到。
解析指令
其次是解析指令,修改之前的OnExecute
方法,并添加新方法ParseCommand
:
//解析指令并返回执行结果,结果可以为空
[CanBeNull] private string ParseCommand(string text)
{
//记录当前的原始命令行
_currentCommandLine = text;
var splitCommand = text.Split(' ');
_currentCommand = splitCommand[0];
//此时splitCommand.Length只可能是1或 >1
_commandArgs = splitCommand.Length == 1 ? Array.Empty<string>() : splitCommand[1..];
//TODO:实现你的解析指令方法
//简单的可以像这样解析,作用是更新当前的提示符
if (_currentCommand== "interface")
{
if (_commandArgs.Length == 0)
{
return "需要参数";
}
Prompt = _commandArgs[1] + ">";
}
return null;
}
//按下回车后的执行方法
private void OnExecute()
{
var text = InputText;
InputText = "";
if (HistoryText.Length>0)
{
HistoryText += "\n";
}
HistoryText += $"{Prompt}<noparse>{text}</noparse>";
//如果是有效输入,则将命令进行存储
if (text.Length > 0)
{
_commandHistory.Add(text);
_currentCommandIndex = _commandHistory.Count;
}
//解析指令,并输出结果(若不为null)
var output = ParseCommand(text);
if (output != null)
{
HistoryText += $"\n{output}";
}
}
考虑到不同控制台执行的命令应是可自定义化的动作,硬编码有违这一思想。(尤其是多个控制台使用不同的命令组时)
所以在这里我给出一个支持命令自定义的解决方案:
基于反射的命令组自动装载
首先新建一个接口
,之后的指令都会实现自它:
public interface ICommandHandler
{
//该命令的名称 (例如:interface)
public string CommandName { get; }
//函数的执行体(第一个参数为数组,第二个参数为执行环境,用于表示哪一个控制台执行了此命令)
[CanBeNull] string Execute(string[] args,TerminalField env);
}
为了能够区分指令组,我们不会直接基于这个接口实现指令,而是在这个接口之上实现一个纯虚类
,例如:
//继承自接口,表示这是一个路由器(Router)专用命令组
public abstract class RouterCommandHandler : ICommandHandler
{
public abstract string CommandName { get; }
public abstract string Execute(string[] args,TerminalField env);
}
之后基于这个纯虚类
创建子类,实现指令相应功能,例如:
public class InterfaceCommandHandler : RouterCommandHandler
{
//该命令为 interface
public override string CommandName => "interface";
//执行体
public override string Execute(string[] args,TerminalField env)
{
//仅限参数长度为1
if (args is not { Length: 1 })
return "仅支持单参数操作"; //先直接返回文本化的错误信息,之后我们会有专门函数
//使用环境参数env可修改对应控制台的变量
//更新此控制台的提示符。
env.Prompt = args[0] + ">";
return null;
}
}
以上便表达了此interface
命令仅限于在Router命令组
使用。之后我们只需对纯虚类
的所有子类进行反射扫描,可得到命令组的所有命令。
为了自动化实现这一过程,建立一个CommandManager
类用于管理指令相关的方法:
public class CommandManager
{
//当前已加载的命令,使用Map保存
private readonly Dictionary<string, ICommandHandler> _commandHandlers = new();
//保留当前控制台的引用,用于分配给欲执行的命令。
private readonly TerminalField _target;
//单参构造函数
public CommandManager(TerminalField target)
{
_target = target;
}
// 注册命令处理器,此方法会被TerminalField调用,若成功则返回true
public bool RegisterCommandHandler<T>()
{
var baseHandlerType = typeof(T);
//确保T是虚类(也就是命令组)
if (baseHandlerType.IsAbstract && baseHandlerType.IsAssignableFrom(typeof(ICommandHandler)))
return false;
//注册前清空当前存在的指令 你也可以进行保留,实现混合多个命令组
_commandHandlers.Clear();
DoRegister(baseHandlerType);
return true;
}
//执行扫描操作
private void DoRegister(Type baseHandlerType)
{
// 获取所有实现 ICommandHandler 的类
var handlerTypes = Assembly.GetExecutingAssembly().GetTypes()
.Where(type => type.IsClass && !type.IsAbstract && type.IsSubclassOf(baseHandlerType));
foreach (var type in handlerTypes)
{
UnityEngine.Debug.Log(type);
var handler = (ICommandHandler)Activator.CreateInstance(type);
_commandHandlers[handler.CommandName] = handler;
}
}
//执行命令,这将被TerminalField调用
public string ExecuteCommand(string command, string[] args)
{
if (_commandHandlers.TryGetValue(command, out var handler))
{
return handler.Execute(args,_target);
}
return "未知命令";
}
}
在TerminalField
中添加一个变量,以及一个工具函数RegisterCommand<T>
用于自动注册指令:
//不要忘记在TerminalField构造函数中进行初始化
private readonly CommandManager _commandManager;
//暴露此方法,用于在外部调用注册
public void RegisterCommand<TBaseHandler>(){
_commandManager.RegisterCommandHandler<TBaseHandler>();
}
//构造函数中进行初始化
public TerminalField(){
//其他代码
...
_commandManager = new CommandManager(this);
...
}
修改之前的ParseCommand
:
private string ParseCommand(string text)
{
//记录当前的原始命令行
_currentCommandLine = text;
var splitCommand = text.Split(' ');
_currentCommand = splitCommand[0];
//此时splitCommand.Length只可能是1或 >1
_commandArgs = splitCommand.Length == 1 ? Array.Empty<string>() : splitCommand[1..];
//**新的指令执行方式**
return _commandManager.ExecuteCommand(_currentCommand, _commandArgs);
}
如果想要注册,我们只需要调用:
//TerminalField内部
RegisterCommand<RouterCommandHandler>();
//或者在TerminalField外部,例如MonoBehaviour中
GetComponent<UIDocument>().rootVisualElement
.Q<TerminalField>().RegisterCommand<RouterCommandHandler>();
这样只需要将命令组的所有指令放于一个cs文件中进行管理,所有更改会自动同步到任何使用此命令组的TerminalField
中。
逻辑实现——命令提示
就像传统的Linux控制台,按下Tab可以自动完成当前指令。
同时如果存在多种选择,则会显示提示栏,按下对应标号自动完成对应指令。
注:在最初,我们已经添加了用于显示提示内容的TextElement
:_suggestionField
若要实现此功能,我们需要几个变量存储对应数据:
//标记当前是否正在显示提示
private bool _isSuggestionShowing;
//可被提示的命令
private List<string> _commandSuggestions = new();
//当前正在显示的提示列表
private List<string> _currentSuggestionList;
//提示栏目内容
private string SuggestionText
{
get => _suggestionField.text;
set => _suggestionField.text = value;
}
//提示显示状态
private bool IsSuggestionShowing
{
get => _isSuggestionShowing;
set
{
if (_isSuggestionShowing == value)
return;
_isSuggestionShowing = value;
_suggestionField.style.display = value ? DisplayStyle.Flex : DisplayStyle.None;
}
}
其中对IsSuggestionShowing
进行了检测,防止不必要的对style
进行调用setter
接下来编写OnTab提示生成
的逻辑,这部分较为简单,主要是对文本进行处理:
//处理显示提示的逻辑
private void OnTab()
{
var inputText = InputText;
//如果输入了多个单词,取得最后一个单词
var lastWordStartWith = inputText.Split(' ').Last();
if (lastWordStartWith.Length == 0) return;
//寻找所有命令开头是lastWordStartWith的指令
_currentSuggestionList = _commandSuggestions.FindAll(s => s.StartsWith(lastWordStartWith));
//判断是否找到了多个
switch (_currentSuggestionList.Count)
{
case 0: //什么也没找到 忽略它
return;
case 1: //只包含一个则自动完成
InputText += _currentSuggestionList[0][lastWordStartWith.Length..];
//用到了我们之前提到的工具函数MoveCursor
MoveCursor(InputText.Length);
break;
case > 1: //包含多个单词,则进行列出
{
var t = "";
for (var i = 0; i < _currentSuggestionList.Count; i++)
{
//注意:我们的TextElement都支持富文本显示(输入除外)
_currentSuggestionList[i] = _currentSuggestionList[i][lastWordStartWith.Length..];
t += $"<size=50%>[{i}]</size>.<color=white><b>{lastWordStartWith}</b></color>{_currentSuggestionList[i]} ";
}
SuggestionText = t;
//设置当前正在显示提示
IsSuggestionShowing = true;
break;
}
}
}
需要再次强调,我们所有的
TextElement
都支持富文本标签(输入框除外,但并不是逻辑上的强制限制)
重写OnKeyDown
函数,使按下Tab键后调用OnTab
,并处理0~9键
的选择事件:
private void OnKeyDown(KeyDownEvent e)
{
//如果想要使用PreventDefault阻止字符输入,则必须使用character进行判断
//读取keyCode会无法阻止字符加入到TextElement,可能是Bug
//同时使用character可以有效检测小键盘和功能键
if (e.character is >= '0' and <= '9')
{
//如果你读取了e.keyCode会导致PreventDefault失效。但对于控制型按键如Enter确没问题
if (!IsSuggestionShowing) return;
//如果当前正在显示提示 则进行如下逻辑
e.PreventDefault();
IsSuggestionShowing = false;
InputText += _currentSuggestionList[e.character - '0'];
//移动光标
MoveCursor(InputText.Length);
return;
}
switch (e.keyCode)
{
case KeyCode.Return:
{
OnExecute();
e.PreventDefault();
break;
}
case KeyCode.Tab: //添加对Tab的处理
{
OnTab();
e.PreventDefault();
break;
}
case KeyCode.UpArrow: //⬆️
{
if (_currentCommandIndex > 0)
_currentCommandIndex--;
else
return;
var newCmd = _commandHistory[_currentCommandIndex];
InputText = newCmd;
MoveCursor(newCmd.Length);
e.PreventDefault();
break;
}
case KeyCode.DownArrow: //⬇️
{
if (_currentCommandIndex < _commandHistory.Count - 1)
_currentCommandIndex++;
else
return;
var newCmd = _commandHistory[_currentCommandIndex];
InputText = newCmd;
MoveCursor(newCmd.Length);
e.PreventDefault();
break;
}
}
}
注意:由于Tab
松开后会切换元素焦点,为恢复焦点,同时也为了在按下其他键自动隐藏提示区域,再次编辑OnKeyUp
事件:
private void OnKeyUp(KeyUpEvent evt)
{
if (evt.keyCode is KeyCode.Return or KeyCode.Tab)
{
_inputField.Focus();
}
if (evt.keyCode != KeyCode.Tab) //如果按下其他键,则隐藏提示区域
{
IsSuggestionShowing = false;
}
}
最后,在构造函数中对_commandSuggestions
进行初始化数据,由于命令的不同,此处就先略过了。
如果选择使用反射式加载命令,则需要在
CommandManager
中的DoRegister
对其进行初始化:
(提前将_commandSuggestions
设为public
)private void DoRegister(Type baseHandlerType) { // 获取所有实现 ICommandHandler 的类 var handlerTypes = Assembly.GetExecutingAssembly().GetTypes() .Where(type => type.IsClass && !type.IsAbstract && type.IsSubclassOf(baseHandlerType)); //清空命令 _target._commandSuggestions.Clear(); foreach (var type in handlerTypes) { var handler = (ICommandHandler)Activator.CreateInstance(type); _commandHandlers[handler.CommandName] = handler; //逐一添加 _target._commandSuggestions.Add(handler.CommandName); } }
逻辑实现——定位报错
定位报错是一个有趣的功能,其作用是在目标的文本下方绘制 “波浪线”
其核心办法是通过MeasureTextSize
测量文本的宽度和偏移,结合富文本标签mspace
锁定等宽字体的大小,绘制确定数量的符号^
。
所以只需三个参数(起点,要绘制的长度,输出的错误信息)即可完整的实现这一逻辑:
private string Error(int begin,int length,string message)
{
//计算 开头标识符+文本从头到波浪起点 的宽度
var beginPix = _historyField.MeasureTextSize(
Prompt + _currentCommandLine[..begin], 0, 0, 0, 0).x;
//要标记的内容的长度
var contentSizePix = _historyField.MeasureTextSize(
_currentCommandLine.Substring(begin, length), 0, 0, 0, 0).x;
//波浪的个数 由于mspace标签等宽的设置,每个波浪的宽度是固定的5px
var errNum = Math.Ceiling(contentSizePix / 5);
//返回文本
return "<color=red>" +
$"<space={beginPix}px>" + //设置本行的开头等于标识符所占的位置
$"<mspace=5px>{string.Concat(Enumerable.Repeat("^", (int)errNum))}</mspace>" +
$"\nError:{message}</color>";
}
为了更快捷的使用,编写一个工具函数用于自动计算出起点与长度:
//如果是-1则标红命令本身 否则标红指定位置的参数 大于参数数组长度则会自动标红最后一个参数
public string Error(int argsIndex,string message)
{
var begin = 0;
int length;
if (argsIndex == -1)
{
begin = 0;
length = _currentCommand.Length;
}
else
{
//将数量牵制在命令参数数量内
argsIndex = Math.Min(argsIndex, _commandArgs.Length - 1);
length = _commandArgs[argsIndex].Length;
//计算偏移
begin += _currentCommand.Length;
for (var i = 0; i < argsIndex; i++)
{
begin += _commandArgs[i].Length + 1;
}
}
//实际的计算方法
return Error(begin, length, message);
}
之后在需要的地方返回Error所计算的文本即可,以interface指令举例:
public class InterfaceCommandHandler : RouterCommandHandler
{
public override string CommandName => "interface";
public override string Execute(string[] args,TerminalField env)
{
//仅限长度为1的参数
if (args is not { Length: 1 })
{
//如果args是0则标红命令本身,否则标红最后一个参数
return env.Error(args.Length - 1, "仅支持单参数操作");
}
env.Prompt = args[0] + ">";
return null;
}
}
逻辑实现——内容滚动/元素铺满
由于我们没有开启multiline
(开启会因结构问题而异常),这会导致当TerminalField
超过父元素的显示范围后部分内容不可见。
为解决此问题,我们需要为其添加ScrollView
组件,将InputField
移入其中。
动态生成
ScrollView
也是multiline
开启后的InputField
官方做法,因此无需担忧性能问题。
首先声明元素变量
:
private readonly ScrollView _scrollView; //滚动窗体
在TerminalField构造函数中进行生成:
public TerminalField(){
//其他初始化代码
...
_scrollView = new ScrollView(ScrollViewMode.VerticalAndHorizontal)
{
horizontalScrollerVisibility = ScrollerVisibility.Hidden,
verticalScrollerVisibility = ScrollerVisibility.Hidden
};
_scrollView.verticalScroller.slider.focusable = false;
_scrollView.horizontalScroller.slider.focusable = false;
//***将_inputBox添加到_scrollView中***
//Add(_inputBox);
_scrollView.Add(_inputBox);
//添加滚动视图到根
Add(_scrollView);
}
此时就已经解决了超出视图的问题。
正如上一章强调的,修改contentContainer会导致Add(…)添加的元素同样加载到其子元素下
我们可以推测:ScrollView
也是修改了contentContainer
,所以_scrollView.Add(_inputBox)
总会加载到正确位置。
为了能让输入命令时_scrollView
自动滚动到最下部,以显示正在输入的内容,更新OnKeyDown
的内容,在其第一行加入:
private void OnKeyDown(KeyDownEvent e)
{
// 滚动到底
_scrollView.verticalScroller.value = float.MaxValue;
//其他代码
...
}
单现在有一个问题,内部_inputBox与TerminalField的设定大小无关,导致留有一(大)部分空隙:
我们可以让_inputBox
的minWidth
,minHeight
始终与元素大小同步,这样确保其内容既能填满空间,也会按需延展。
为达到同步的目的,可以绑定GeometryChangedEvent
事件,它会在元素大小发生改变时发出:
//在构造函数中添加
RegisterCallback<GeometryChangedEvent>(evt =>
{
_inputBox.style.minWidth = evt.newRect.width;
_inputBox.style.minHeight = evt.newRect.height;
});
逻辑实现——可变文本块
由于
TerminalField
实质上只是文本框,因此为了能够显现可动的字符动画,不可避免的要对文本框的所有文本进行更新,如何高效的管理数个乃至几十个需要同时更新的字符动画是个问题。
首先建立一个虚类UpdatableText
,作为可变文本块的基类:
public abstract class UpdatableText
{
public int Begin; //标记文本块的开始位置
protected int StrLength;//文本块的字符长度
private string _content;//文本块的字符内容
private bool _dirty; //标记是否需要更新
protected string Content//属性,用于自动设置_dirty
{
get => _content;
set
{
if (value.Equals(_content))return;//避免无意义的更新
_content = value;
_dirty = true; //标记此文本块需要更新
}
}
//自动更新动画的逻辑 对于那些不明确何时结束的动画很有用
protected virtual void AutoUpdate() { }
//更新链 输入上一帧的文本 输出更新后的文本
public string DoUpdate(string oldValue)
{
AutoUpdate();
if (!_dirty) return oldValue;
oldValue = oldValue.Remove(Begin, StrLength);
//将Content的长度固定
if (Content.Length != StrLength)
{
//超出则裁剪 不足则补全 让长度保持固定
if (Content.Length > StrLength)
Content = Content[..StrLength];
else
Content += new string(' ', StrLength - Content.Length);
}
oldValue = oldValue.Insert(Begin, Content);
_dirty = false;
return oldValue;
}
//获取当前的画面内容
public string GetCurrentContent()
{
return Content;
}
}
维持可变文本块的字符长度不变非常重要,因为一旦发生错位,后面所有的可变文本块都会产生错位
接下来为了管理/更新文本中的所有可变文本块,建立一个管理类UpdateableTextManager
:
public class UpdateableTextManager
{
//管理的控制台
private readonly TerminalField _target;
//当前的可变文本块
private readonly List<UpdatableText> _updatableTextList = new();
//初始化绑定
public UpdateableTextManager(TerminalField target)
{
_target = target;
//绑定后立刻使用schedule开启循环,每0.5s更新一次当前所有的文本块
_target.schedule.Execute(UpdateAllText).Every(500);
}
//更新逻辑
private void UpdateAllText()
{
//仅限当前包含文本块
if (_updatableTextList.Count == 0) return;
//进行“累加”算法,将当前文本逐个输入到各个可变文本块中,积累差异
var oldValue = _updatableTextList.Aggregate(_target.HistoryText, (current, updatableText) => updatableText.DoUpdate(current));
//更新画面
_target.HistoryText = oldValue;
}
//向控制台的当前位置追加一个可变文本块
//可变文本块应当本身开头自带一个换行符
public void InsertUpdateableText(UpdatableText obj)
{
obj.Begin = _target.HistoryText.Length;
_target.HistoryText += obj.GetCurrentContent();
_updatableTextList.Add(obj);
}
}
声明一个UpdateableTextManager
变量,并在TerminalField
构造函数中初始化它:
//如果你想要在外部控制添加文本块 则需要让它public
//例如你使用了前文的CommandManager,并打算使用指令添加可变文本块
public readonly UpdateableTextManager UpdateableTextManager;
//构造函数
public TerminalField()
{
UpdateableTextManager = new UpdateableTextManager(this);
//其他代码
...
}
作为一个例子,我创建一个将绘制进度条的可变文本块:
public class ProgressBar : UpdatableText
{
public int Progress = 0; // 进度(0到100之间)
private readonly int _equalNum;//等于号的个数(用于填充进度条)
//参数是进度条的宽度
public ProgressBar(float totalWidth)
{
//两个中括号 因此减去2 用到了mspace设置等宽字体为5px,因此宽度总是为5
_equalNum = (int)(totalWidth / 5 - 2);
//技巧:末尾预留几个空格放置被意外裁剪
Content = "\n<mspace=5px>[" + new string(' ',_equalNum) + "]</mspace> ";
//必须手动设置StrLength。且只能设置StrLength一次。
StrLength = Content.Length;
}
//设置当前进度
public void SetProgress(int value)
{
//仅当进度发生改变
if (value == Progress) return;
Progress = Mathf.Clamp(value,0,100); // 限制进度在0到1之间
//如果进度完成,则显示固定的内容
if (value == 100)
{
//计算使Finished总是显示在中间
//不要使用align=center标签,除非你确定用户无法修改控制台宽度
var i = _equalNum / 2 - 4;
Content = "\n<mspace=5px>[" + new string('=',i) + "Finished" + new string('=',i) + "]</mspace>";
}
else
{
//进度为完成 计算当前应该绘制多少等于号,多少空格
//他们是等宽字体mspace=5px,因此等于号和空格宽度永远相等。只需要确保数量总和一致
var f = value / 100f;
var i = _equalNum * f; //等于号的数量
var j = _equalNum - i; //空格的数量
Content = "\n<mspace=5px>[" + new string('=', (int)i) + new string(' ', (int)j) + "]</mspace>";
}
}
}
如何让这个可变文本块显示在控制台中?只需要这样使用:
public class ProgressCommandHandler : RouterCommandHandler
{
public override string CommandName => "progress";
public override string Execute(string[] args,TerminalField env)
{
//新建控制条 它的宽度等于当前的控制台宽度contentRect.width
var updatableText = new ProgressBar(env.contentRect.width - 5);
//使用UpdateableTextManager.InsertUpdateableText添加到控制台中
env.UpdateableTextManager.InsertUpdateableText(updatableText);
//**作为演示,让其逐渐的增加数值,直到进度为100**
env.schedule.Execute(() => updatableText.SetProgress(updatableText.Progress + 1)).Every(100).Until(() =>updatableText.Progress == 100);
return null;
}
}
作为对
AutoUpdate
方法的一个使用例子,下面是无限加载条的实现方式:
public class LoadingBar : UpdatableText
{
private readonly int _num; //空格的数量
private readonly int _offset;//小球初始偏移
private int _currentPos; //小球当前位置
private int _step = 5; //小球每次更新移动距离
public LoadingBar(float totalWidth)
{
_num = (int)(totalWidth / 5) - 2;
Content = "\n<mspace=5px>|o" + new string(' ',_num - 1) + "|</mspace>";
_offset = 14;
_currentPos = 0;
StrLength = Content.Length;
}
//这个方法总是在更新画面前被调用一次
protected override void AutoUpdate()
{
//左右移动小球
Content = Content.Remove(_offset + _currentPos, 1);
if ((_step > 0 && _currentPos + _step >= _num) || (_step < 0 && _currentPos - _step <= 0))
{
_step = -_step;
}
_currentPos += _step;
Content = Content.Insert(_offset + _currentPos, "o");
}
}
最后
此控制台一个可用的样式:
以上的方式实现的控制台,基础功能堪堪够用,如果未来有更新或有重大改进我会再次更新。第一次写文章,如有遗漏请见谅。
最近正在Unity重写我的Godot项目,很多网上极少教程的功能有待验证和实现。未来还会更新更多本人认为足够新鲜/有趣写成文章的东西,若您期待还请收藏点赞关注。
标签:Tookite,string,自定义,Length,private,TerminalField,Unity,var,public From: https://blog.csdn.net/qq_36288357/article/details/142531669