使用Promise进行游戏开发

本文翻译自Promises for Game Development, by Ashley Davis 和 Adam Single

引言

本文中,我将分享使用Promise进行游戏开发的经验。旨在揭示为什么promise能有提升游戏开发进程。

本文不面向初学者。读者需要初级至中级以上的C#水平。本文将帮助为游戏规模扩大而深陷泥潭的你们。读者需要已经了解管理异步操作链、复杂网页的相互依赖及基于时间序列游戏逻辑的难点。本文便是你所要寻找的良方用于构建更好的代码结构,他将给你一个全新的选择。

本文会先介绍一下什么是promises及泛泛说明下它在游戏开发中如何有用。这样抛砖引玉,能够更好的了解promise所应用在游戏开发中的具体情形。这将引领我们探讨如何扩展promise模式去应对更高级的游戏开发问题:组合小单元游戏逻辑、基于时间序列的逻辑及构建条件门逻辑。最后,比较promise和其他你曾考虑或用过的模式。

请不要在阅读本文时假定promise适用于任何情形。他并不是游戏开发的唯一工具。Promise是构建复杂异步代码的强大工具,起眼可能很难喜欢。介于异步代码越来越流行,我们需要类似promise的模式去帮助我们更好的驯服越来越复杂的代码。

在Real Serious Games中,我们使用Unity,C#。如果你试图使用C#版promise(绑定于任何C#游戏引擎,不限于Unity),你可以使用我们的promise类库。如果你使用Javascript版promise,有太多类库可以任意使用,甚至新版本的JS还自带promise。如果你使用其他编程语言,该技术依旧适用,但是你可能要好点时间找个好用的promise库。

同样有些unity例子使用promise在github

介绍promise

Promise 是一种设计模式,它构建异步代码及平滑(依赖)异步运行序列的复杂性。

最近有篇文章帮助我更具体的认识到promise是怎么帮到我们的。通常而言,运行中的异步函数很难获取其返回结果。另外,处理错误及异常也很难实现。当异步代码抛出异常时会发生什么?promise很有说服力的一点是,他给了我们在异步世界中返回函数组合)及异常处理(https://en.wikipedia.org/wiki/Exception_handling)。

Promise抽象化成绑定多个异步操作到单一第一类值对象的管道。可以认为,promise定下异步约定只有成功或失败。

每个异步操作的结果通过管道传递。这样异步代码能容易地获取并操作结果。当管道完全成功后,success回调将被调用。

运行成功的管道图

Promise同样还有强大的错误处理机制(具体而言,类似异常处理),允许错误处理者在管道的任何阶段介入。倘若只关心整个异步序列的成功还是失败,可以把异常处理放在管道的最后。一旦管道的任何阶段发生错误,后续管道将短路求值并把控制权转移给错误处理者。这很像try-catch机制。

发生异常的管道图

用JS开发网页应用的经历让我们发现了promise。这里说的JS是完全版本JS,而不是Unity版的伪JS。阅读维基百科后发现,promise由函数式编程语言移植到JS,函数式语言真是好东西的发源地呀。

Promise在JS中常被颂赞其解决反模式——回调地狱。虽然有很多JS类库用于管理异步代码,但是promise从中杀出,成为标准库的一部分。

好吧,现在概述下promise如何帮助进行游戏开发。

使用Promise进行游戏开发

游戏如何架构?Promise如何促进游戏开发?

你可能认为游戏只是时间序列——依赖一个接一个的活动,依赖前置活动或玩家输入。当玩家满足一定目标后,达成游戏或等级。目标又依赖活动及前置目标……如果能从4维视角看,游戏就像挂毯样,交织着这种复杂的目标及依赖。

一些游戏开发工具包以优雅易懂的方式帮助连接目标及活动依赖,且游戏演变后同样容易修改。当然,现在有许多可用的模式来架构游戏代码:策略模式、有限状态机、层次状态机、行为树、脚本化、协程、消息事件、实体组件系统等等,每个都是值得深入理解的。

我想说的是,primise也隶属于其中。它是简明的备选,适用于不同任务情形,你甚至可以直接使用它们。promise的威力在于它能把整个活动序列当做单一的实体。这是promise能被复用的基础,你可以搭建越来越大的组件,其组合多种游戏逻辑及行为。这样的代码易读、易懂、易维护。

Promise已经在许多传统异步操作如载入资源中被直接使用。同样可用于序列移动,动画及变成声音序列而更好的调用过场动画。但是对于一些更复杂的逻辑,我们必须加深认识promis模式。开始前,先复习下C#中promise的基本使用。

Promise对C#的帮助

Real Serious Games中,编写了C#版的promise模式,它直接改善异步任务代码的设计,如载入资源。这种情形下,异步载入比阻塞等待载入完成后回调更值得选择。这样做让线程能够处理其他逻辑,或简单说可以让UI保持响应。

通过一些例子来明白promise的基本内容吧。

异步操作通常由函数返回promise开始,先看看如下代码:

1
2
3
4
public IPromise<MyAsset> LoadAsset(string assetPath)
{
// ...
}

LoadAsset 初始化了一个基于Promise的异步操作:

1
2
3
4
5
var promise = new Promise<MyAsset>();
// ... start the async operation ...
return promise;

这里,异步操作在返回promise前。调用者可以直接使用返回的promise对象(通过他的 fluent API),甚至异步代码还在执行中,并没有完成。

异步操作完成后,无论如何都会发送通知,LoadAsset resolve Promise

1
promise.Resolve(theLoadedAsset);

解决了的proimse会触发下游回调管道,它由调用者用函数Then,ThenAll,Done定义。

最简单的管道单单使用Done附在管道回调的最后:

1
2
LoadAsset(someAssetPath)
.Done(myAsset => OnAssetLoaded(myAsset));

多操作可以链上Then,例子如下:

1
2
3
LoadAsset(someAssetPath)
.Then(myAsset => LoadAsset(myAsset.LinkedAssetPath))
.Done(nextAsset => OnAssetLoaded(nextAsset));

上述匿名函数定义了每个管道阶段的回调仅在promise被resolve后才被调用。每个管道阶段返回一个全新promise对象,它表达新异步操作必须在管道能到下一个阶段(或在最后阶段,由Done指定)完成。

这是个琐碎的例子,但是希望能够开始见识到promise的威力。

进一步说,RSG Promise库增加了新功能能够链接多依赖promise。例子使用ThenAll载入多关联资源:

1
2
3
LoadAsset(someAssetPath)
.ThenAll(myAsset => myAsset.LinkedAssets.Select(path => LoadAsset(path)))
.Done(linedAsset => OnAllAssetLoaded());

ThenAll传入的回调返回promise的集合。所有的promise完成后管道才会继续。LINQ SELECT转换关联资源集合成载入资源的promise集合。作为C#程序员,我更倾向于使用LINQ的方法语法而不是检索语法

Promise所做的最大工作仅仅是如何简单的管理异步操作序列链。想插入新的操作到管道中?这么修改就可:

1
2
3
4
5
LoadAsset(someAssetPath)
.ThenAll(myAsset => myAsset.LinkedAssets.Select(path=>LoadAsset(path)))
.Then(LinkedAssets => SomeOtherAsyncOperation(linkedAssets))
.Then(somethingElse => AndAnotherAsyncOperation(somethingElse))
.Done(somethingElseAgain => OnAllAssetsLoaded(somethingElseAgain));

C#版的promise库及其文档可以在github上找到

Promise 对游戏开发的帮助

游戏开发中,有太多的情形需要等待异步操作完成或满足一些条件。以下是一些情形:

  • 网上下载文件、数据
  • 动画结束后播放粒子特效
  • 动画结束后播放声音特效
  • 移动摄像机到指定位置
  • 等待玩家死亡
  • 等待玩家损失所有生命
  • 等待玩家输入
  • 等待超时
  • 等待成就的所有要求被满足

接下来的章节中,我们通过例子将由简入繁地探讨游戏开发中的promise.

Promise用于异步资源载入

游戏通常需要异步操作。无论是加载、初始化亦或是运行时操作等需要在多帧中执行的操作。启动时,需载入所需资源。也许这些数据需要从云端数据库推送。其它系统的初始化依赖这些数据。

拙略的尝试是频繁轮询载入是否完成:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public void Update(float deltaTime)
{
if(! gameLoaded)
{
if (gameLoadFinished)
{
// loading has finished.
gameLoaded = true;
// Initialize systems ...
} else
{
// Not load yet, maybe process the loading screen animation.
return;
}
}else
{
// Game is Loaded ...
// Update AI, world, etc.
}
}

轮询是有用的,但却是坏习惯。你也可以看到,上述代码是如何的愚笨及不雅。

上述编码风格会随着初始依赖的增加而快速复杂化。先不考虑其他问题,若失败后怎么处理及恢复?难道继续轮询某个错误条件?

上述轮询让我想到 计算pull模式中询问对象状态是否改变。依据Lee Campbell的观点,世界已经是 push模式, 但是开发者依旧在追赶中。push模式中,更多是通知状态变化,而不是去询问。

使用C# 的events,能够进入push模式的国度:

1
2
3
4
5
6
7
8
9
10
11
12
public void Startup()
{
// No need to poll, this event will notify
// when the level has been loaded.
levelLoader.LevelLoaded += levelLoader_LevelLoaded;
levelLoaded.Load("SomeLevel");
}
private void levelLoader_LevelLoaded(object sender, LevelLoadedEventArgs e)
{
// Initialize dependent systems
}

C# event优于之前,但是代码依旧会很快变得复杂。脑补有个异步网页爬虫依赖必须在游戏启动前完成。你用C# event能浪成怎样呢?再说,随着复杂度增加,困惑变得愈加尖锐,代码也变得越来越难维护管理。

抛开event,我们使用回调(异步方法),这是JS、Lua中烂大街的技术:

1
2
3
4
5
6
public void Startup()
{
levelLoader.Load("someLevel", loadedLevel=>{
// Initialize dependent systems ...
});
}

回调给了很多好处,它甚至可以允许实现链式加载及初始化

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public void Startup()
{
// Load the level.
levelLoader.Load("SomeLevel", loadedLevel =>
{
// After level has loaded, load behaviours required by the level.
behaviorLoader.LoadBehaviors(loadedLevel.RequiredBehaviours,
loadedBehaviors =>
{
// Initialise dependent systems...
}
);
});
}

回调的问题在于,它导致了深度嵌入的异步函数让人困惑。这就是大家所说的回调地狱

这就是Promise登场的理由啦。大家需要更好的工具架构、管理复杂的异步代码。你若认同本文之前所描述的问题,promise将备受欢迎的加入你的工具包。

之前所列的方法将导致代码迅速错乱交织,难以调试、测试、维护。使用Promise能够以优雅易读的方式布局链式依赖。

1
2
3
4
levelLoader.LoadLevel("SomeLevel")
.Then(loadedLevel => LoadSometingElse(loadedLevel))
.Then(loadedLevel => LoadAnotherThing(loadedLevel)))
.Done(loadedLevel => StartGame(loadedLevel));

关于Promise另外的好处是:渐进式重构代码有可能转化成promise。我们深知这点是因为我们就这么做了,递进替换Unity的协程为Promise。(每步都认真测试了)

这仅是promise的牛刀小试。更加深入探索它,你将明白它对你的开发过程会产生怎样的变革。

Promise用于组合游戏逻辑

Promise具有强大的能力通过流式接口组合操作序列。我们已经通过增加组合操作扩展promise规范,用于解决游戏开发的特定问题。本节中,我们将浏览下这些新操作。All是组合并行操作,Race是第一个并行操作完成时完成,Sequence是调度顺序操作集合。

Promise.All

All打包多个promise,它返回的promise仅在所有打包的promise都被resolve后才resolve。

这可以用在很多场合。例如成就系统。想象一个益智游戏的成就需要玩家玩30分且完成10关后解锁:

1
2
3
4
5
6
7
IPromise OpeningGambitAchievement()
{
return Promise.All(
WaitForTimeSpentOnPuzzles(60 * 20),
FinishedSpecificNumberOfLevels(10)
);
}

WaitForTimeSpentOnPuzzlesFinishedSpecificNumberOfLevels都会返回promise。它们内部怎么执行我们后续在谈,现在最重要的是理解OpeningGambitAchievement返回的promise仅在上述两个promise都resolve才会被resolve,而不管两个事件相隔多么离谱。

使用这个promise,仅仅需要触发及遗忘:

1
2
OpeningGambitAchievement()
.Done(() => ShowAchievementNotification("OpeningGambit");

All 同样可传入promise集合用于resolve,例如玩家必须完成目标的集合:

1
2
3
4
IEnumerable<IPromise> ObjectivesToComplete()
{
// ... return a collection of promises ...
}

这样,当玩家达成目标后,可以很容易接受通知:

1
2
Promise.All(ObjectivesToComplete())
.Done(() => OnObjectivesCompleted());

All 还可以通过ThenAll链式传入

1
2
3
LoadLevel(someLevel)
.ThenAll(theLevel => ObjectivesToComplete(theLevel))
.Done(() => OnLevelCompleted());

Promise.Race

RaceAll很像,区别点在于它会在第一个内部promise resolve后就直接resolve。内部每个Promise都Race完成。这个机制很容易用于构建超时代码。

想象下某个情形需要玩家提供输入,但是有一个时间限制。本例中,返回promise将在玩家输入后或超时后被resolve,先到先得:

1
2
3
4
IPromise WaitForTimedUserInput(float timeoutSeconds)
{
return Promise.Race(WaitForInput(), Timeout(timeoutSeconds));
}

需要警惕的是,其他内部promise依旧运行知道完成。尽管Race promise 会在第一个内部promise完成后立马resolve并调用链式逻辑,其他内部promise依旧会运行知道完成。所以需要小心使用promise会可能导致异步状态变更而产生副作用。

Promise.Sequence

一些场景如动画动态生成移动序列能够简单使用Sequence函数,如下硬编码操作序列:

1
2
3
4
5
PromiseOne()
.Then(PromiseTwo())
.Then(PromiseThree())
...
.Done();

Sequence可用于动态生成操作序列(可能从数据中载入),用于改善上述硬编码形式。

Sequence 输入函数集合,返回promise。每个函数仅在前一个promise resolve后调用下一个。这使得异步操作序列串行执行。

1
2
3
4
5
6
7
8
9
10
IEnumerable<Func<IPromise>> GenerateMovementSequence()
{
// Generate a collection of functions that each initiate an async operation
// and return a promise.
}
Promise.Sequence(GenerateMovementSequence())
.Done(() => {
// The sequence has completed.
});

这看上去比实际更复杂。我们需要函数集合而不是promise集合,因为我们需要延迟启动每个异步操作直到轮到它。不能仅仅传递promise结合,每个存在的promise都暗示着异步操作已经启动。换句话说,序列中的每个操作都是同步启动的,这并不是我们所要的(这是All所要的)。因此,Sequence必须传递违背调用的函数。

例如,有个NPC守卫需要走一系列的路径点。当上述行为完成后NPC将复位,等待玩家的后续激发。

可以用promise实现上述功能。链接Then,生成一个NPC在每个路径点上的移动序列。这意味着路径点需要在运行时可知。我更喜欢用数据驱动来实现,从数据中导入路径点集合。这简直是用Sequence的完美时刻。

假定我们有个移动NPC并返回一个promise的函数,该promise在NPC到达指定点后resolve。

1
IPromise MoveToPosition(Vector3 endPosition, float movementSpeed);

这些就够我们手工链接了。但是Sequence需要的是返回一个函数而不是promise,允许Sequence在恰当时候调用。

1
2
3
Func<IPromise> PrepMoveToPosition(Vector3 endPosition, float movementSpeed){
return () => MoveToPosition(endPosition, movementSpeed);
}

PrepMoveToPosition以匿名函数包装MoveToPosition,返回新函数。这样就可以创建一个函数集传递给Sequence

1
2
3
4
5
6
7
8
9
10
Vector3[] waypoints = ... loaded from data ...
void MoveAlongWaypoints(float speed)
{
// Generate collection of functions for moving through the way points.
var moves = waypoints.Select(waypoint => PrepMoveToPosition(waypoint, speed));
Promise.Sequence(moves)
.Done(() => Reset());
}

可以看出,promise不仅可以这样,还更鼓励我们分离逻辑,打破函数紧密,逻辑组件更易读,好管理。

Sequence通用可以通过thenSequence链接在一起。

Promise用于时间逻辑

在上述场景中使用promise有直观的好处,但是扩展使用promise到时间逻辑上时,就触碰到它的天花板了。

许多游戏使用某种方式的定时器,跟踪时间在游戏开发中是不可或缺的部分。例子包括威力提升限定时间,依据时间的结束条件,竞速游戏每圈时间及最高级的是,整个过场动画是许多时间操作的序列。

定时器最简单的形式就是一定时间后触发逻辑。代码中一次性解决这个简单问题,一个浮点数随着增量流逝时间变化增加。

1
2
3
4
5
6
7
8
9
10
11
12
void Update(float deltaTime)
{
curTime += deltaTime;
if (curTime < triggerTime)
{
// Still counting towards the timer
} else
{
// Time is up
}
}

这是简单的技巧,但是复杂性也随之放大。若是有很多定时器,讲很难调试及维护。

考虑个更复杂的例子。

假设角色可换造型,有许多外形,每种有不同的增益。角色只能从人类形态激发转换成其他形态,一旦转变后,玩家不可手动变回,只能在经过随机时间后变回。更复杂的是,每种形态玩家都能激发专属威力提升,这些提升同样有自身定时器。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
class Character
{
float currentFormtimer; // The amount of time left in the current form.;
enum Form{
Human,
Wolf,
Hawk,
Dolphin
}
Form currentForm = Form.Human;
float currentPowerupTimer;
enum Powerup{
None,
Werewolf,
HawkSpeed,
DolphinEcho
}
Powerup currentPowerup = Powerup.None;
public void Update(float deltaTime)
{
if (currentForm != Form.Human){
currentFormTime -= deltaTime;
if(currentFormTimer < 0f){
currentForm = Form.Human;
currentPowerup = Powerup.None;
}
if (currentPowerup != Powerup.None){
currentPowerupTimer -= deltaTime;
if (currentPowerupTimer <= 0f){
currentPowerup = Powerup.None;
}
}
}
}
}

这个例子依旧很简单,但容易变得很复杂,并且代码很难看且不好阅读。你可以想象当我们添加更多特性及定时器后,代码会长残成啥样。

我们同样扩展了promise库,用于提升使用定时器。这样让我们把游戏逻辑从Updata函数中移出,成独立函数,每个都有清晰定义的职责

我们实现了PromiseTimer,它消除了promise和定时器间的间隙。WaitFor函数返回promise,它会在经过特定时间后resolve。

1
2
3
4
5
PromiseTimer promiseTimer = new PromiseTimer();
promiseTimer.WaitFor(timeToWait)
.Done(() => {
// Time is up
});

定义同类中的便捷函数:

1
2
3
4
5
6
PromiseTimer promiseTimer = new PromiseTimer();
IPromise WaitFor(float timeToWait)
{
return promiseTimer.WaitFor(timeToWait);
}

定时器必须在某个位置被更新,我们可以在Character类中做:

1
2
3
4
void Update(float deltaTime)
{
promiseTimer.Update(deltaTime);
}

现在用WaitFor重写下Character例子。还记得Update中的逻辑代码吗,现在把他移动到恰当的帮助函数中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
class Character
{
PromiseTimer promiseTimer = new PromiseTimer();
enum Form{
Human,
Wolf,
Hawk,
Dolphin
}
Form currentForm = Form.Human;
enum Powerup
{
None,
Werewolf,
HawkSpeed,
DolphinEcho
}
Powerup currentPowerup = Powerup.None;
public void Update(float deltaTime)
{
promiseTimer.Update(deltaTime);
}
// Activates wolf mode if possible
public IPromise BeWolf()
{
if(currentForm != Form.Huma)
{
return Promise.Resolved()
}
// Wait for a random time between 10 an 20 seconds
return RunTimer(Random.Range(10f, 20f))
.Then(()=>currentForm = Form.Human);
}
...
// Activate power-up.
public IPromise ActivateWerewolfPowerup()
{
if (currentForm != Form.Wolf || currentPowerup == Powerup.Werewolf)
{
return Promise.Resolved();
}
currentPowerup = Powerup.Werewolf;
return promiseTimer.WaitFor(Random.Range(1f, 5f))
.Then(() => currentPowerup = Powerup.Werewolf);
}
}

这样,所有的定时器逻辑将有PromiseTimer有条理的处理。

你可能注意到上述变形代码的小问题!难道狼力提升可以在狼形态到时后依旧激活? 我们可以简单的扩展条件门控promise来解决。

Promise用于条件门控

你可能注意到AllRaceSequence类似于标准编程结构 andorfor。按这个思路来想,有利于帮助理解用它应用于构造条件门控及行为。

PromiseTimer基于时间条件扩展了 WaitUntilWaitWhile操作。

WaitUntil 返回promise, 它将在未来某个时刻达成判断条件后被resolved。这个条件可以是限时,这样就和WaitFor一致了,亦可以是任何返回布尔值结果的表达式。

上节中提到的威力提升函数中的逻辑错误:

1
2
3
4
5
6
7
8
9
IPromise ActivateWerewolfPowerup()
{
if (currentForm != Form.Wolf)
{
return Promise.Resolved();
}
return promiseTimer.WaitFor(Random.Range(1f, 5f));
}

若是在激发WaitFor却未完成前,currentForm 已改变,这意味着玩家已经变回人类形态,但是依旧有狼力提升,直到超时。假设角色不再变狼,该函数可以用WaitUntil修正:

1
2
3
4
5
6
7
IPromise ActivateWerewolfPromiseup()
{
var poweruptime = Random.Range(1f, 5f);
return promiseTimer.WaitUntil( t=> {
return t.elapsedTime >= powerupTime || currentForm != Form.Wolf
});
}

这里,promise仅在超时后resolve一次,或者玩家不再是狼了,无论二者谁先触发。

早先讨论Promise.All的时候,用两个promise来检测达成目标成就。可以用PromiseTimer改善:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
IPromise WaitForTimeSpentOnPUzzles(float seconds)
{
return promiseTimer.WaitUntil(timeData => {
var timePlaying = 0f;
if(!paused)
timePlaying += timeData.deltaTime;
return timePlaying >= seconds;
});
}
IPromise FinishedSpecificNumberOfLevels(int levelCount)
{
return promiseTimer.WaitUntil(_ => levelsCompleted >= levelCount);
}

使用WaitUntil 能够快速简单的搭建复杂可维护系统。例如,假设刚才的变形游戏有一个奇怪的成就系统。有一个成就这么说:周二使用海豚形态完成关卡,剩余一秒。可以想象这多么难编代码。尤其是有150+多个类似成就。我们能够使用WaitUtil去监控复杂条件集及动态响应:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
class LevelManager
{
DateTime timeOfLevelStart;
Character character;
float curTimeRemaining; // The amount of time left on the clock
float totalLevelTime = 60f; // 1 minute to finish the level.
PromiseTimer promiseTimer = new PromiseTimer();
public LevelManager()
{
timeOfLevelStart = DateTime.Now();
curTimeRemaining = totalLevelTime;
// Timer for the level,
// this time counting down so we can catch that last second
promiseTimer.WaitUntil(timeData =>
{
curTimeRemaining = totalLevelTime - timeData.elapsedTime;
return curTimeRemaining <= 0;
})
.Done();
promiseTimer.WaitUntil(_ =>
{
return
timeOfLevelStart.DayOfTheWeek == DayOfTheWeek.Tuesday &&
character.IsInDolphinMode &&
curTimeRemaining <= 1f &&
curTimeRemaining > 0f;
})
.Then(() => ShowAchievementNotification("NailedItOnDolphinDay"))
.Done();
}
}

嵌套Promise

我们已经见识过promise嵌套promise。现在更进一步来使用它。

假设开发的游戏需要新手指引功能。指引有一些动作,然后暂停等玩家交互后才可继续。我们可以以一个小promise构造大promis

1
2
3
4
5
6
7
8
9
10
11
12
13
14
IPromise MoveCamera(Vector3 endPosition, float durationSeconds)
{
var startPosition = camera.CurPosition;
return promiseTimer.WaitUntil(timeData =>
{
camera.CurPosition = Vector3.Lerp(
startPosition,
endPosition,
timeData.elapsedTime / durationSeconds
);
return IsPositionCloseEnough(camera.CurPosition, endPosition);
});
}

上述代码没有新知识点。现在写个等待玩家输入的函数

1
2
3
4
IPromise WaitForKeyInput(KeyCode key)
{
return promiseTimer.WaitUntil(_ => InputManager.GetKey(key));
}

接下来序列文本信息,允许等待玩家按空格键显示下一条消息。最后显示的文本序列并不明显需要布尔值:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
Func<IPromise> PrepTextBoxesForStage(string text, bool waitForInput)
{
return () =>
{
TextBox.Text = text;
TextBox.Show(); // Sets up the text and displays it on screen.
if (waitForInput)
{
return WaitForKeyInput(KeyCode.Space);
}
return Promise.Resolved();
}
}

现在构建指引序列。移动摄像机到特定位置,显示消息,等待输入:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
Func<IPromise> PrepTutorialStage(
Vector3 cameraPosition,
string[] texts,
KeyCode key
)
{
// If the index is the last one in the array,
// then we don't want to wait for the user's input
var textBoxes = texts.Select(
(text, index) =>
PrepTextBoxesForStage(text, index != texts.length - 1)
);
return () =>
{
return MoveCamera(cameraPosition, 2f)
.ThenSequence(() => textBoxes)
.Then(() => WaitForKeyInput(key));
}
}

接下来,假设数据库或表格中有各种阶段的描述,其可以导入成TutorialStage结构:

1
2
3
4
5
6
struct TutorialStage
{
public Vector3 Position;
public string[] Texts;
public KeyCode key;
}

把这些都放在一个小函数中,它返回一个promise,它直到指引完成才被resolve:

1
2
3
4
5
6
7
8
9
IPromise RunTutorial(IEnumerable<TutorialStage> tutorialData)
{
var tutorialStages = tutorialData
.Select(data =>
PrepTutorialStage(data.Position, data.Texts, data.key)
);
return Promise.Sequence(tutorialStages);
}

唷, 我们有指引步骤的序列,自定义上下文,能够等待玩家输入,数据驱动并且每个环节都分离独立。可以在github上看相应DEMO。

Promise对比Unity协程

Promise可替代Unity的协程。协程有其用武之地但我更喜欢promise。若你曾在任意难度下使用协程,会发现理解它会迅速变难,代码同样也会更复杂。

Unity的协程是由C#的迭代器创建的。Unity控制它,运行协程其实是返回一个迭代器。Unity然后一步一步的执行迭代器,每次调用yield 我们都将控制权交回给了Unity。接着Unity又一步步执行迭代器,知道又一次yield或最终返回。

Promse来自JS,JS并没有协程机制,尽管ES6支持了生成器,人们已经讨论如何在JS中应用协程,pros和cons将实现这一点。协程这么看来,毁誉参半。

当然,我们相信使用promse会比协程有更好的表达性及可维护的代码。

然而,协程有一个主要优势:代码流程更易读。这在线性序列代码下更易调试。阅读代码流程更频繁需要使用 yieldreturn,而不是ThenThenAll等promise API。在短线性序列代码下,协程更易读及调试。

小协程很难扩展组合成大协程。嵌套协程难于管理,你可以这么做但是一开始肯定很难实现。尽管如此,多试几次没准可以。换而言之,promise能够以各种姿势诞生大逻辑序列。这种组件模式结果让代码更可复用。你可以以高阶返回promise函数作为结束,用于编写共用逻辑去组件新行为、新且不同的游戏逻辑。重写重构promise不值一提,增加了你快速实验的能力,它是让你更具生产力,同时探索调整游戏乐趣所不可缺少的。

协程的另外一个问题就是缺乏控制,尽管你有它们。Unity自动运行协程,你对协程没有控制,他们如何前进,你对他在特定点的行为没有具体概念。这也是协程难调试的原因之一。这算是你理解代码的灾难吧。你能控制的,就是yield下,或停止协程。

停止一个协程同样有问题。我们仅能通过魔术字符串停止指定名字的协程。这影响了代码中的类型安全及打断了VS的自动重构工具使用。一个或一组promise可以很容易地通过PromiseTimer中断,且不需要任何魔术字符串。

PromiseTimer 把控制权交给我们。我们能够按照我们的意愿更新promise,甚至打印当前运行的promise列表。可以用多个PromiseTimer以不同形式去控制不同的多组promise。例如可以暂停某一组,其他组继续运行。

那协程的最大问题在哪呢?他只能在MonoBehaviour中使用!若是你的代码和Unity紧密耦合,那你的代码不可脱逃它运行。这严重限制了代码复用及测试驱动开发。若是你不懂这些,可能对你也没多大问题,继续用协程就是啦。

就是某种形式的行为树?

当我们使用promise,并拓展它用于游戏开发,是否觉得重新造了行为树)的轮子。

我们并不是在构建AI(这是行为树要做的)。我们是构建交互游戏行为。序列化声音、动画、用户交互等等。序列化关卡流,并评估进行下一关的条件。

promise和行为树有一些重合的功能。

在promise的扩展API中,我们使用All等操作并行执行异步代码。这和行为树的并行节点相类似,同样的还有Sequence

promise可以层次嵌套,意思说父promise可以在子promise resolve后resovle。行为树同样可以层次嵌套,它们是颗),递归)数据结构。

行为树的选择节点,这在promise中没有对应API,这并不是因为很难添加而是没必要。

当考虑到promise的局限后,你可能会考虑使用行为树,他们比promise功能更全。

行为树是典型的数据驱动,由游戏策划通过编辑器构建。这允许非编程人员能够直接构建,调整,平衡,是一种理想的方式构建游戏逻辑及AI。对于独立开发者而言,可能没有使用行为树编辑器的选项,甚至都没有专门的游戏策划,因为开发者常常同时也是策划。在这种情形下,代码驱动的游戏AI效果更好。我们已经推动promise到此地步,也有好结果。然而我们可以想象行为树若有代码驱动的流式API,那么在某些情形下更适合我们。

行为树更容易控制,它们被设计成可暂停,停止,重启等。promise并没有设计成这样,尽管我们可以暂停或停止一个基于时间的promise(PromiseTimer),然而,若不是不能停止一个异步操作的中间过程,例如等待网络交易结果,promise有些困难。这并不是说promise不能通过变通手段解决问题,而是你深入promise涉及到这些问题,你可能重新考虑使用行为树。这里说的是,用promise在他适合的领域,用行为树在promise难以企及的领域。

依赖情景的行为树同样更加容易调试。如果你有奢侈的行为树编辑器,你很有可能也有一些可视化的行为树调试器。这很好,但并不适合在真实代码调试器中调试。调试promise挺难的,但至少可以在调试器中去做。我们可以想象我们神奇的行为树流式API能够比promise API更好调试。

结论

尽管本文描述了我们使用promise进行游戏开发的经历。任何高级技术都有它的利弊,我们必须了解,理解风险并作出良性决策,哪些技术可以用在游戏开发中。

开发中引入了错误的技巧或技术可能导致悲剧。每件事都有其自身土壤,但是错误的应用会对你的生产产生副作用,所有要注意,保留一个备选项当特定技术不能满足你。

确保你有计划时间去学习。你不能匆匆上手并期望直接有个好用。你需要理解哪些情形是有用的,理解如何应变并减轻潜在隐患。

希望这些没吓到你! 我们只是确认你意识到你已经上路了。希望这篇介绍promise的文章能够帮助你探索promise。注意到任何高级技术可能是一个黑洞哦。

我们的C#版promise可以在github上找到。

例子也可以在github上找到

致谢
Ash and Adam

图标这么闪,你不点一下吗