TDD 和游戏物理
我正在玩一个小型游戏项目,由于我在 TDD 方面经验不是很丰富,所以我很想就一些事情获得一些专家的意见。
首先,我很早就意识到 TDD 似乎并不适合游戏开发。似乎人们对这个问题的看法差异很大。我最初未经教育的观点是,TDD 似乎对所有游戏逻辑都很有效。我心想,任何处理视频输出和声音的东西都会被抽象成可以进行视觉测试的类。
事情开始得很顺利。目标是创建一个 2d 太空飞行游戏(对于那些关心的人来说是小行星)。我为 Ship 类创建了一系列单元测试。像初始化、旋转这样的事情可以很容易地在一系列测试中进行测试,例如:GetRotation()、TurnRotateRightOn()、Update(1)、GetRotation()、Expect_NE(rotation1、rotation2)。然后我遇到了第一个问题。
我对 TDD 的理解是,您应该按照您认为应该使用类的方式编写测试。我希望船能够移动,所以我写了一个基本上说的课程。获取坐标 ()、ThrustOn()、更新 (1)、获取坐标 ()。确保船移动到某个地方就很好了。然而,我很快意识到我必须确保船以正确的方向和正确的速度行驶。接下来是 75 行单元测试,我基本上必须初始化旋转、检查坐标、初始化推力、更新船舶、获取新坐标、检查新旋转。更重要的是,我认为不需要在游戏中获取船舶的速度(只是坐标,船舶应该自行更新)。因此我没有直接的方法来获得速度。因此,测试基本上必须重新计算速度应该是多少,这样我就可以确保它与更新后得到的坐标相匹配。总而言之,这非常混乱,而且非常耗时,但很有效。测试失败,测试通过,等等。
这一切都很好,直到后来我意识到我想将船舶的更新代码重构为抽象的“Actor”类。我意识到,虽然 Actor 的每个子类都需要能够正确计算新位置,但并非每个子类都必须以相同的方式更新其速度(有些碰撞,有些不碰撞,有些具有静态速度)。现在,我基本上面临着复制和更改庞大的测试代码的前景,我不禁认为应该有更好的方法。
有谁有处理单元测试这种复杂的黑盒类型工作的经验吗?看起来我基本上必须在测试中编写完全相同的物理代码,这样我才能知道结果应该是什么。这似乎真的是自我挫败,而且我确信我在这个过程中的某个地方错过了所有这一切的要点。我非常感谢任何人可以提供的任何帮助或建议。
I'm playing around with a small game project and as I'm not very experienced in TDD I'd love to get some expert opinions on a couple of things.
First of all, I realized early on that TDD did not seem ideal for game development. It seems that opinions vary pretty wildly on the subject. My initial uneducated opinion was that TDD seemed as though it would work very well for all the game logic. I thought to myself that anything that would deal with video output and sound would be abstracted into classes that would be tested visually.
Things started off well. The goal was to create a 2d space flight game (asteroids for those that care). I created a series of unit tests for the Ship class. Things like initialization, rotation, can easily be tested in a series such as: GetRotation(), TurnRotateRightOn(), Update(1), GetRotation(), Expect_NE(rotation1, rotation2). Then I hit the first problem.
My understanding of TDD is that you should write the test how you think you should use the class. I want the ship to be able to move so I wrote a class that basically said. GetCoordinates(), ThrustOn(), Update(1), GetCoordinates(). That was fine to make sure the ship moved somewhere. However, I quickly realized I had to make sure the ship was moving in the correct direction and at the correct speed. What followed was a 75 line unit test where I basically had to initialize the rotation, check the coordinates, initialize thrust, update the ship, get the new coordinates, check the new rotation. What's more, I can see no need to ever get the ship's velocity in the game (just the coordinates, the ship should update itself). Because of this I had no direct way of getting the velocity. So the test basically had to recalculate what the velocity should've been just so I could make sure it matched up with what coordinates I was getting after the update. All in all this was very messy, and very time consuming, but worked. The test failed, made the test pass, etc.
This was fine until later when I realized I wanted to refactor the update code of the ship into an abstract "Actor" class. I realized that while every subclass of Actor would need to be able to correctly calculate a new position, not every subclass would necessarily update their velocity the same way (some collide, some don't, some have static velocities). Now, I'm basically faced with the prospect of duplicating and altering that huge and massive test code and I can't help but think there should be a better way.
Does anyone have any experience in dealing with unit testing this sort of complex black box type of workings? It seems like I'm basically having to write the exact same physics code in the test just so I know what the result is supposed to be. It seems self defeating really, and I'm sure I'm missing the point of all this somewhere along the way. I'd greatly appreciate any help or advice that anyone could offer.
如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。
绑定邮箱获取回复消息
由于您还没有绑定你的真实邮箱,如果其他用户或者作者回复了您的评论,将不能在第一时间通知您!
发布评论
评论(3)
我建议您首先创建一个组件,在给定一系列控制输入的情况下计算位置和方向。然后,该组件构成一个用于测试目的的“单元”。该组件的测试用例将练习您能想到的所有场景:零加速度、恒定非零加速度、脉冲加速度命令等。如果应用程序不需要速度,则该组件将不会公开任何功能与速度有关。
当生成包含在测试中的预期输出时,对这些预期结果的正确性保持高度信心非常重要。因此,需要最大限度地减少生成预期结果所需的代码量。特别是,如果
您发现自己编写的测试脚手架几乎与被测组件一样复杂,那么测试本身出现错误的前景就成为一个严重的问题。
在这种情况下,我将直接从运动方程生成测试数据。我使用 Mathematica 来达到这个目的,因为我可以直接输入方程,求解它们,然后生成结果的图表和表格。这些图表让我能够直观地看到结果,从而确信它们是可信的正确的。 Excel / OpenOffice / Google Apps 以及 Mathematica 的开源替代品(如 Sage)都可以用于相同目的。无论选择什么,关键问题是能够求解运动方程而无需编写重要的代码。
一旦我们有了一组好的测试用例以及预期的结果,我们就可以编写单元测试的代码。请注意,测试代码非常简单,本身不执行任何计算。它只是将组件的输出与我们之前获得的硬编码结果进行比较。测试用例就位后,我们可以编写组件本身,添加代码直到测试全部通过。当然,在严格的 TDD 中,这些操作完全按照这个顺序发生。我承认,我个人并不坚持瀑布式开发,而是倾向于在创建测试数据、编写测试和编写组件代码之间来回切换。
Mathematica 或 Excel 文档本身的使用寿命超出了最初创建测试的时间。当添加新功能或(但愿)以后发现错误时,它们可以再次使用。我主张像源代码一样对待文档。
在本次练习结束时,结果是一个“防弹”组件,我们确信自己将在任何给定的控制输入集下计算对象的正确位置和方向。在此基础上,我们可以构建更多使用该功能的组件,例如船舶、小行星、碟子和镜头。为了避免每个组件的测试用例组合爆炸,我将放弃严格的黑盒测试方法。因此,例如,如果我们正在测试“船舶”组件,我们将编写知道它使用位置/方向组件的测试。利用这种白盒知识,我们可以避免重新测试与运动相关的所有极端情况。船舶单元测试可以执行“冒烟测试”,验证船舶实际上是否响应控制输入,但主要重点是测试船舶组件本身独有的任何功能。
因此,总结一下:
I suggest that you start by creating a component that computes position and orientation given a sequence of control inputs. That component then constitutes a "unit" for the purpose of testing. The test cases for that component would exercise all of the scenarios that you can think of: zero acceleration, constant non-zero acceleration, pulsed acceleration commands, etc. If the application has no need for velocity, then the component will not expose any functionality related to velocity.
When generating the expected outputs for inclusion in the tests, it is important to have high confidence that those expected results are correct. For this reason, one needs to minimize the amount of code required to generate the expected results. In particular, if
you find yourself writing test scaffolding that is nearly as complex as the component under test, then the prospect of bugs appearing the tests themselves becomes a serious concern.
In this case, I would generate the test data directly from the equations of motion. I use Mathematica for this purpose as I can enter the equations directly, solve them and then generate graphs and tables of the results. The graphs let me visualize the results and thereby have confidence that they are credibly correct. Excel / OpenOffice / Google Apps could be used for the same purpose, as well as open source alternatives to Mathematica like Sage. Whatever one chooses, the key concern is to be able to solve the equations of motion without having to write non-trivial code.
Once we have a good set of test cases along with the expected results, we can code up the unit test. Note that the test code is very simple, performing no calculations itself. It simply compares the component's output to the hard-coded results that we obtained earlier. With the test cases in place, we can write the component itself, adding code until the tests all pass. Of course, in strict TDD these actions happen in exactly this order. I confess that I don't personally stick to the waterfall and tend to bounce back and forth between creating test data, writing tests and writing component code.
The Mathematica or Excel documents themselves have a useful life beyond the initial creation of the tests. They can be used again when new functionality is added or (heaven forbid) should bugs be found later. I would advocate treating the documents like source code.
At the end of this exercise, the result is a "bomb-proof" component that we have convinced ourselves will calculate the correct position and orientation for an object under any given set of control inputs. From this foundation, we can build further components that use that functionality, like ships, asteroids, saucers and shots. In order to avoid a combinatorial explosion of test cases for each component, I would depart from a strict black-box testing approach. So, for example, if we were testing a "ship" component, we would write tests knowing that it uses the position/orientation component. Using this white-box knowledge, we can avoid retesting all of the corner cases related to motion. The ship unit tests can perform "smoke tests" that verify that ships do, in fact, respond to control inputs but the main focus would be on testing any functionality unique to the ship component itself.
So, to summarize:
你可以简化你的测试。您可以简单地检查被测物体是否移动,而不是检查正确的矢量和加速度。在大多数游戏中,无论如何,您都会在物理模型中引入一些少量的随机性,以使事情变得有趣。
You could dumb down your tests a bit. Instead of checking for the correct vector and acceleration, you could simply check, that the object under test moves at all. In most games you would introduce some small amount of randomness into the physics model anyway to keep things interesting.
我认为如果您采取更“情景式”的方法可能会有所帮助。您可以简单地指定您的太空飞船应该在 30 个游戏步骤后的位置以及旋转角度,而不是像您似乎正在做的那样跟踪连续体的坐标和旋转。如果事实证明这是正确的,那么你的船可能也做了所有正确的事情。这样您将编写更少的测试代码。
同样的想法也适用于针对碰撞的“事件”。如果台球要从两堵墙上弹起并击中另一个球,您可以在最终碰撞发生时引发一个事件,并检查碰撞的“入射角”。再次强调,您没有检查其间的所有步骤。如果入射角正确,那么球在击中最终球之前可能会正确地从两堵墙上弹开。
当然,您必须为不发生碰撞的情况做好准备。您的测试可以考虑每单位时间的游戏点击次数以达到最终碰撞。您进行测试运行必要的游戏点击次数以实现碰撞。如果在规定的点击次数内没有发生碰撞,则测试可能会失败。
所有这些都是通过游戏点击而不是实时完成的,因此测试可以立即发生,而不是等待预期结果(就像您实际玩游戏时通常所做的那样)。
I think it might help if you took a more "episodic" approach. Rather than tracking coordinates and rotation across a continuum, which you seem to be doing, you could simply specify where your space ship should be, and at what rotation, after 30 game-steps. If that proves correct, then your ship probably did all the correct things in between as well. You'll write a lot less test code that way.
The same idea works by targeting the "episode" of a collision. If a billiard ball is meant to bounce off two walls and hit another ball, you could just raise an event when the final collision occurs, and check the "angle of incidence" of the collision. Once again you are not checking all the steps in between. If the angle of incidence is correct, then the ball probably bounced off the two walls correctly before hitting the final ball.
Of course you must be prepared for the case where no collision ever occurs. Your test can account for game-clicks per unit time to reach the final collision. You make the test run for the necessary number of game clicks to achieve the collision. If the collision has not happened within the prescribed number of clicks, then the test can properly fail.
All of this is done in game-clicks rather than real-time, so that the test can happen near instantly, rather than waiting for the expected result (as you would normally do if you were actually playing the game).