跳转至内容

BYTEPATH #10 - Coding Practices

Introduction

In this article I'll talk about some "best coding practices" and how they apply or not to what we're doing in this series. If you followed along until now and did most of the exercises (especially the ones marked as content) then you've probably encountered some possibly questionable decisions in terms of coding practices: huge if/elseif chains, global variables, huge functions, huge classes that do a lot of things, copypasting and repeating code around instead of properly abstracting it, and so on.

这一篇里,我想聊聊一些所谓的“最佳编程实践”,以及它们到底适不适用于我们这个系列里正在做的事情。如果你一路跟到了这里,而且大多数练习都真的做了,尤其是那些标了 content 的内容练习,那你大概已经见过不少从“代码规范”角度看起来有点可疑的决定:很长的 if/elseif 链、全局变量、特别巨大的函数、什么都往里塞的大类、明明可以抽象却直接复制粘贴重复写的代码,等等。

If you're a somewhat experienced programmer in another domain then that must have set off some red flags, so this article is meant to explain some of those decisions more clearly. In contrast to all other previous articles, this one is very opinionated and possibly wrong, so if you want to skip it there's no problem. We won't cover anything directly related to the game, even though I'll use examples from the game we're coding to give context to what I'm talking about. The article will talk about two main things: global variables and abstractions. The first will just be about when/how to use global variables, and the second will be a more general look at when/how to abstract/generalize things or not.

如果你本身在别的开发领域已经有一些经验,那这些做法多半早就让你警铃大作了。所以这篇文章的目的,就是把这些选择背后的理由尽量讲清楚一点。和前面那些文章不太一样,这一篇会非常主观,而且也完全可能有些地方我是错的。所以你如果想跳过它,也完全没问题。这里不会直接讲和游戏功能实现强相关的内容,虽然我还是会用我们正在做的游戏里的例子来解释。整篇主要会围绕两件事展开:全局变量,以及抽象。前者讲什么时候、怎样用全局变量;后者则更广一点,讨论什么时候该抽象、什么时候不该。

Also, if you've bought the tutorial then in the codebase for this article I've added some code that was previously marked as content in exercises, namely visuals for all the player ships, all attacks, as well as objects for all resources, since I'll use those as examples here.

另外,如果你买了完整教程,这一篇对应的代码里,我还提前补进了一些之前只在练习里要求你自己做的内容,比如所有玩家飞船的外观、所有攻击、以及各种资源对象。因为接下来我会拿它们当例子来讲。

Global Variables

The main advice people give each other when it comes to global variables is that you should generally avoid using them. There's lots of discussion about this and the reasoning behind this advice is generally fair. In a general sense, the main problem that comes with using global variables is that it makes things more unpredictable than they need to be. As the last link above states:

说到全局变量,大家最常给出的建议就是:能不用就别用。关于这一点,网上已经有很多 很多 很多 讨论,而且这些讨论背后的论证,大体上也都站得住脚。一般来说,全局变量最大的问题就在于,它会让系统变得比本来需要的更不可预测。上面最后那篇链接里就说得很直白:

To elaborate, imagine you have a couple of objects that both use the same global variable. Assuming you're not using a source of randomness anywhere within either module, then the output of a particular method can be predicted (and therefore tested) if the state of the system is known before you execute the method.

说得更具体一点,假设你有两个对象,它们都会用到同一个全局变量。再假设这两个模块内部都没有引入随机性,那么只要你在执行某个方法之前知道系统当前的状态,就可以推断出这个方法的输出,因此也就能测试它。

However, if a method in one of the objects triggers a side effect which changes the value of the shared global state, then you no longer know what the starting state is when you execute a method in the other object. You can now no longer predict what output you'll get when you execute the method, and therefore you can't test it.

但如果其中一个对象的方法产生了副作用,改掉了这份共享的全局状态,那么当你再去执行另一个对象的方法时,就不再清楚它的初始状态到底是什么了。这样一来,你就没法预测它会输出什么,自然也就没法好好测试它。

And this is all very good and reasonable. But one of the things that these discussions always forget is context. The advice given above is reasonable as a general guideline, but as you get more into the details of whatever situation you find yourself in you need to think clearly if it applies to what you're doing or not.

这些话都很有道理,也完全说得通。但这类讨论经常忽略掉一件事:语境。上面那套建议,作为普遍原则当然没有问题;可一旦你真正进入具体情境,就得认真想一想,它到底适不适用于你手头正在做的东西。

And this is something I'll repeat throughout this article because it's something I really believe in: advice that works for teams of people and for software that needs to be maintained for years/decades does not work as well for solo indie game development. When you're coding something mostly by yourself you can afford to cut corners that teams can't cut, and when you're coding video games you can afford to cut even more corners that other types of software can't because games need to be maintained for a lower amount of time.

这也是我在整篇文章里会反复强调的一点,因为我确实很相信它:那些适用于团队协作、适用于需要维护很多年甚至很多十年的软件的建议,放到单人独立游戏开发里,往往就没那么合适了。当你几乎是一个人在写东西时,你可以接受一些团队完全没法接受的捷径;而当你写的是游戏时,你甚至还能接受更多其他软件项目通常不敢走的捷径,因为游戏的维护周期一般没那么长。

The way this difference in context manifests itself when it comes to global variables is that, in my opinion, we can use global variables as much as we want as long as we're selective about when and how to use them. We want to gain most of the benefits we can gain from them, while avoiding the drawbacks that do exist. And in this sense we also want to take into account the advantages we have, namely, that we're coding by ourselves and that we're coding video games.

这种语境差异反映到全局变量上,在我看来就是:只要我们对“什么时候用、怎么用”足够挑剔,那么其实完全可以大胆用全局变量。我们的目标不是死守某条教条,而是尽量拿到它带来的好处,同时避开它真实存在的那些坑。而在判断时,也要把我们的现实优势算进去,也就是:现在是单人开发,而且做的是游戏。

Types of Global Variables

In my view there are three types of global variables: those that are mostly read from, those are that are mostly written to, and those that are read from and written to a lot.

在我看来,全局变量大概可以分成三类:主要被读取的,主要被写入的,以及又经常读又经常写的。

Type 1

The first type are global variables that are read from a lot and rarely written to. Variables like these are harmless because they don't really make the program any more unpredictable, as they're just values that are there and will be the same always or almost always. They can also be seen as constants.

第一类,是那种会被频繁读取、但几乎不会被修改的全局变量。它们通常没什么危险,因为它们并不会真的让程序更不可预测。它们就只是安安静静地待在那里,值永远不变,或者几乎永远不变。某种程度上,你甚至可以把它们看成常量。

An example of a variable like this in our game is the variable all_colors that holds the list of all colors. Those colors will never change and that table will never be written to, but it's read from various objects whenever we need to get a random color, for instance.

我们这个游戏里的一个例子,就是保存所有颜色列表的 all_colors。这些颜色根本不会变,这张表也不会被随手改来改去,但很多对象会在需要随机取颜色时去读取它。

Type 2

The second type are global variables that are written to a lot and rarely read from. Variables like these are mostly harmless because they also don't really make the program any more unpredictable as they're just stores of values that will be used in very specific and manageable circumstances.

第二类,是那种经常被写入、但很少被读取的全局变量。这类变量通常也还算安全,因为它们同样不会太明显地增加程序的不确定性。它们更像是一个值仓库,平时只是不断收集信息,只有在某些特定、可控的场景下才会被真正拿出来用。

In our game so far we don't really have any variable that fits this definition, but an example would be some table that holds data about how the player plays the game and then sends all that data to a server whenever the game is exited. At all times and from many different places in our codebase we would be writing all sorts of information to this table, but it would only be read and changed slightly perhaps once we decide to send it to the server.

到目前为止,我们这个游戏里还没有特别典型的第二类变量。不过举个例子的话,可以想象一张表,专门记录玩家游玩时的各种行为数据,等到游戏退出时,再统一发到服务器。那在整个运行过程中,代码库里的很多地方都可能往这张表里写信息,但它真正被读取、并稍微加工一下,可能只会发生在你准备上传那一刻。

Type 3

The third type are global variables that are written to a lot and read from a lot. These are the real danger and they do in fact increase unpredictability and make things harder for us in a number of different ways. When people say "don't use global variables" they mean to not use this type of global variable.

第三类,是既经常被写,又经常被读的全局变量。这才是真正危险的那一类。它们确实会显著提高系统的不确定性,也会从多个角度增加开发难度。大多数人说“别用全局变量”的时候,真正想禁止的,其实就是这类东西。

In our game we have a few of these, but I guess the most prominent one would be current_room. Its name already implies some uncertainty, since the current room could be a Stage object, or a Console object, or a SkillTree object, or any other sort of Room object. For the purposes of our game I decided that this would be a reasonable hit in clarity to take over trying to fix this, but it's important to not overdo it.

在我们的游戏里,这种变量也有几个,其中最典型的大概就是 current_room。它的名字本身就带着一点不确定性,因为它可能指向 Stage,也可能是 Console,还可能是 SkillTree,总之就是某种 Room 对象。对这个游戏来说,我认为为了换取便利,接受这么一点清晰度上的损失是值得的,但前提是你不能把这种做法用得太泛滥。


The main point behind separating global variables into types like these is to go a bit deeper into the issue and to separate the wheat from the chaff, let's say. Our productivity would be harmed quite a bit if we tried to be extremely dogmatic about this and avoid global variables at all costs. While avoiding them at all costs works for teams and for people working on software that needs to be maintained for a long time, it's very unlikely that the all_colors variable will harm us in the long run. And as long as we keep an eye on variables like current_room and make sure that they aren't too numerous or too confusing (for instance, current_room is only changed whenever the gotoRoom function is called), we'll be able to keep most things under control.

之所以要把全局变量细分成这几类,就是想把问题看得再深一点,把真正麻烦的东西和其实没那么要命的东西区分开来。如果我们在这件事上过于教条,非要不惜一切代价杜绝全局变量,那生产效率会被很明显地拖慢。对团队项目、对长期维护的软件来说,这么做或许值得;但像 all_colors 这种变量,长期来看几乎不可能真的害到我们。至于像 current_room 这种稍微危险一点的变量,只要我们盯住它、确保数量别太多、语义别太乱,比如像现在这样它只会在 gotoRoom 被调用时改变,那整体局面基本还是可控的。

Whenever you see or want to use a global variable, think about what type of global variable it is first. If it's a type 1 or 2 then it probably isn't a problem. If it's a type 3 then it's important to think about when and how frequently it gets written to and read from. If you're writing to it from random objects all over the codebase very frequently and reading it from random objects all over the codebase then it's probably not a good idea to make it a global. If you're writing to it from a very small set of objects very infrequently, and reading it from random objects all over the codebase, then it's still not good, but maybe it's manageable depending on the details. The point is to think critically about these issues and not just follow some dogmatic rule.

所以,当你看到一个全局变量,或者想引入一个全局变量时,先问问自己:它属于哪一类?如果是第一类或第二类,通常问题不大;如果是第三类,那就必须认真想清楚它会在什么时候被写、被写得有多频繁、又会在什么地方被读。如果代码库里到处都是对象在频繁改它,同时又到处都有人在读它,那大概率就不该做成全局。相反,如果只有极少数地方会偶尔修改它,而读取它的地方比较多,那虽然仍然不算理想,但也许还能接受,关键还是看细节。核心不是死守教条,而是带着判断去思考。

Abstracting vs. Copypasting

When talking about abstractions what I mean by it is a layer of code that is extracted out of repeated or similar code underneath it in order to be used and reused in a more constrained and well defined manner. So, for instance, in our game we have these lines:

说到抽象,我指的是:把一段重复出现、或者非常相似的代码提炼出来,包成一层更收敛、更明确的接口,方便反复复用。比如在我们的游戏里,有这么几行:

lua
local direction = table.random({-1, 1})
self.x = gw/2 + direction*(gw/2 + 48)
self.y = random(16, gh - 16)

And they are the same on all objects that need to be spawned from either left or right of the screen at a random y position. I think so far about 6-7 objects have these 3 lines at their start. The argument for abstraction here would say that since these lines are being repeated on multiple objects we should consider abstracting it up somehow and have those objects enjoy that abstraction instead of having to have those repeated lines of code all over. We could implement this abstraction either through inheritance, components, a function, or some other mechanism. For the purposes of this discussion all those different ways will be treated as the same thing because they show the same problems.

凡是那种需要从屏幕左侧或右侧随机 y 位置出生的对象,开头几乎都会有这一模一样的三行。到现在为止,大概已经有六七种对象在这么写了。从抽象派的角度来看,这种重复已经足够明显,我们就应该把它提炼出来,不管是做成继承、组件、函数,还是别的什么机制,总之不该让同样的代码散落在一堆对象里。这里为了讨论方便,我把这些实现方式都看成一回事,因为它们面对的是同一种问题。

Now that we're on the same page as to what we're talking about, let's get into it. The main discussion in my view around these issues is one of adding new code against existing abstractions versus adding new code freely. What I mean by this is that whenever we have abstractions that help us in one way, they also have (often hidden) costs that slow us down in other ways.

把“抽象”这个词先说清楚之后,就可以往下谈了。在我看来,这类问题真正的核心在于:你是在已有抽象之上继续加新代码,还是更自由地直接把新代码写进去。我的意思是,抽象当然会在某些方面帮到我们,但与此同时,它也会带来一些经常被忽略的代价,而这些代价往往正好会拖慢你在另一些场景里的效率。

Abstracting

In our example above we could create some function/component/parent class that would encapsulate those 3 lines and then we wouldn't have to repeat them everywhere. Since components are all the rage these days, let's go with that and call it SpawnerComponent (but again, remember that this applies to functions/inheritance/mixins and other similar methods of abstraction/reuse that we have available). We would initialize it like spawner_component = SpawnerComponent() and magically it would handle all the spawning logic for us. In this example it's just 3 lines but the same logic applies to more complex behaviors as well.

拿刚才那个例子来说,我们完全可以弄一个函数、组件或者父类,把这三行封进去,这样就不用在每个对象里重复写。既然这几年大家都很迷组件,那我们不妨就假设它叫 SpawnerComponent。当然,你得记住,这里说的不只是组件,也包括函数、继承、mixin 这些各种形式的抽象和复用。然后我们只要写 spawner_component = SpawnerComponent(),它就能神奇地帮我们搞定所有出生逻辑。虽然这个例子只有三行,但更复杂的行为本质上也是同一个问题。

The benefits of doing this is that now everything that deals with the spawning logic of objects in our game is constrained to one place under one interface. This means that whenever we want to make some change to the spawning behavior, we have to change it in one place only and not go over multiple files changing everything manually. These are well defined benefits and I'm definitely not questioning them.

这么做的好处非常明确:现在所有和出生逻辑相关的内容都被收束到了一个地方,挂在同一个接口下面。以后只要你想改出生方式,就只用改这一处,不必翻好几个文件挨个手动修改。这些好处都非常实在,我完全没有否认它们的意思。

However, doing this also has costs, and these are largely ignored whenever people are "selling" you some solution. The costs here make themselves apparent whenever we want to add some new behavior that is kinda like the old behavior, but not exactly that. And in games this happens a lot.

但与此同时,这么做也有代价,而这个部分经常会在别人向你“推销”某种方案时被轻轻带过。它的代价通常会在这样一种时候暴露出来:你想加的新行为和旧行为有点像,但又不完全一样。而在游戏开发里,这种情况真的非常常见。

So, for instance, say now that we want to add objects that will spawn exactly in the middle of the screen. We have two options here: either we change SpawnerComponent to accept this new behavior, or we make a new component that will implement this new behavior. In this case the obvious option is to change SpawnerComponent, but in more complex examples what you should do isn't that obvious. The point here being that now, because we have to add new code against the existing code (in this case the SpawnerComponent), it takes more mental effort to do it given that we have to consider where and how to add the functionality rather than just adding it freely.

比如现在,我们想新增一种对象,它不从左右边缘出生,而是固定从屏幕正中间出现。这时候你有两个选择:要么修改 SpawnerComponent 让它支持这种新行为,要么再写一个新组件专门干这个事。这个例子里,改 SpawnerComponent 看起来是更自然的选择;但一旦场景再复杂一点,到底该怎么做就没那么显然了。问题的关键就在这儿:你现在不是自由地直接加代码,而是在“已有抽象”的约束下继续加代码。于是你必须额外思考“功能到底该放哪里、该怎样接进去”,这会消耗更多心智负担。

Copypasting

The alternative option, which is what we have in our codebase now, is that these 3 lines are just copypasted everywhere we want the behavior to exist. The drawbacks of doing this is that whenever we want to change the spawning behavior we'll have to go over all files tediously and change them all. On top of that, the spawning behavior is not properly encapsulated in a separate environment, which means that as we add more and more behavior to the game, it could be harder to separate it from something else (it probably won't remain as just those 3 lines forever).

另一种选择,也就是我们现在代码库里的做法,是把这三行哪里需要就直接复制到哪里。这样做的坏处也很明显:一旦你想整体修改出生逻辑,就得痛苦地把所有相关文件都翻一遍、一个个去改。而且这套逻辑并没有被封装在一个独立边界里,所以随着游戏里和它相关的行为越来越多,日后想把它从别的逻辑里拆出来,可能会越来越麻烦。毕竟它大概率不会永远都只停留在这三行。

The benefits of doing this, however, also exist. In the case where we want to add objects that will spawn exactly in the middle of the screen, all we have to do is copypaste those 3 lines from a previous object and change the last one:

不过复制粘贴也有它自己的优势。还是刚才那个“从屏幕中间出生”的例子,这时候你要做的事情就只是把之前对象里的三行代码复制过来,再改最后一行:

lua
local direction = table.random({-1, 1})
self.x = gw/2 + direction*(gw/2 + 48)
self.y = gh/2

In this case, the addition of new behavior that was similar to previous behavior but not exactly the same is completely trivial and doesn't take any amount of mental effort at all (unlike with the SpawnerComponent solution).

在这种情况下,“新增一种和旧行为相似、但又不完全一样的行为”这件事,几乎就是零成本,脑子都不用怎么拐弯。和 SpawnerComponent 那种方案相比,它的阻力小得多。

So now the question becomes, as both methods have benefits and drawbacks, which method should we default to using? The answer that people generally talk about is that we should default to the first method. We shouldn't let code that is repeated stay like that for too long because it's a "bad smell". But in my opinion we should do the contrary. We should default to repeating code around and only abstract when it's absolutely necessary. The reason for that is...

所以真正的问题就变成了:既然两种方法都有得有失,那默认应该站哪边?主流说法通常是,应该默认站在第一种,也就是尽早抽象,因为重复代码是一种“坏味道”,不应该让它存在太久。但我的看法正好相反。我觉得默认应该是先允许代码重复,只有在“真的有必要”的时候才去抽象。原因就在于……

Frequency and Types of Changes

One good way I've found of figuring out if some piece of code should be abstracted or not is to look at how frequently it changes and in what kind of way it changes. There are two main types of changes I've identified: unpredictable and predictable changes.

我自己判断一段代码该不该抽象时,一个很有用的视角就是:看看它改得有多频繁,以及它是以什么方式在变化。我大致把这些变化分成两类:不可预测的变化,以及可预测的变化。

Unpredictable Changes

Unpredictable changes are changes that fundamentally modify the behavior in question in ways that go beyond simple small changes. In our spawning behavior example above, an unpredictable change would be to say that instead of enemies spawning from left and right of the screen randomly, they would be spawned based on a position given by a procedural generator algorithm. This is the kind of fundamental change that you can't really predict.

所谓不可预测的变化,是那种会从根本上改写行为本身的变化,不是简单的小修小补。回到我们前面的出生逻辑例子,如果某天你决定敌人不再从屏幕左右随机出现,而是改成由程序生成算法决定出生位置,那就是一种典型的不可预测变化。因为这种改法已经不是“微调”,而是把规则本身都换掉了。

These changes are very common at the very early stages of development when we have some faint idea of what the game will be like but we're light on the details. The way to deal with those changes is to default to the copypasting method, since the more abstractions we have to deal with, the harder it will be to apply these overarching changes to our codebase.

这种变化在开发早期尤其常见。那时候你对游戏只有一个模糊轮廓,但很多细节根本还没定下来。面对这种情况,我认为更适合默认采用复制粘贴的方式,因为你手头的抽象层越多,等到这种大刀阔斧的改动真正来了,整个代码库反而越难动。

Predictable Changes

Predictable changes are changes that modify the behavior in small and well defined ways. In our spawning behavior example above, a predictable change would be the example used where we'd have to spawn objects exactly in the middle y position. It's a change that actually changes the spawning behavior, but it's small enough that it doesn't completely break the fundamentals of how the spawning behavior works.

可预测变化则不同,它们是那种边界明确、改动幅度不大的变化。还是出生逻辑那个例子,从左右随机出生,变成固定在中间 y 位置出生,就是一种可预测变化。它确实改变了出生行为,但改动还没有大到推翻整套机制的底层假设。

These changes become more and more common as the game matures, since by then we'll have most of the systems in place and it's just a matter of doing small variations or additions on the same fundamental thing. The way to deal with those changes is to analyze how often the code in question changes. If it changes often and those changes are predictable, then we should consider abstracting. If it doesn't change often then we should default to the copypasting idea.

随着游戏逐渐成熟,这类变化会越来越多。因为这时候大部分系统已经定型了,剩下的往往只是围绕同一套基础机制做小幅变体和增量扩展。面对这种情况,更合适的做法是去看这段代码到底改得频不频繁。如果它经常改,而且改法还都比较可预测,那就值得考虑抽象;如果它其实不怎么改,那我还是更倾向于继续允许复制粘贴存在。


The main point behind separating changes into these two different types is that it lets us analyze the situation more clearly and make more informed decisions. Our productivity would be harmed if all we did was default to abstracting things dogmatically and avoiding repeated code at all costs. While avoiding that at all costs works for teams and for people working on software that needs to be maintained for al ong time, it's not the case for indie games being written by one person.

把变化分成这两类,最大的意义在于:它让我们能更清醒地分析当下局势,做出更靠谱的判断。如果我们什么都不想,逮着重复代码就条件反射地去抽象,生产力反而会受伤。那套“无论如何都要杜绝重复”的思路,对团队开发、对长期维护的软件也许是合理的;但对于单人独立游戏开发,并不一定成立。

Whenever you get the urge to generalize something, think really hard about if it's actually necessary to do that. If it's a piece of code that is not changing often then worrying about it at all is unnecessary. If it is changing often then is it changing in a predictable or unpredictable manner? If it's changing in an unpredictable manner then worrying about it too much and trying to encapsulate it in any way is probably a waste of effort, since that encapsulation will just get in the way whenever you have to change the whole thing in a big way. If it's changing in a predictable manner, though, then we have potential for real abstraction that will benefit us. The point is to think critically about these issues and not just follow some dogmatic rule.

所以,每当你产生“这段东西要不要泛化一下”的冲动时,先逼自己认真想一想,它到底是不是真的有必要。如果这段代码根本不常改,那你连担心它都没必要;如果它确实经常改,那接着问:它是按可预测的方式在改,还是总在不可预测地变?如果是后者,那你花很多力气去封装它,很可能只是白费劲,因为下一次大改一来,这层封装大概率反而会碍手碍脚。只有当它经常变化、而且变化方式又足够可预测时,抽象才真正可能成为帮助。重点依然是带着判断去思考,而不是照搬某条僵硬规则。

Examples

We have a few more examples in the game that we can use to further discuss these issues:

为了把这个问题讲得更实一点,我们再拿游戏里的几个例子往下说。

Left/Right Movement

This is something that is very similar to the spawning code, which is the behavior of all entities that just move either left or right in a straight line. So this applies to a few enemies and most resources. The code that directs this behavior generally looks something like this and it's repeated across all these entities:

第一个例子和前面的出生逻辑很像,就是那些只会沿直线向左或向右移动的实体。这种行为用在一部分敌人和大多数资源对象上。控制它们移动的代码通常长这样,而且会在这些实体里反复出现:

lua
function Rock:new(area, x, y, opts)
    ...

    self.w, self.h = 8, 8
    self.collider = self.area.world:newPolygonCollider(createIrregularPolygon(8))
    self.collider:setPosition(self.x, self.y)
    self.collider:setObject(self)
    self.collider:setCollisionClass('Enemy')
    self.collider:setFixedRotation(false)
    self.v = -direction*random(20, 40)
    self.collider:setLinearVelocity(self.v, 0)
    self.collider:applyAngularImpulse(random(-100, 100))
  
  	...
end

function Rock:update(dt)
    ...

    self.collider:setLinearVelocity(self.v, 0) 
end

Depending on the entity there are very small differences in the way the collider is set up, but it's really mostly the same. Like with the spawning code, we could make the argument that abstracting this into something else, like maybe a LineMovementComponent or something would be a good idea.

不同实体之间,碰撞体的设置细节会有一点小差别,但大体结构其实都差不多。和出生逻辑一样,你完全可以主张把这块再抽出来,做成类似 LineMovementComponent 之类的东西。

The analysis here is exactly as before. We need to think about how often this behavior is changed across all these entities. The answer to that is almost never. The behavior that some of those entities have to move left/right is already decided and won't change, so it doesn't make sense to worry about it at all, which means that it's alright to repeat it around the codebase.

而我对它的判断,和前面一模一样:先看这类行为到底会不会频繁变化。答案几乎是不会。这些实体“就该左右直线移动”这件事,基本已经定死了,不太可能再推翻。所以没必要专门为它操心抽象,允许它重复存在,其实完全没问题。

Player Ship Visuals and Trails

If you did most of the exercises, there's a piece of code in the Player class that looks something like this:

如果你前面的练习做得差不多了,那 Player 类里应该已经有一大块类似下面这种代码:

It's basically two huge if/elseifs, one to handle the visuals for all possible ships, and another to handle the trails for those ships as well. One of the things you might think when looking at something like this is that it needs to be PURIFIED. But again, is it necessary? Unlike our previous examples this is not code that is repeating itself over multiple places, it's just a lot of code being displayed in sequence.

本质上它就是两大串巨长的 if/elseif:一串负责处理各种飞船的外观,另一串负责处理对应的尾迹。你第一眼看到这种东西时,可能会忍不住觉得它必须被“净化”,必须被整理。但问题还是老问题:真的有必要吗?和前面的例子不一样,这里并不是一段代码散落在很多地方重复出现,而只是大量代码按顺序堆在了一起。

One thing you might think to do is to abstract all those different ship types into different files, define their differences in those files and in the Player class we just read data from those files and it would be all clean and nice. And that's definitely something you could do, but in my opinion it falls under unnecessary abstraction. I personally prefer to just have straight code that shows itself clearly rather than have it spread over multiple layers of abstraction. If you're really bothered by this big piece of code right at the start of the Player class, you can put this into a function and place it at the bottom of the class. Or you can use folds, which is something your editor should support. Folds look like this in my editor, for instance:

你完全可以把每种飞船都拆到不同文件里,把差异定义在那些文件中,然后让 Player 只去读取这些数据。这样看起来会更整洁,也确实是一种可行方案。但在我看来,这就属于没必要的抽象。我个人更喜欢让代码就直白地摆在那里,而不是把它们分散到好几层抽象后面。如果你只是单纯受不了 Player 类开头那一大坨代码,那完全可以把它包成一个函数,挪到类的底部;或者直接用编辑器的代码折叠功能。比如我编辑器里的折叠效果大概长这样:

Player Class Size

Similarly, the Player class now has about 500 lines. In the next article where we'll add passives this will blow up to probably over 2000 lines. And when you look at it the natural reaction will be to want to make it neater and cleaner. And again, the question to be asked is if it's really necessary to do that. In most games the Player class is the one that has the most functionality and often times people go through great lengths to prevent it from becoming this huge class where everything happens.

类似地,Player 类现在大概已经有 500 行了。等到下一篇我们开始加被动之后,它很可能会直接膨胀到 2000 行以上。看到这种体量,你的本能反应多半还是会觉得:这不行,得想办法把它整理得更漂亮一点。可我还是同一个问题:这件事真的有必要吗?在很多游戏里,Player 本来就是功能最集中的类,所以人们常常会非常努力地阻止它变成这种“什么都发生在这里”的超级大类。

But for the same reasons as why I decided to not abstract away the ship visuals and trails in the previous example, it wouldn't make sense to me to abstract away all the various different logical parts that make up the player class. So instead of having a different file for player movement, one for player collision, another for player attacks, and so on, I think it's better to just put it all in one file and end up with a 2000 Player class. The benefit-cost ratio that comes from having everything in one place and without layers of abstraction between things is higher than the benefit-cost ratio that comes from properly abstracting things away (in my opinion!).

但也正因为我前面已经决定不去把飞船外观和尾迹过度抽象,所以对 Player 类内部这些不同逻辑模块,我同样不觉得有必要非拆不可。与其搞成“移动一个文件、碰撞一个文件、攻击一个文件……”这种结构,我宁可接受一个 2000 行的 Player 类,把东西都放在一起。至少在我看来,把所有内容集中在一个地方、减少中间那几层抽象所带来的收益,反而比“形式上更规整地拆开”还更大。

Entity Component Systems

Finally, the biggest meme of all that I've seen take hold of solo indie developers in the last few years is the ECS one. I guess by now you can kind of guess my position on this, but I'll explain it anyway. The benefits of ECSs are very clear and I think everyone understands them. What people don't understand are the drawbacks.

最后,近几年我在单人独立开发圈子里见过的最大“梗”,大概就是 ECS 了。你现在应该已经能猜到我对它的大致态度,但我还是照样说一遍。ECS 的优点其实非常明确,我觉得大家都懂;真正经常被忽略的,是它的代价。

By definition ECS are a more complicated system to start with in a game. The point is that as you add more functionality to your game you'll be able to reuse components and build new entities out of them. But the obvious cost (that people often ignore) is that at the start of development you're wasting way more time than needed building out your reusable components in the first place. And like I mentioned in the abstracting/copypasting section, when you build things out and your default behavior is to abstract, it becomes a lot more taxing to add code to the codebase, since you have to add it against the existing abstractions and structures. And this manifests itself massively in a game based around components.

ECS 从定义上来说,就是一套起手更复杂的体系。它的承诺是:随着你不断给游戏加功能,你可以反复复用组件,再把新实体组合出来。但它一个再明显不过、却总被轻描淡写带过的成本是:在开发刚开始的时候,你会花掉远超实际需要的时间,去搭那些“未来可能会复用”的组件。正如我前面在“抽象 vs 复制粘贴”里说过的,一旦你的默认习惯变成“先抽象”,那么往代码库里继续塞新代码这件事就会变得更吃力,因为你必须处处对齐已有结构。而在一个以组件为核心的游戏架构里,这种负担会被放大得很明显。

Furthermore, I think that most indie games actually never get to the point where the ECS architecture actually starts paying off. If you take a look at this very scientific graph that I drew what I mean should become clear:

更进一步说,我甚至觉得大多数独立游戏,压根走不到 ECS 真正开始回本的那个阶段。你看一眼我这张非常“科学”的图,大概就能明白我的意思:

So the idea is that at the start, "yolo coding" (what I'm arguing for in this article) requires less effort to get things done when compared to ECS. As time passes and the project gets further along, the effort required for yolo coding increases while the effort required for ECS decreases, until a point is reached where ECS becomes more efficient than yolo coding. The point I wanna make is that most indie games, with very few exceptions (in my view at least) ever reach that intersection point between both lines.

这张图想表达的是:在项目早期,相比 ECS,我这里主张的这种“先莽起来再说”的写法,需要更少的努力就能把东西做出来。随着时间推移、项目越做越大,这种写法的成本会慢慢升高,而 ECS 的边际成本则会下降,最终某个节点之后,ECS 会比这种写法更高效。问题是,在我看来,绝大多数独立游戏,除了极少数例外,根本走不到那条交叉线出现的地方。

And so if this is the case, and in my view it is, then it makes no sense to use something like an ECS. This also applies to a number of other programming techniques and practices that you see people promote. This entire article has been about that, essentially. There are things that pay off in the long run that are not good for indie game development because the long run never actually manifests itself.

如果现实真是这样,而我确实认为大多数时候就是这样,那用 ECS 这种东西就没什么意义了。同样的判断,其实也适用于很多别的、经常被人推崇的编程技巧和实践。说到底,这整篇文章讲的都是这一件事:有些东西确实会在“长期”里回本,但独立游戏开发的问题是,这个所谓的长期,很多时候根本不会真正到来。

END

Anyway, I think I've given enough of my opinions on these issues. If you take anything away from this article just consider that most programming advice you'll find on the Internet is suited for teams of people working on software that needs to be maintained for a long time. Your context as a developer of indie video games is completely different, and so you should always think critically about if the advice given by other people suits you or not. Lots of times it will suit you, because there are things about programming that are of benefit in every context (like, say, naming variables properly), but sometimes it won't. And if you're not paying attention to the times when it doesn't you'll be slower and less productive than you otherwise could be.

总之,这些话我应该已经说得够多了。如果这篇文章你只想带走一个结论,那就是:你在网上看到的大多数编程建议,默认面对的都是团队协作、以及需要长期维护的软件项目。而你作为一个独立游戏开发者,所处的上下文完全不是一回事。所以每次听到某条建议,都应该先想清楚它适不适合你。有些建议当然很适合,因为总有一些原则在任何开发环境下都有价值,比如好好命名变量;但也有些时候它并不适合。如果你没意识到这一点,那你的开发速度和效率,很可能会比本来该有的更差。

At the same time, if at your day job you work in a big team on software that needs to be maintained for a long time and you've incorporated the practices and styles that come with that, if you can't come home and code your game with a different mindset then trying to do the things I'm outlining in this article would be disastrous. So you also need to consider what your "natural" coding environment is, how far away it is from what I'm saying is the natural coding environment of solo indie programmers, and how easily you can switch between the two on a daily basis. The point is, think critically about your programming practices, how well they're suited to your specific context and how comfortable you are with each one of them.

当然,反过来说,如果你白天的本职工作就是在大团队里维护那种要活很多年的软件,而且你已经把那套流程和风格内化成了自己的默认思维方式,那么你要是回家之后没法切换心态,再照着我这篇文章里的思路去写游戏,后果很可能会很惨。所以你也得反过来评估:什么才是你“天然习惯”的编码环境?它和我这里所说的“单人独立开发的自然环境”到底差多远?你平时又能不能比较顺畅地在两者之间切换?说到底,还是那句话:认真审视你自己的编程习惯,判断它们和你具体处境的匹配度,以及你对每一种方式究竟有多自在。