个人技术总结——动作队列:基于命令队列进行迸发输入处理、动画协调和命令插队

222100436魏文铮 2024-06-02 21:06:21
这个作业属于哪个课程https://bbs.csdn.net/forums/ssynkqtd_06
这个作业要求在哪里https://bbs.csdn.net/topics/618785000
这个作业的目标进行项目开发的个人技术总结
其他参考文献《构建之法》、http://www.gamecolg.com/design_m_onvideo_m_inkey_m_gtb1691541250001.html

目录

  • 技术概述
  • 技术详述
  • 实现
  • 流程图
  • 实现代码(C#)
  • 使用示例
  • 技术使用中遇到的问题和解决过程
  • 何时使用AddLast,何时使用CutInBefore?
  • 总结
  • 参考

技术概述

通常需要支持用户能够在短时间内输入若干命令;命令的执行逻辑会存在一定时间的动画,动画执行期间,用户是可以输入更多命令的,后面的命令需要在前面命令动画处理完后执行;绝大多数情况下会产生需要插队的命令。

技术详述

实现

将命令看作一个个 元素(Element) ,用一个 动作队列(ActionSequence) 作为若干元素的容器。

因为要支持元素的高效插入和删除,所以对于动作队列,本质上是使用了一个双向链表(LinkedList)作为存储元素的容器。

类图如下:

下面对类图中各个成分进行说明:

  • ActionSequence:动作队列

    • elements:动作队列所存储的元素

    • isDone:当前元素是否已经完成?

    • curNode:当前正在执行的元素的结点。记录这个字段是为了便于插入元素。

    • AddLast(Element):在动作队列末尾追加指定元素。

    • AddFirst(Element):在动作队列头部插入指定元素

    • CutInBefore(Element):在动作队列当前的元素前插入一个指定元素。因为我们用curNode`记录了当前正在执行的元素,所以可以插入。

    • CutInAfter(Element):在动作队列当前的元素之后插入一个指定元素。与CutInBefore同理。

    • CallNext():通知下一个元素执行。如果当前元素还未完成,即、isDone为false,则该函数没有效果。

  • Element: 动作元素

    • sequence:这个动作元素所在的动作队列。

    • OnCalled():动作的相应逻辑。由子类重写。当ActionSequenceCallNext 函数被调用时,并且当前元素已经执行完毕、这个元素是下一个元素时,该函数会被调用。

    • SetDone(): 首先会将sequenceisDone 设置为true,然后调用 sequenceCallNext 函数。也就是说,这个函数的功能就是设置这个元素已经执行完了,要通知下一个元素执行。元素必须显示调用这个函数,表示其执行完毕,否则动作队列会一直处于“阻塞”的状态

关于为什么需要 CutInBefore 这个函数,原因是元素可能会产生“子元素”,这个子元素按理来说不能追加到队尾。想象一下函数调用过程,一个函数(外部函数)内部调用了另一个函数(内部函数),因此这个内部函数必须是先于外部函数后的函数执行的;同样,子元素(内部函数)应该要插入到当前元素(外部函数)之前,达到先于当前元素后的元素(外部函数后的函数)执行的目的。事实上,CutInBefore 这个函数被非常频繁地使用。

一般一场游戏只需要一个动作队列,因此我将 ActionSequence 设计为单例类,可以全局访问,或者也可以选择创建多个动作队列对象。

流程图

下面流程图展示了 CallNext 函数的代码逻辑:

实现代码(C#)

using System.Collections.Generic;
using UnityEngine;

/// <summary>
/// 动作队列
/// </summary>
public class ActionSequence : Singleton<ActionSequence>
{
    /// <summary>
    /// 动作元素队列
    /// </summary>
    private LinkedList<Element> _elements = new();

    /// <summary>
    /// 当前元素是否已完成
    /// </summary>
    internal bool i_isDone = true;

    /// <summary>
    /// 当前正在执行的动作元素
    /// </summary>
    private LinkedListNode<Element> _curNode;

    internal LinkedList<Element> i_elements => _elements;

    /// <summary>
    /// 队尾追加一个动作
    /// </summary>
    /// <param name="element"></param>
    public void AddLast(Element element)
    {
        if (element == null)
            return;

        element.i_sequence = this;
        _elements.AddLast(element);
    }

    /// <summary>
    /// 队头插入一个动作
    /// </summary>
    /// <param name="element"></param>
    public void AddFirst(Element element)
    {
        if (element == null)
            return;

        element.i_sequence = this;
        _elements.AddFirst(element);
    }

    /// <summary>
    /// 执行下一个动作
    /// </summary>
    public void CallNext()
    {
        if (!i_isDone) // 没有完成,返回
            return;

        if (_elements.Count == 0)
            return;

        if (_curNode != null)
            _elements.Remove(_curNode);

        if (_elements.Count == 0) // 如果没有元素
        {
            i_isDone = true;
            _curNode = null;
        }
        else // 如果还有元素
        {
            i_isDone = false;
            _curNode = _elements.First;
            _curNode.Value.OnCalled();
        }
    }

    /// <summary>
    /// 在当前动作前插入动作
    /// </summary>
    /// <param name="element"></param>
    public void CutInBefore(Element element)
    {
        element.i_sequence = this;
        if (_curNode == null)
        {
            _elements.AddLast(element);
        }
        else
        {
            _elements.AddBefore(_curNode, element);
        }
    }

    /// <summary>
    /// 在当前动作之后插入动作
    /// </summary>
    /// <param name="element"></param>
    public void CutInAfter(Element element)
    {
        element.i_sequence = this;
        if (_curNode == null)
        {
            _elements.AddFirst(element);
        }
        else
        {
            _elements.AddAfter(_curNode, element);
        }
    }

    /// <summary>
    /// 清除所有动作
    /// </summary>
    public void Clear()
    {
        _elements.Clear();
        i_isDone = true;
        _curNode = null;
    }
}
/// <summary>
/// 动作队列的元素
/// </summary>
public class Element
{
    /// <summary>
    /// 这个动作元素所在的动作队列
    /// </summary>
    internal ActionSequence i_sequence;

    /// <summary>
    /// 被呼叫时触发。代码体内一定要调用SetDone,否则动作队列会阻塞。
    /// </summary>
    public virtual void OnCalled() { }

    /// <summary>
    /// 设置为已完成
    /// </summary>
    public void SetDone()
    {
        i_sequence.i_isDone = true;
        i_sequence.CallNext();
    }
}

使用示例

以我的Unity项目——遗忘之海作为使用示例。在这个示例中,要实现的功能描述如下:

在战斗场景,处于玩家回合下,玩家可以进行“闪念”和“结束回合”两个操作。

闪念:丢弃所有手牌,然后抽5张牌。

结束回合:(一般情况下)丢弃所有手牌,然后顺序执行以下逻辑:丢弃所有手牌,显示“敌人回合”提示,敌人依次执行意图,显示“玩家回合”提示,进入玩家回合。

图中红色圈出部分是玩家可以点击的对应按钮。

现在,玩家连续快速依次点击“闪念”和“结束回合”两个按钮。

动作队列的运作图如下,这个运作图描述了这个过程中,动作队列的元素链表内容和 curNode 所指向的元素的变化过程:

  • 内容为0.5f的元素表示停顿0.5秒后完成的元素,也就是说,它是用于“暂时阻塞队列运作”的元素。在这个阻塞期间,可以向动作队列中加入新的元素。

  • 注意图中第3和第4行,“抽5张牌”元素的逻辑是调用CutInBefore函数在动作队列当前元素(“抽5张牌”元素本身)之前连续插入5个“抽牌”元素。执行完毕后,接下来执行队头元素,进入到图中第5行的格局。

  • 动作队列遵循总是从队头取下一个元素的原则。

  • 当动作队列的全部元素执行完毕,“闪念”和“结束回合”这两个逻辑就正确地、有序地处理完毕了。可以看出,元素还会产生新元素,并且有时候需要将新元素利用CutInBefore进行插队(这种元素也就是子元素)。

技术使用中遇到的问题和解决过程

何时使用AddLast,何时使用CutInBefore

当要实现某个大的流程时,应该使用 AddLast 定义整个框架;而在每个小步骤中,使用 CutInBefore 更为合适,它相当于在大框架中间插入了一些小动作。

例如,玩家回合到敌人回合被定义为一个大框架;而敌人回合这个小步骤中,敌人要攻击玩家,要给玩家塞入手牌等等,这些动作步骤都会有动画过程,因此需要使用 CutInBefore 嵌入到大框架之中,保证框架的执行流程有序。

以下是项目代码示例(C#):

大框架:

/// <summary>
/// 进入敌人回合
/// </summary>
public void EnterEnemyTurn()
{
    ActionSequence.Instance.AddLast(new CallbackElement((e) =>
    {
        StateTipPanelHelper.Show("敌人回合", e.SetDone); // 显示“敌人回合”
    }));

    ActionSequence.Instance.AddLast(new CallbackElement((e) =>
    {
        enemyComp.CallDefComp();
        enemyComp.CallAllEnemies(); // 通知敌人行动
        e.SetDone();
    }));

    ActionSequence.Instance.AddLast(new CallbackElement((e) =>
    {
        StateTipPanelHelper.Show("你的回合", e.SetDone);  // 显示“你的回合”
    }));

    ActionSequence.Instance.AddLast(new CallbackElement((e) =>
    {
        EnterPlayerTurn(); // 进入玩家回合
        e.SetDone();
    }));

    ActionSequence.Instance.CallNext();
}

当然,其中的每个动作元素可能都会有自己的子元素,比如 CallAllEnemy 中,调用了以下函数:

/// <summary>
/// 提交一个敌人的行动命令
/// </summary>
/// <param name="enemy"></param>
public void CallEnemy(Enemy enemy)
{
    ActionSequence.Instance.CutInBefore(new CallbackElement((e) =>
    {
        ViewManager.Instance.DoFx(new FX.DoBehaviorFx(enemy.EnemyAgentComp.enemyAgent.gameObject, false));
        enemy.intentionComp.ExecuteIntention();
        e.SetDone();
    }));
    ActionSequence.Instance.CutInBefore(new BlankElement(1f));
    ActionSequence.Instance.CallNext();
}

这个函数利用 CutInBefore 生成了大框架中第二个元素的子元素。

基于上述经验,大多情况下就能解决何时使用AddLast,何时使用CutInBefore的问题了。

总结

  1. 动作队列的作用是进行迸发输入处理、动画协调和命令插队。

  2. 元素代表一个个具体命令,动作队列是元素的容器。有4种方式向动作队列中插入元素:AddLast、AddFirst、CutInBefore和CutInAfter。

  3. 通过继承Element类,定义具体的元素类,并重写OnCalled函数,表示一个具体的命令。元素必须显示地调用其SetDone函数,以表示其执行完毕,并执行动作队列地下一个元素。

  4. AddLast和CutInBefore之间选择非常重要。当要实现某个大的流程时,应该使用 AddLast 定义整个框架;而在每个小步骤中,使用 CutInBefore 更为合适,它相当于在大框架中间插入了一些小动作。

参考

在这个框架开发完毕的后来,我学习了知名Unity插件DoTween,发现我们许多设计思想不谋而合,例如Sequence,并且DoTween有许多值得学习的地方:https://dotween.demigiant.com/

...全文
111 回复 打赏 收藏 转发到动态 举报
写回复
用AI写文章
回复
切换为时间正序
请发表友善的回复…
发表回复

122

社区成员

发帖
与我相关
我的任务
社区描述
FZU-SE
软件工程 高校
社区管理员
  • LinQF39
  • 助教-吴可仪
  • 一杯时间
加入社区
  • 近7日
  • 近30日
  • 至今
社区公告
暂无公告

试试用AI创作助手写篇文章吧