在本文中,我将讨论如何在视频游戏中实现一系列动作和过场动画。 本文是本文的翻译,并且是与我在莫斯科的Lua上发表的主题相同的主题,因此,如果您喜欢观看视频,可以在这里观看。
文章代码是用Lua编写的,但是可以轻松地用其他语言编写(使用协程的方法除外,因为协程不是全部语言)。
本文介绍了如何创建一种机制,使您可以编写以下形式的过场动画:
local function cutscene(player, npc) player:goTo(npc) if player:hasCompleted(quest) then npc:say("You did it!") delay(0.5) npc:say("Thank you") else npc:say("Please help me") end end
参赛作品
动作序列经常在视频游戏中找到。 例如,在过场动画中:角色遇到敌人,对他说些什么,敌人回答等等。 动作顺序可以在游戏中找到。 看一下这个gif:

1.门打开
2.角色进入房屋
3.门关上
4.屏幕逐渐变暗
5.级别变化
6.屏幕明亮地消失
7.角色进入咖啡厅
动作序列也可以用于编写NPC的行为脚本或执行首领战斗,其中首领一个接一个地执行某些动作。
问题
标准游戏循环的结构使动作序列的实现变得困难。 假设我们有以下游戏循环:

while game:isRunning() do processInput() dt = clock.delta() update(dt) render() end
我们要实现以下场景:玩家接近NPC,NPC说:“您做到了!”,然后稍停片刻后说:“谢谢!”。 在理想的世界中,我们将这样写:
player:goTo(npc) npc:say("You did it!") delay(0.5) npc:say("Thank you")
在这里,我们面临一个问题。 完成这些步骤需要一些时间。 某些动作甚至可能等待播放器的输入(例如,关闭对话框)。 除了
delay
功能,您不能调用相同的
sleep
-看起来游戏已冻结。
让我们看一下解决问题的几种方法。
布尔,枚举,状态机
实施一系列操作的最明显方法是将有关当前状态的信息存储在布尔,行或枚举中。 该代码将如下所示:
function update(dt) if cutsceneState == 'playerGoingToNpc' then player:continueGoingTo(npc) if player:closeTo(npc) then cutsceneState = 'npcSayingYouDidIt' dialogueWindow:show("You did it!") end elseif cutsceneState == 'npcSayingYouDidIt' then if dialogueWindow:wasClosed() then cutsceneState = 'delay' end elseif ... ...
这种方法很容易导致意大利面条式代码和if-else表达式的长链,因此我建议避免使用这种方法来解决问题。
行动清单
操作列表与状态机非常相似。 动作列表是一个接一个执行的动作的列表。 在游戏循环中,当前动作将调用
update
函数,即使动作花费很长时间,它也允许我们处理输入并渲染游戏。 操作完成后,我们继续进行下一个操作。
在我们要实现的过场动画中,我们需要执行以下操作:GoToAction,DialogueAction和DelayAction。
对于其他示例,我将在Lua中使用面向OOP的中产类库。
以下是
DelayAction
实现方式:
ActionList:update
函数如下所示:
function ActionList:update(dt) if not self.isFinished then self.currentAction:update(dt) if self.currentAction.isFinished then self:goToNextAction() if not self.currentAction then self.isFinished = true end end end end
最后,过场动画本身的实现:
function makeCutsceneActionList(player, npc) return ActionList:new { GoToAction:new { entity = player, target = npc }, SayAction:new { entity = npc, text = "You did it!" }, DelayAction:new { delay = 0.5 }, SayAction:new { entity = npc, text = "Thank you" } } end
注意 :在Lua中,可以这样调用
someFunction({ ... })
:
someFunction{...}
。 这使您可以编写
DelayAction:new{ delay = 0.5 }
而不是
DelayAction:new({delay = 0.5})
。
看起来好多了。 该代码清楚地显示了操作顺序。 如果我们想添加一个新动作,我们可以轻松地做到这一点。 创建诸如
DelayAction
类的类非常简单,以使编写过场动画更加方便。
我建议您看一下Sean Middleditch关于动作列表的演示,其中提供了更复杂的示例。
操作列表通常非常有用。 我将它们用于我的游戏已经有一段时间了,总体上还是很高兴的。 但是这种方法也有缺点。 假设我们要实现稍微复杂一点的过场动画:
local function cutscene(player, npc) player:goTo(npc) if player:hasCompleted(quest) then npc:say("You did it!") delay(0.5) npc:say("Thank you") else npc:say("Please help me") end end
要进行if / else仿真,您需要实现非线性列表。 这可以使用标签来完成。 可以对某些动作进行标记,然后根据某种条件,而不是转到下一个动作,可以转到具有所需标签的动作。 它可以工作,但是它不像上面的函数那样容易读写。
Lua协程使此代码成为现实。
协程
Lua的Corua基础知识
Corutin是一种可以暂停然后再恢复的功能。 协程与主程序在同一线程中执行。 没有为协程创建任何新线程。
要暂停
coroutine.yield
,您需要调用
coroutine.yield
来恢复
coroutine.resume
。 一个简单的例子:
local function f() print("hello") coroutine.yield() print("world!") end local c = coroutine.create(f) coroutine.resume(c) print("uhh...") coroutine.resume(c)
程序输出:
你好
呃...
世界
运作方式如下。 首先,我们使用
coroutine.create
创建
coroutine.create
。 此调用后,corutin无法启动。 为此,我们需要使用
coroutine.resume
运行它。 然后调用函数
f
,该函数将写入“ hello”,并使用
coroutine.yield
暂停自身。 这类似于
return
,但是我们可以使用
coroutine.resume
恢复
f
。
如果在调用
coroutine.yield
时传递参数,那么它们将成为“主流”中对
coroutine.resume
的相应调用的返回值。
例如:
local function f() ... coroutine.yield(42, "some text") ... end ok, num, text = coroutine.resume(c) print(num, text)
ok
是一个变量,它使我们可以知道协程的状态。 如果
ok
是
true
,那么使用协程,一切都很好,内部没有发生任何错误。 其后的返回值(
num
,
text
)与传递给
yield
参数相同。
如果
ok
是
false
,那么协程会出问题,例如,在其中调用了
error
函数。 在这种情况下,第二个返回值将是一条错误消息。 协程示例中发生错误:
local function f() print(1 + notDefined) end c = coroutine.create(f) ok, msg = coroutine.resume(c) if not ok then print("Coroutine failed!", msg) end
结论:
协程失败了! 输入:4:尝试对nil值执行算术(全局'notDefined')
coroutine.status
状态可以通过调用
coroutine.status
获得。 Corutin可能处于以下情况:
- “正在运行”-协程当前正在运行。
coroutine.status
是从corutin本身调用的 - “已暂停”-Corutin已暂停或从未启动
- “正常”-corutin已激活,但未执行。 也就是说,corutin在自身内部发射了另一种corutin
- “死”-协程已完成执行(即协程中的功能已完成)
现在,借助这一知识,我们可以实现基于协同程序的动作序列和过场动画的系统。
使用corutin创建过场动画
这是基本
Action
类在新系统上的外观:
function Action:launch() self:init() while not self.finished do local dt = coroutine.yield() self:update(dt) end self:exit() end
该方法类似于动作列表:在动作完成之前,将调用动作的
update
功能。 但是这里我们使用协程,并且在游戏循环的每个迭代中都
yield
(
Action:launch
从某个协程调用
Action:launch
)。 在游戏循环
update
某处,我们像这样恢复当前过场动画的执行:
coroutine.resume(c, dt)
最后,创建一个过场动画:
function cutscene(player, npc) player:goTo(npc) npc:say("You did it!") delay(0.5) npc:say("Thank you") end
delay
功能的实现方法如下:
function delay(time) action = DelayAction:new { delay = time } action:launch() end
创建此类包装程序可大大提高过场代码的可读性。
DelayAction
实现如下:
此实现与我们在操作列表中使用的实现相同! 让我们再次看一下
Action:launch
功能:
function Action:launch() self:init() while not self.finished do local dt = coroutine.yield()
这里的主要内容是
while
,该循环一直运行到动作完成为止。 看起来像这样:

现在让我们看一下
goTo
函数:
function Entity:goTo(target) local action = GoToAction:new { entity = self, target = target } action:launch() end function GoToAction:initialize(params) ... end function GoToAction:update(dt) if not self.entity:closeTo(self.target) then ...
协程很好地处理事件。 实现
WaitForEventAction
类:
function WaitForEventAction:initialize(params) self.finished = false eventManager:subscribe { listener = self, eventType = params.eventType, callback = WaitForEventAction.onEvent } end function WaitForEventAction:onEvent(event) self.finished = true end
此功能不需要
update
方法。 它将执行(尽管它不会做任何事情……),直到收到具有所需类型的事件为止。 这是此类的实际应用
say
函数的实现:
function Entity:say(text) DialogueWindow:show(text) local action = WaitForEventAction:new { eventType = 'DialogueWindowClosed' } action:launch() end
简单易读。 对话框关闭时,将分派“ DialogueWindowClosed”类型的事件。 say动作结束,下一个动作开始执行。
使用协程,您可以轻松创建非线性过场动画和对话框树:
local answer = girl:say('do_you_love_lua', { 'YES', 'NO' }) if answer == 'YES' then girl:setMood('happy') girl:say('happy_response') else girl:setMood('angry') girl:say('angry_response') end

在此示例中,
say
函数比我之前显示的函数稍微复杂一些。 它会在对话中返回玩家的选择,但这并不难实现。 例如,可以在
WaitForEventAction
使用
WaitForEventAction
,它将捕获PlayerChoiceEvent事件,然后返回其信息将包含在事件对象中的播放器的选择。
稍微复杂一些的例子
使用协程,您可以轻松创建教程和小任务。 例如:
girl:say("Kill that monster!") waitForEvent('EnemyKilled') girl:setMood('happy') girl:say("You did it! Thank you!")

协程也可以用于AI。 例如,您可以创建一个函数,怪物可以使用该函数沿某些轨迹移动:
function followPath(monster, path) local numberOfPoints = path:getNumberOfPoints() local i = 0

当怪物看到玩家时,我们可以简单地停止执行协程并将其删除。 因此,
followPath
内部的无限循环(
while true
)并不是真正的无限。
使用corutin,您可以执行“并行”操作。 在两个动作都完成之前,过场动画不会继续进行下一个动作。 例如,我们将制作一个过场动画,女孩和猫以不同的速度跑到朋友的位置。 他们来找她后,猫说“喵”。
function cutscene(cat, girl, meetingPoint) local c1 = coroutine.create( function() cat:goTo(meetingPoint) end) local c2 = coroutine.create( function() girl:goTo(meetingPoint) end) c1.resume() c2.resume()
这里最重要的部分是
waitForFinish
函数,它是
WaitForFinishAction
类的包装,可以按以下方式实现:
function WaitForFinishAction:update(dt) if coroutine.status(self.c1) == 'dead' and coroutine.status(self.c2) == 'dead' then self.finished = true else if coroutine.status(self.c1) ~= 'dead' then coroutine.resume(self.c1, dt) end if coroutine.status(self.c2) ~= 'dead' then coroutine.resume(self.c2, dt) end end
如果允许同步第N个动作,则可以使此类更强大。
您还可以创建一个类,等待
一个协程完成,而不是等待
所有协程完成执行。 例如,它可以用于赛车迷你游戏。 在协程内部,将等待一名骑手到达终点线,然后执行一些动作序列。
Corutin的优缺点
协程是一个非常有用的机制。 使用它们,您可以编写易于阅读和修改的过场动画和游戏代码。 这类过场动画可以轻松地由修改者或非程序员(例如,游戏或关卡设计师)编写。
而且所有这些都是在一个线程中执行的,因此同步或
竞态条件没有问题。
该方法具有缺点。 例如,保存可能存在问题。 假设您的游戏中有一个使用协程实现的很长的教程。 在本教程中,播放器将无法保存,因为 为此,您需要保存协程的当前状态(包括协程的整个堆栈以及内部变量的值),以便在从保存中进一步加载时,可以继续本教程。
(
注意 :使用
PlutoLibrary库
,协程可以序列化,但该库仅适用于Lua 5.1。)
过场动画不会发生此问题,因为 通常在游戏中过场动画是不允许的。
如果将它分成小块,则可以解决长篇教程的问题。 假设玩家完成了本教程的第一部分,并且必须进入另一个房间才能继续本教程。 此时,您可以设置检查点或为玩家提供保存的机会。 在保存中,我们将编写类似“玩家完成了教程的第1部分”的内容。 接下来,玩家将学习本教程的第二部分,为此我们将使用另一个协程。 依此类推...在加载时,我们只是开始执行与玩家必须经过的零件相对应的协程。
结论
如您所见,有几种不同的方法可以实现一系列动作和过场动画。 在我看来,协程方法非常强大,我很高兴与开发人员分享。 我希望这种解决问题的方法可以使您的生活更轻松,并允许您在游戏中制作史诗般的过场动画。