首页 > 其他分享 >Unity UI Tookite:实现命令控制台 [自定义元素]

Unity UI Tookite:实现命令控制台 [自定义元素]

时间:2024-09-26 21:49:22浏览次数:9  
标签:Tookite string 自定义 Length private TerminalField Unity var public

目录


前言

最近在将Godot项目重写至Unity,其中有一个功能是类似于CMD控制台的功能。同样的网上搜罗后也没有发现相关实现。拙笔一篇。


功能需求

  1. 我希望控制台是一个单独的元素,可以被快速的添加到任何需要的地方。
  2. 我希望可以实现所有基础功能:
    • 命令反馈输出
    • prompt提示符
    • 命令历史/提示
    • 自定义颜色/格式

结果预览
窗体用到了上一章的 Unity UI Tookite:实现窗体模板


基础逻辑实现——输入输出分离

在开始之前,需明确:

  1. 用户发送出的文本和系统输出的文本绝不能再次被用户编辑。
  2. 用户不能干涉系统的输出过程。

由于在UI Tookite中实现文本输入相当复杂,本元素将基于Input Field实现。

首先观察InputField的结构,发现用户的文本输入由内部的一个TextElement处理。
InputField结构

使用代码对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<>,但会导致UxmlFactoryUxmlTraits不可用,且依然需要手动处理文本输入相关的功能

定义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的设定大小无关,导致留有一(大)部分空隙:
在这里插入图片描述
我们可以让_inputBoxminWidth,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

相关文章

  • Windows 允许用户自定义和安装网络协议。以下是一些方法和步骤,帮助您在 Windows 中进
    Windows允许用户自定义和安装网络协议。以下是一些方法和步骤,帮助您在Windows中进行此操作。1.使用设备管理器安装协议您可以通过设备管理器来安装特定的网络协议:打开设备管理器:右键点击“开始”菜单,选择“设备管理器”。找到网络适配器:展开“网络适配器”部分。......
  • Unity DatePicker用法,实现UI的日期/时间选择器功能
    前言用Unity3d做一个类似于选时间段,查询数据并展示统计UI的功能插件https://assetstore.unity.com/packages/tools/gui/datepicker-for-unityui-68264样例效果弹出日期选择器时间范围选择器包含类型SharedCalendar共享的日历,这个就是几个选择器共用一个日历来选择时间......
  • SpringBoot3自定义favicon.ico图标
            在学习SpringBoot项目的过程中,我想在我的个人项目中添加自定义favicon.ico的图标。但是你会发现在使用yml去配置favicon时,发现配置被废除了。如下图所示:        即使没有配置,SpringBoot也会帮我们去扫描resource包下的static,我们只需要将favicon.ico......
  • 使用Pygal库创建可缩放的矢量图表:从基础到高级自定义详解
    在数据可视化的世界中,创建可缩放的矢量图表是至关重要的,因为它们可以无损地在各种设备和分辨率下进行展示。Python中有许多强大的库可供选择,其中Pygal是一个出色的选择,它提供了创建各种类型的交互式矢量图表的功能。什么是Pygal?Pygal是一个Python库,专门用于创建可缩放的矢量图表。......
  • Unity中的功能解释(数学位置相关和事件)
    向量计算Vector3.Slerp(起点坐标,终点坐标,t),可是从起点坐标以一个圆形轨迹到终点坐标,有那么多条轨迹,那怎么办Vector3.Slerp进行的是沿球面插值,因此并不是沿着严格的“圆形轨迹”移动,而是在两点所在的大圆弧(球体上的最短路径)上插值。点乘叉乘判断方位,点乘得到的结果大于0和小于......
  • quixel bridge如何导入unity
    1.QuixelBridge下载和设置下载QuixelBridge-Manage3Dcontentandexportwithoneclick客户端注册安装。bridge模型导出路径配置和插件下载客户端点击Edit->ExportSettings ExportTatget选择Unity类型;点击下载unity的插件,下载的插件位置看后面有介......
  • 【解决了一个小问题】aws s3 sdk 中的自定义header设置哪些不参与aws v4 签名
    作者:张富春(ahfuzhang),转载时请注明作者和引用链接,谢谢!cnblogs博客zhihuGithub公众号:一本正经的瞎扯在通过代理访问s3服务端的时候,s3服务端返回类似的错误信息:<?xmlversion="1.0"encoding="UTF-8"standalone="yes"?><Error><Code>AuthorizationQueryParametersE......
  • unity调用java静态方法
    在Unity中调用Java静态方法通常需要通过Android插件实现。以下是基本步骤:创建Java类:在AndroidStudio中创建一个Java类,包含静态方法。packagecom.example.myplugin;publicclassMyJavaClass{publicstaticStringmyStaticMethod(){return"Hello......
  • Flutter 自定义国家选择器:基于 A ~ Z字母索引的列表跳转与侧边栏导航实现
    在许多移动应用中,我们经常需要通过字母索引快速跳转到目标位置,比如通讯录、国家选择等功能。这篇博客将带大家实现一个仿照通讯录的Flutter国家选择器。通过一个字母索引的侧边栏,用户可以快速跳转到目标字母分组。效果:1.项目需求与设计思路我们需要实现一个包含多个国......
  • 自定义表格样式
     HTML:<divclass="table-container"><tablestyle="width:90%;margin-left:5%"><trclass="table-title"><thstyle="width:33%&qu......