122
社区成员
发帖
与我相关
我的任务
分享| 这个作业属于哪个课程 | 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 |
通常需要支持用户能够在短时间内输入若干命令;命令的执行逻辑会存在一定时间的动画,动画执行期间,用户是可以输入更多命令的,后面的命令需要在前面命令动画处理完后执行;绝大多数情况下会产生需要插队的命令。
将命令看作一个个 元素(Element) ,用一个 动作队列(ActionSequence) 作为若干元素的容器。
因为要支持元素的高效插入和删除,所以对于动作队列,本质上是使用了一个双向链表(LinkedList)作为存储元素的容器。
类图如下:

下面对类图中各个成分进行说明:
ActionSequence:动作队列
elements:动作队列所存储的元素
isDone:当前元素是否已经完成?
curNode:当前正在执行的元素的结点。记录这个字段是为了便于插入元素。
AddLast(Element):在动作队列末尾追加指定元素。
AddFirst(Element):在动作队列头部插入指定元素
CutInBefore(Element):在动作队列当前的元素前插入一个指定元素。因为我们用curNode`记录了当前正在执行的元素,所以可以插入。
CutInAfter(Element):在动作队列当前的元素之后插入一个指定元素。与CutInBefore同理。
CallNext():通知下一个元素执行。如果当前元素还未完成,即、isDone为false,则该函数没有效果。
Element: 动作元素
sequence:这个动作元素所在的动作队列。
OnCalled():动作的相应逻辑。由子类重写。当ActionSequence 的 CallNext 函数被调用时,并且当前元素已经执行完毕、这个元素是下一个元素时,该函数会被调用。
SetDone(): 首先会将sequence的isDone 设置为true,然后调用 sequence 的 CallNext 函数。也就是说,这个函数的功能就是设置这个元素已经执行完了,要通知下一个元素执行。元素必须显示调用这个函数,表示其执行完毕,否则动作队列会一直处于“阻塞”的状态。
关于为什么需要 CutInBefore 这个函数,原因是元素可能会产生“子元素”,这个子元素按理来说不能追加到队尾。想象一下函数调用过程,一个函数(外部函数)内部调用了另一个函数(内部函数),因此这个内部函数必须是先于外部函数后的函数执行的;同样,子元素(内部函数)应该要插入到当前元素(外部函数)之前,达到先于当前元素后的元素(外部函数后的函数)执行的目的。事实上,CutInBefore 这个函数被非常频繁地使用。
一般一场游戏只需要一个动作队列,因此我将 ActionSequence 设计为单例类,可以全局访问,或者也可以选择创建多个动作队列对象。
下面流程图展示了 CallNext 函数的代码逻辑:

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的问题了。
动作队列的作用是进行迸发输入处理、动画协调和命令插队。
元素代表一个个具体命令,动作队列是元素的容器。有4种方式向动作队列中插入元素:AddLast、AddFirst、CutInBefore和CutInAfter。
通过继承Element类,定义具体的元素类,并重写OnCalled函数,表示一个具体的命令。元素必须显示地调用其SetDone函数,以表示其执行完毕,并执行动作队列地下一个元素。
AddLast和CutInBefore之间选择非常重要。当要实现某个大的流程时,应该使用 AddLast 定义整个框架;而在每个小步骤中,使用 CutInBefore 更为合适,它相当于在大框架中间插入了一些小动作。
在这个框架开发完毕的后来,我学习了知名Unity插件DoTween,发现我们许多设计思想不谋而合,例如Sequence,并且DoTween有许多值得学习的地方:https://dotween.demigiant.com/