游戏中过场动画和动作序列的实现

在本文中,我将讨论如何在视频游戏中实现一系列动作和过场动画。 本文是本文的翻译,并且是与我在莫斯科的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 ... ... --   ... end end 

这种方法很容易导致意大利面条式代码和if-else表达式的长链,因此我建议避免使用这种方法来解决问题。

行动清单


操作列表与状态机非常相似。 动作列表是一个接一个执行的动作的列表。 在游戏循环中,当前动作将调用update函数,即使动作花费很长时间,它也允许我们处理输入并渲染游戏。 操作完成后,我们继续进行下一个操作。

在我们要实现的过场动画中,我们需要执行以下操作:GoToAction,DialogueAction和DelayAction。

对于其他示例,我将在Lua中使用面向OOP的中产类库。

以下是DelayAction实现方式:

 --  function DelayAction:initialize(params) self.delay = params.delay self.currentTime = 0 self.isFinished = false end function DelayAction:update(dt) self.currentTime = self.currentTime + dt if self.currentTime > self.delay then self.isFinished = true end end 

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 -- ... -    actionList:update(dt) 

注意 :在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) -- will print '42 "some text"' 

ok是一个变量,它使我们可以知道协程的状态。 如果oktrue ,那么使用协程,一切都很好,内部没有发生任何错误。 其后的返回值( numtext )与传递给yield参数相同。

如果okfalse ,那么协程会出问题,例如,在其中调用了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功能。 但是这里我们使用协程,并且在游戏循环的每个迭代中都yieldAction: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 -- -  ... local c = coroutine.create(cutscene, player, npc) coroutine.resume(c, dt) 

delay功能的实现方法如下:

 function delay(time) action = DelayAction:new { delay = time } action:launch() end 

创建此类包装程序可大大提高过场代码的可读性。 DelayAction实现如下:

 -- Action -   DelayAction local DelayAction = class("DelayAction", Action) function DelayAction:initialize(params) self.delay = params.delay self.currentTime = 0 self.isFinished = false end function DelayAction:update(dt) self.currentTime = self.currentTime + dt if self.currentTime >= self.delayTime then self.finished = true end end 

此实现与我们在操作列表中使用的实现相同! 让我们再次看一下Action:launch功能:

 function Action:launch() self:init() while not self.finished do local dt = coroutine.yield() -- the most important part self:update(dt) end self:exit() end 

这里的主要内容是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 ... --  , AI else self.finished = true end end 

协程很好地处理事件。 实现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 --      while true do monster:goTo(path:getPoint(i)) if i < numberOfPoints - 1 then i = i + 1 --     else --   i = 0 end end end 



当怪物看到玩家时,我们可以简单地停止执行协程并将其删除。 因此, 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(c1, c2) --    cat:say("meow") ... end 

这里最重要的部分是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部分”的内容。 接下来,玩家将学习本教程的第二部分,为此我们将使用另一个协程。 依此类推...在加载时,我们只是开始执行与玩家必须经过的零件相对应的协程。

结论


如您所见,有几种不同的方法可以实现一系列动作和过场动画。 在我看来,协程方法非常强大,我很高兴与开发人员分享。 我希望这种解决问题的方法可以使您的生活更轻松,并允许您在游戏中制作史诗般的过场动画。

Source: https://habr.com/ru/post/zh-CN427135/


All Articles