那么我将如何设计一个重放系统?
您可能从某些游戏(例如《魔兽争霸3》或《星际争霸》)中知道了这一点,您可以在游戏玩完后再次观看。
您最终得到一个相对较小的重播文件。所以我的问题是:
- 如何保存数据?(自定义格式?)(小文件大小)
- 应该保存什么?
- 如何使其具有通用性,使其可以在其他游戏中用于记录时间段(例如,不是完整的比赛记录)?
- 使前进和后退成为可能(据我记得,WC3无法后退)
那么我将如何设计一个重放系统?
您可能从某些游戏(例如《魔兽争霸3》或《星际争霸》)中知道了这一点,您可以在游戏玩完后再次观看。
您最终得到一个相对较小的重播文件。所以我的问题是:
Answers:
这篇出色的文章涵盖了很多问题:http : //www.gamasutra.com/view/feature/2029/developing_your_own_replay_system.php
本文提到并做得很好的一些事情:
在大多数情况下,进一步提高压缩率的一种方法是将所有输入流解耦,并对它们进行完整的行程编码。如果您以8位编码运行并且运行本身超过8帧(除非您的游戏是真正的按钮混搭程序,否则很有可能),这将是delta编码技术的胜利。我在赛车游戏中使用了此技术,压缩了2位玩家的8分钟输入,同时在赛道上缩小了几百个字节。
在使此类系统可重用方面,我使重放系统处理了通用输入流,但还提供了挂钩,以允许特定于游戏的逻辑将键盘/游戏手柄/鼠标输入编组到这些流中。
如果您想快退或随机寻道,可以每N帧保存一个检查点(您的完整游戏状态)。应该选择N以减小重播文件的大小,并确保在将状态重播到所选点时播放器必须等待的时间是合理的。解决此问题的一种方法是确保只能对这些确切的检查点位置进行随机搜索。快退是将游戏状态设置为紧挨相关帧之前的检查点的问题,然后重播输入内容直至到达当前帧。但是,如果N太大,则可能每隔几帧就会出现故障。缓解这些故障的一种方法是在从当前检查点区域回放缓存的帧时,异步地预先缓存前两个检查点之间的帧。
除了“确保击键可重播”解决方案(这可能非常困难)之外,您还可以只记录每一帧的整个游戏状态。稍加压缩,即可将其大幅压缩。这就是Braid处理其倒带代码的方式,并且效果很好。
由于无论如何都需要倒退检查点,因此您可能只想在复杂的事情之前尝试以简单的方式实现它。
n
几秒钟。游戏。
您可以将系统视为由一系列状态和函数组成,其中f[j]
带有输入的函数x[j]
会将系统状态更改s[j]
为state s[j+1]
,如下所示:
s[j+1] = f[j](s[j], x[j])
状态是对整个世界的解释。玩家的位置,敌人的位置,分数,剩余的弹药等。画出游戏框架所需的一切。
功能是可能影响世界的任何事物。帧更改,按键,网络数据包。
输入是函数获取的数据。更改帧可能要花费自上一帧通过以来的时间,按键可能包括实际按下的按键以及是否按下了Shift键。
为了便于说明,我将作以下假设:
假设1:
给定游戏运行的状态数量远大于功能的数量。您可能有成千上万个状态,但只有几十个功能(帧更改,按键,网络数据包等)。当然,输入量必须等于状态量减一。
假设2:
存储单个状态的空间成本(内存,磁盘)比存储函数及其输入的空间成本大得多。
假设3:
呈现状态的时间成本(时间)相近,或者比在状态上计算函数的时间成本(时间)长一个或两个数量级。
根据您的重放系统的要求,有几种方法可以实现重放系统,因此我们可以从最简单的方法开始。我还将用棋盘游戏作一个小例子,记录在纸上。
方法1:
商店s[0]...s[n]
。这非常简单,非常简单。由于假设2,其空间成本相当高。
对于国际象棋,这将通过为每一步绘制整个棋盘来完成。
方法2:
如果只需要向前重放,则可以简单地存储s[0]
,然后存储f[0]...f[n-1]
(请记住,这只是函数ID的名称)和x[0]...x[n-1]
(这些函数的输入是什么)。要重播,您只需从开始s[0]
,然后计算
s[1] = f[0](s[0], x[0])
s[2] = f[1](s[1], x[1])
等等...
我想在这里做一个小的注释。其他几位评论员说,这款游戏“必须具有确定性”。凡是这样说的人都需要重新获得计算机科学101的资格,因为除非您的游戏要在量子计算机上运行,否则所有计算机程序都是确定的¹。这就是使计算机如此出色的原因。
但是,由于您的程序很可能依赖于外部程序,从库到CPU的实际实现,因此确保平台之间的功能相同可能非常困难。
如果使用伪随机数,则可以将生成的数字存储为输入的一部分,也可以x
将prng函数的状态存储为状态的一部分s
,并将其实现存储为function的一部分f
。
对于国际象棋,这可以通过绘制初始棋盘(已知)来完成,然后描述每个动作并说明哪一块去了哪里。顺便说一下,这就是他们实际的做法。
方法3:
现在,您很可能希望能够进行重放。也就是说,计算s[n]
任意n
。通过使用方法2,您需要先进行计算,s[0]...s[n-1]
然后才能s[n]
根据假设2 进行计算,这可能会非常慢。
为了实现这一点,方法3是方法1和2的概括:存储f[0]...f[n-1]
,x[0]...x[n-1]
就像方法2一样s[j]
,对于j % Q == 0
给定的常量,都存储Q
。简单来说,这意味着您将书签存储在每个Q
州中的一个州。例如,对于Q == 100
,您存储s[0], s[100], s[200]...
为了计算s[n]
任意值n
,您首先加载先前存储的s[floor(n/Q)]
,然后从floor(n/Q)
到计算所有函数n
。最多,您将在计算Q
函数。较小的值Q
可以更快地计算但会占用更多的空间,而较大的值会Q
消耗较少的空间,但需要较长的时间来计算。
方法3与Q==1
方法1相同,而方法3与Q==inf
方法2相同。
对于国际象棋,这可以通过绘制每一个动作以及每10个棋盘中的一个来完成Q==10
。
方法4:
如果要反转重播,可以使方法3的微小变化假设Q==100
,你要计算s[150]
通过s[90]
反向。使用未经修改的方法3,您将需要进行50次计算s[150]
,然后再进行49次计算s[149]
,依此类推。但是,由于您已经计算过s[149]
要获取get s[150]
,因此可以在第一次s[100]...s[150]
计算时使用来创建一个缓存s[150]
,然后s[149]
在需要显示它时就已经在缓存中。
对于任何给定的值s[j]
,只需要在每次计算时重新生成缓存即可。这次,增加将导致较小的大小(仅用于高速缓存),但时间较长(仅用于重新创建高速缓存)。如果您知道计算状态和函数所需的大小和时间,则可以计算出的最佳值。j==(k*Q)-1
k
Q
Q
对于国际象棋,这可以通过绘制每一个动作以及每10个棋盘中的一个完成(对于Q==10
)来完成,但是,还需要绘制一张单独的纸,即您计算出的最后10个棋盘。
方法5:
如果状态仅占用太多空间,或者函数占用太多时间,则可以创建一个实际上实现(而非伪造)反向重放的解决方案。为此,必须为每个功能创建反向功能。但是,这要求您的每个功能都是注入。如果这是可行的,那么为了f'
表示函数的逆f
,计算s[j-1]
就像
s[j-1] = f'[j-1](s[j], x[j-1])
请注意,此处的函数和输入均为j-1
,不是j
。如果您要进行计算,则将使用相同的函数和输入
s[j] = f[j-1](s[j-1], x[j-1])
创建这些函数的逆函数是棘手的部分。但是,您通常不能这样做,因为游戏中的每个功能通常都会丢失一些状态数据。
此方法可以按原样反向计算s[j-1]
,但前提是您有s[j]
。这意味着从您决定向后重放的点开始,您只能向后观看重放。如果要从任意点向后回放,则必须将此方法与方法4混合使用。
对于国际象棋,这是无法实现的,因为对于给定的棋盘和先前的移动,您可以知道移动的是哪一块,但不知道从哪移动了。
方法6:
最后,如果不能保证所有功能都是注入,则可以通过一些小技巧来实现。除了让每个函数只返回一个新的状态,您还可以让它返回它丢弃的数据,如下所示:
s[j+1], r[j] = f[j](s[j], x[j])
r[j]
丢弃的数据在哪里。然后创建您的反函数,以使它们采用丢弃的数据,如下所示:
s[j] = f'[j](s[j+1], x[j], r[j])
除f[j]
和之外x[j]
,还必须r[j]
为每个函数存储。再一次,如果您希望能够搜索,则必须存储书签,例如方法4。
对于国际象棋,这与方法2相同,但是与方法2不同,方法2仅说明哪一块棋子到达何处,还需要存储每块棋子来自何处。
实现方式:
由于这适用于特定游戏的各种状态,各种功能,因此您可以做几个假设,这将使其易于实现。实际上,如果您在整个游戏状态下实现方法6,则不仅可以重播数据,还可以及时返回并从任何给定时刻恢复播放。那真是太棒了。
除了存储所有游戏状态外,您还可以简单地存储绘制给定状态所需的最低要求,并每隔固定的时间序列化此数据。您的状态将是这些序列化,而您的输入现在将是两个序列化之间的差异。它们起作用的关键是,如果世界状态也几乎没有变化,则序列化应该几乎不变。这种差异是完全不可逆的,因此使用书签实现方法5是非常可能的。
我已经在一些主要游戏中实现了此功能,主要是当事件(fps片段减少或体育游戏中的得分)发生时即时回放最近的数据。
我希望这个解释不要太无聊。
¹这并不意味着某些程序的行为就像不确定的(例如MS Windows ^^)。现在说真的,如果您可以在确定性计算机上进行非确定性程序,则可以肯定地说,您将同时赢得菲尔兹奖章,图灵奖,甚至还有可能获得奥斯卡奖和格莱美奖。
其他答案尚未涵盖的一件事是漂浮的危险。您不能使用浮点数创建完全确定的应用程序。
使用浮点数,您可以拥有一个完全确定性的系统,但前提是:
这是因为浮点数的内部表示从一个CPU到另一个CPU有所不同-在AMD和Intel CPU之间最为明显。只要值在FPU寄存器中,它们的精度就比C端的精度高,因此任何中间计算都将以更高的精度进行。
很明显,这将如何影响AMD与Intel位-例如,假设一个使用80位浮点数,而另一个使用64位浮点数-但是为什么要使用相同的二进制要求呢?
如我所说,只要值在FPU寄存器中,就可以使用更高的精度。这意味着无论何时重新编译,编译器优化都可能在FPU寄存器内外交换值,从而导致结果略有不同。
您可能可以通过设置_control87()/ _ controlfp()标志来使用可能的最低精度来提供帮助。但是,某些库也可能会碰到这一点(至少某些d3d版本这样做了)。
保存您的随机数生成器的初始状态。然后保存,加盖时间戳,每个输入(鼠标,键盘,网络等)。如果您有网络游戏,则可能已经具备了所有功能。
重新设置RNG并播放输入。而已。
这不能解决重绕问题,因为没有从头开始尽可能快地回放的方法,没有通用解决方案。您可以通过每X秒检查一次整个游戏状态来提高性能,然后只需要重播那么多游戏即可,但是整个游戏状态的获取成本也可能非常高。
文件格式的详细信息无关紧要,但是大多数引擎已经有一种方法可以序列化命令和状态-用于联网,保存等。只是使用它。
我会投票反对确定性重放。每1 / N秒保存每个实体的状态更容易实现FAR且不易出错。
只保存要在回放中显示的内容-如果只是位置和方向,还可以,如果您还想显示统计信息,也可以保存,但通常尽可能少保存。
调整编码。尽可能少地使用所有内容。只要看起来足够好,重播就不必是完美的。即使您使用浮点数作为标题,也可以将其保存为一个字节,并获得256个可能的值(1.4º精度)。对于您的特定问题,这可能足够,甚至太多。
使用增量编码。除非您的实体进行传送(如果确实如此,则将它们分开对待),将位置编码为新位置和旧位置之间的差异-对于短距离的移动,您获得的位数远少于完整位置所需的位数。
如果要轻松倒带,请每N帧添加关键帧(完整数据,无增量)。这样,您就可以以较低的精度获得差值和其他值,如果定期重置为“ true”值,则舍入错误不会造成太大问题。
最后,用gzip压缩整个内容:)
这个很难(硬。首先,最重要的是阅读Jari Komppa的答案。
由于浮动结果略有不同,因此在我的计算机上进行的重播可能无法在您的计算机上进行。这很重要。
但是之后,如果您有随机数,则将种子值存储在重播中。然后加载所有默认状态并将随机数设置为该种子。从那里您可以简单地记录当前的键/鼠标状态及其持续的时间。然后使用该事件作为输入运行所有事件。
要跳动文件(这要困难得多),您需要转储MEMORY。就像每个单位都在哪里,金钱,时间长短以及所有游戏状态一样。然后快速转发,但重播所有内容,但跳过渲染,声音等,直到到达所需的时间目标为止。这可能每分钟或5分钟发生一次,具体取决于转发的速度。
要点是-处理随机数-复制输入(播放器和远程播放器)-转储文件的状态,以便跳出文件和...-使浮动不中断(是的,我不得不大喊大叫)
对于没有人提及此选项,我感到有些惊讶,但是如果您的游戏包含多人游戏组件,则可能已经完成了该功能所需的许多艰苦工作。毕竟,什么是多人游戏,但是尝试在(略微)不同的时间在您自己的计算机上重播其他人的动作?
再次假设您一直在研究带宽友好的网络代码,这也为您带来了较小文件大小的好处。
它在许多方面都结合了“非常确定性”和“保留所有记录”选项。您仍然需要确定性-如果您的重播本质上是机器人以与您最初玩游戏时完全相同的方式再次玩游戏,那么他们采取的任何可能产生随机结果的动作都必须具有相同的结果。
数据格式可能就像网络流量的转储一样简单,尽管我想稍微清理一下不会有什么坏处(毕竟,您不必担心重放延迟)。通过使用其他人提到的检查点机制,您可以只重玩游戏的一部分-通常,多人游戏无论如何都会经常发送完整的游戏更新状态,因此您可能已经完成了这项工作。
为了获得尽可能小的重播文件,您需要确保您的游戏具有确定性。通常,这涉及查看您的随机数生成器,并查看它在游戏逻辑中的使用位置。
您很可能需要具有游戏逻辑RNG,以及用于GUI,粒子效果,声音之类的其他所有RNG。完成此操作后,需要记录游戏逻辑RNG的初始状态,然后记录每帧所有玩家的游戏命令。
对于许多游戏而言,输入与游戏逻辑之间存在一定程度的抽象,其中输入被转换为命令。例如,按下控制器上的A按钮会导致将“跳转”数字命令设置为true,并且游戏逻辑会对命令做出反应,而无需直接检查控制器。这样,您只需记录会影响游戏逻辑的命令(无需记录“暂停”命令),并且该数据很可能会比记录控制器数据小。您也不必担心记录控制方案的状态,以防玩家决定重新映射按钮。
使用确定性方法而不是使用游戏状态的快照并快进到要查看的时间点,倒退是一个难题,除了在每一帧记录整个游戏状态外,您无能为力。
另一方面,快进无疑是可行的。只要您的游戏逻辑不依赖于渲染,就可以在渲染游戏的新框架之前根据需要多次运行游戏逻辑。快进的速度将取决于您的计算机。如果要大幅度跳过,则需要使用与倒带相同的快照方法。
编写依赖于确定性的重放系统,最重要的部分可能就是记录调试数据流。该调试流包含每个帧(RNG种子,实体转换,动画等)中尽可能多的信息的快照,并且能够在重播期间针对游戏状态测试记录的调试流。这样一来,您便可以在任何给定帧的末尾快速让您知道不匹配的情况。这将节省无数小时想要从未知的不确定性错误中拔出头发的时间。诸如未初始化的变量之类的简单操作会在第11小时将所有内容弄乱。
注意:如果您的游戏涉及动态内容流,或者您在多个线程或不同内核上具有游戏逻辑,请祝您好运。
要同时启用记录和倒带,请记录所有事件(用户生成,计时器生成,通信生成...)。
对于每个事件,记录事件的时间,更改的内容,以前的值,新值。
除非计算是随机的,否则不需要
记录计算值(在这些情况下,您也可以记录计算值,或在每次随机计算后记录对种子的更改)。
保存的数据是更改列表。
更改可以以各种格式(二进制,xml等)保存。
更改包括实体ID,属性名称,旧值,新值。
确保您的系统可以播放这些更改(访问所需实体,将所需属性更改为前进到新状态或后退到旧状态)。
例:
为了能够更快地快退/快进或仅记录特定的时间范围,
关键帧是必要的-如果始终记录,请立即保存整个游戏状态。
如果仅记录特定时间范围,则在开始时保存初始状态。
如果您需要有关如何实现重放系统的想法,请在google中搜索如何在应用程序中实现撤消/重做。对于某些人(可能不是全部),显而易见的是,撤消/重做与游戏重播在概念上是相同的。这只是一种特殊情况,您可以倒退,然后根据应用程序寻找特定的时间点。
您会看到没有人执行撤消/重做,抱怨确定性/不确定性,浮点变量或特定的CPU。