我在13年游戏开发中写出的一些烂代码

【导读】:Evan Todd 用风趣幽默的口吻点评了自己从 2004 年以来做游戏时写的代码,有 Java、C++、Python。经过十几年打怪练级,最近终于完整地做完并推出了自己的第一套游戏。Todd 今年 26 岁。

在一个独处的星期五晚上,因急需一些灵感,你决定重温一些你过去「征服」的程序。旧硬盘缓缓地旋转着,你浏览着过去那些光荣岁月里编写的代码。

噢,不! 这根本不是你所期望的。代码真的有这么糟糕吗? 为什么没有人告诉你?当时为什么会喜欢这样写?有必要在一个功能中写这么多的 goto 吗?很快,你就关闭了这个项目。 有那么一瞬间,你甚至考虑删除它,然后清空硬盘。

以下是我对自己过去的编程经历中的一些经验教训、代码片段和警告的整理。为了暴露错误,我没有对原有的命名进行修改。

2004 年

这一年我十三岁。这个项目取名《红月》,这是一个雄心勃勃的第三人称飞行射击游戏。在该项目中,几乎没有代码不是逐字逐句地从《Developing Games in Java》中复制出来的,这样写出来的代码毫无疑问糟糕透了。让我们来看一个例子。

我想给玩家设计多武器切换功能。具体方案是将武器模型旋转到玩家模型的内部,用它变换出下一个武器,然后再将它旋转回来。以下是动画代码。 别把它想得太难了。

public void updateAnimation(long eTime) {
    if(group.getGroup("gun") == null) {
        group.addGroup((PolygonGroup)gun.clone());
    }
    changeTime -= eTime;
    if(changing && changeTime <= 0) {
        group.removeGroup("gun");
        group.addGroup((PolygonGroup)gun.clone());
        weaponGroup = group.getGroup("gun");
        weaponGroup.xform.velocityAngleX.set(.003f, 250);
        changing = false;
    }
}

我要指出两个有趣的问题。 首先,这里涉及了多个状态变量:

  • changeTime
  • changing
  • weaponGroup
  • weaponGroup.xform.velocityAngleX

即使定义了这么多的变量,仍感觉像是缺少点什么似的。噢,对了,我们还需要一个变量来跟踪当前装备的武器。 当然,这个变量被定义在另一个文件中。

另一个有趣的问题是,我从来没有真正创建过一个以上的武器模型。每个武器使用相同的模型。所有的武器模型代码只是累赘。

如何改进

删除多余的变量。在这个案例中,只需要设置两个变量:weaponSwitchTimer 和 weaponCurrent。其他一切状态,都可以从这两个变量中推演得到。

显示地初始化一切。 此函数将检查武器是否为空,并在必要时对其进行初始化。三十秒的观察期,能够确保玩家在本游戏中始终拥有武器。如果没有武器,则游戏无法进行,也可能程序会崩溃。

显然,在某些时候,我在这个函数中遇到了一个空指针异常(NullPointerException)。然而,我并没有思考为什么会出现空指针异常,相反地,我只是在函数中插入了一个快速的非空检查,并让程序继续运行。事实上,大多数武器处理函数都进行了这样的非空检查!

提前检查,提前处理! 不要将这些问题留给电脑去解决。

命名

boolean noenemies = true; // why oh why

对布尔变量进行正向命名。如果你发现自己写的代码也像这样,那你可能得重新评估一下你的一些「人生决策」了:

if (!noenemies) {
    // are there enemies or not??
}

错误处理

整个代码库中,随意散落着像这样的代码片段:

static {
    try {
        gun = Resources.parseModel("images/gun.txt");
    } catch (FileNotFoundException e) {} // *shrug*
    catch (IOException e) {}
}

你可能会认为「应该更优雅地处理这个错误!向用户或某事件发送消息。」事实上,我认为刚好相反。

做再多的错误检查都不为过,但一定不要做过多的错误处理。在这个例子中,没有武器模型,游戏是无法进行的,所以我宁愿让程序崩溃。不要试图对不可恢复的错误进行温和处理。

这就要求我们提前判定哪些错误是可以恢复的。不幸的是,Sun 认为几乎所有 Java 错误都必须是可恢复的,这导致类似于上述例子中的懒惰错误处理。

2005-2006年

在这个时间段,我学习了 C++ 和 DirectX。 我决定写一个可复用的引擎,以便人们可以从我过去 14 年来学到的丰富知识和经验中获益。

如果你认为这次也将只是令人尴尬或难为情的,请先保留你的观点。

当时,我已经学习了面向对象编程,它被认为是写好代码的标志。这导致我写出以下这种怪物代码:

class Mesh {
public:
	static std::list<Mesh*> meshes; // Static list of meshes; used for caching and rendering
	Mesh(LPCSTR file); // Loads the x file specified
	Mesh();
	Mesh(const Mesh& vMesh);
	~Mesh();
	void LoadMesh(LPCSTR xfile); // Loads the x file specified
	void DrawSubset(DWORD index); // Draws the specified subset of the mesh
	DWORD GetNumFaces(); // Returns the number of faces (triangles) in the mesh
	DWORD GetNumVertices(); // Returns the number of vertices (points) in the mesh
	DWORD GetFVF(); // Returns the Flexible Vertex Format of the mesh
	int GetNumSubsets(); // Returns the number of subsets (materials) in the mesh
	Transform transform; // World transform
	std::vector* GetMaterials(); // Gets the list of materials in this mesh
	std::vector<Cell*>* GetCells(); // Gets the list of cells this mesh is inside
	D3DXVECTOR3 GetCenter(); // Gets the center of the mesh
	float GetRadius(); // Gets the distance from the center to the outermost vertex of the mesh
	bool IsAlpha(); // Returns true if this mesh has alpha information
	bool IsTranslucent(); // Returns true if this mesh needs access to the back buffer
	void AddCell(Cell* cell); // Adds a cell to the list of cells this mesh is inside
	void ClearCells(); // Clears the list of cells this mesh is inside
protected:
	ID3DXMesh* d3dmesh; // Actual mesh data
	LPCSTR filename; // Mesh file name; used for caching
	DWORD numSubsets; // Number of subsets (materials) in the mesh
	std::vector materials; // List of materials; loaded from X file
	std::vector<Cell*> cells; // List of cells this mesh is inside
	D3DXVECTOR3 center; // The center of the mesh
	float radius; // The distance from the center to the outermost vertex of the mesh
	bool alpha; // True if this mesh has alpha information
	bool translucent; // True if this mesh needs access to the back buffer
	void SetTo(Mesh* mesh);
}

我还了解到,注释也被看作为好代码的标志,这导致我写出这样的「瑰宝」:

D3DXVECTOR3 GetCenter(); // Gets the center of the mesh

这个类还存在更严重的问题。Mesh 的概念是一个令人困惑的抽象,在现实世界没有参照物。尽管是我写出来的,但我也对它感到困惑。这是一个容纳顶点、索引和其他数据的容器吗?这是一个用于从磁盘加载和卸载数据的资源管理器吗?这是一个将数据发送到 GPU 的渲染器吗?它代表了所有这些东西。

如何改进

Mesh 类应该是一个「普通的旧数据结构」。它应该没有「智能」,这意味着我们可以安全地将所有无用的 getters 和 setters 丢弃,并将所有的字段都设为 public 属性。

然后,我们可以将资源管理和渲染分离为独立于惰性数据的系统。是的,是系统,而不是对象。当另一种抽象更合适时,就没必要将每个问题都转化为面向对象的抽象。

关于注释问题的修改,大多数时候,删除就可以了。由于注释不受编译器检查,容易过时,这是造成误导的主要因素。我认为不应该对代码进行注释,除非他们属于以下情况:

  • 注释解释的是 why,而不是 what。这些注释是最有用的。
  • 用几句话来解释下面的大块代码是什么。这些注释有助于指导和阅读代码。
  • 对声明的数据结构进行注释,说明每个字段的意义。这些注释往往是不必要的。但有时,字段与内存中的概念的映射关系不能够直观显示,就有必要通过添加注释来描述这种映射关系。

2007-2008 年

这段时间,是我的「PHP 黑暗岁月」。
图0:我在13年游戏开发中写出的一些烂代码

2009-2010 年

此时,我正在上大学。我做了一个基于 Python 的第三人称多人射击游戏《 Acquire、Attack、 Asplode、 Pwn》(简称 A3P)。关于此项目,我没有任何理由为自己辩解。形势真的越来越尴尬了,这个项目带了一个侵犯健康权益的背景音乐。

(注:这个视频搬运失败,请看这里:https://youtu.be/qdt2ixQSjZo

当我写这个游戏的时候,新学到的经验是全局变量被认为是糟糕代码的标志。全局变量提高了代码的耦合度。它们允许 A 函数通过修改全局变量进入完全不相关的 B 函数。 全局变量无法跨线程使用。

然而,几乎所有的游戏代码都需要访问整个 world 状态。我通过将所有内容存储在「world」对象中,并将「world」传递到每个单独的函数中来「解决」这个问题。 再也没有全局变量了!我认为这是「出色的实现」,因为理论上我可以同时运行多个、独立的「world」。

在实践中,「world」作为一个事实上的全局状态容器。多个「worlds」的想法当然是不需要的,也没有经过测试,但我相信,如果没有进行重大的重构,这也不会有效。

一旦你加入了清理全局变量的奇特「宗教团体」,你会找到很多有创意方法用以欺骗自己。最糟糕的莫过于单例:

class Thing
{
    static Thing i = null;
    public static Thing Instance()
    {
        if (i == null)
            i = new Thing();
        return i;
    }
}

哇,魔术啊! 看不到一个全局变量!然而,单例比全局变量更糟糕,原因如下:

  • 全局变量的所有潜在缺陷仍存在于单例中。 如果你认为单例不是一个全局变量,你只不过是在自欺欺人罢了。
  • 在最好的情况下,访问单例只是给你的程序增加了昂贵的分支指令。 在最坏的情况下,这将会是一个完整的函数调用。
  • 你不知道一个单例会在什么时候被初始化,直到该程序被真正地运行。这是程序员简单地将本该在设计时应该做出的决策留给程序自己去处理的另一个例子。

如何改进

如果某个变量必须要全局化,就让它全局化好了。在定义全局变量时,请结合整个项目进行考虑。有些经验可以借鉴。

真正的问题在于代码之间相互依赖。全局变量,容易使不相关的代码之间创建不可见的依赖关系。组合相互依赖的代码,并入到内聚的系统中,以最小化这些不可见的依赖关系。实现它的一个好方法,就是将与系统相关的所有内容都放到该系统自己的线程中,并强制其它的代码通过消息传递与该系统通信。

布尔参数

你可能写过像这样的代码:

class ObjectEntity:
    def delete(self, killed, local):
        # ...
        if killed:
            # ...
        if local:
            # ...

在这里,我们有四个不同的但又极度相似的「删除」操作,它们的差异仅仅在于两个布尔参数。看起来似乎完全合理。现在,让我们来看看调用这个函数的客户端代码:

obj.delete(True, False)

可读性很差,不是吗?

如何改进

这是个案。然而,Casey Muratori 提供的一条建议适用于此:先写客户端代码。我敢肯定,任何一个有理智的人,都不会写出上面这种客户端代码。 相反地,你可能会这样写:

obj.killLocal()

然后,写出 killLocal() 函数的实现代码。

命名

对命名如此多地关注,可能看起来很奇怪。但就像老笑话一样,这是计算机科学中尚未解决的两个问题之一。另一个是缓存失效和差一 错误(off-by-one errors)。

看一下这些函数:

class TeamEntityController(Controller):
 
    def buildSpawnPacket(self):
        # ...
 
    def readSpawnPacket(self):
        # ...
 
    def serverUpdate(self):
        # ...
 
    def clientUpdate(self):
        # ...

显然,前两个函数是相互关联的,最后两个函数也是相关的。但是它们没有通过命名来反映这个事实。 在 IDE 中,这些功能将不会在自动完成菜单项中相邻显示。

一种更好地命名方式是,以相同的方式开始,并以不同的方式结束。如下所示:

class TeamEntityController(Controller):
 
    def packetSpawnBuild(self):
        # ...
 
    def packetSpawnRead(self):
        # ...
 
    def updateServer(self):
        # ...
 
    def updateClient(self):
        # ...

自动补全对话框在显示这些代码时也更加易于理解。

2010-2015年

有了 12 年的编程经验后,我才已完成了一个完整的游戏项目。

虽然,到目前为止我已经学习了很多编程知识,但这个游戏却是我所犯下的一些重大错误的特辑。

数据绑定

当时,「响应式」UI 框架编程之风刚刚兴起,像微软的 MVVM 和 Google 的 Angular 。现在,这种风格的编程主要集中在 React 中。

所有这种类型的框架都基于相同的基础 promise 库。它们向你展示一个 HTML 文本字段,一个空的 <span> 标签元素和一行绑定二者的脚本代码。在文本字段中键入,然后「嘭」! <span> 标签中的内容不可思议地更新了。

在游戏的上下文中,它看起来像这样:

public class Player
{
    public Property<string> Name = new Property<string> { Value = "Ryu" };
}
 
public class TextElement : UIComponent
{
    public Property<string> Text = new Property<string> { Value = "" };
}
 
label.add(new Binding<string>(label.Text, player.Name));

哇,现在,UI 会根据玩家的名字自动更新!我可以保持 UI 和游戏代码完全独立。这是很吸引人的,因为我们通过游戏的状态推导出 UI 状态,从而消除了游戏中的 UI 状态变量。

然而,这里仍有一些危险信号。 我不得不将游戏中的每一个字段都转换成一个 Property 对象,该对象包含依赖于它的绑定列表:

public class Property<Type> : IProperty
{
    protected Type _value;
    protected List<IPropertyBinding> bindings; 
 
    public Type Value
    {
        get { return this._value; }
        set
        {
            this._value = value;
 
            for (int i = this.bindings.Count - 1; i >= 0; i = Math.Min(this.bindings.Count - 1, i - 1))
                this.bindings[i].OnChanged(this);
        }
    }
}

游戏中的每个单字段,甚至最后一个布尔值,都附带了一个不好控制的动态分配数组。

看一下属性绑定变更通知的循环,就能够了解我使用这个模式所遇到的问题了。它必须向后迭代绑定列表,因为绑定可以根据实际需要添加或删除 UI 元素,导致绑定列表更改。

然而,由于我对数据绑定如此热爱,所以我在进行数据绑定之前创建了整个游戏。我将对象分解成组件,并将对象的属性绑定在一起。很快,事情就失控了。

jump.Add(new Binding<bool>(jump.Crouched, player.Character.Crouched));
jump.Add(new TwoWayBinding<bool>(player.Character.IsSupported, jump.IsSupported));
jump.Add(new TwoWayBinding<bool>(player.Character.HasTraction, jump.HasTraction));
jump.Add(new TwoWayBinding<Vector3>(player.Character.LinearVelocity, jump.LinearVelocity));
jump.Add(new TwoWayBinding<BEPUphysics.Entities.Entity>(jump.SupportEntity, player.Character.SupportEntity));
jump.Add(new TwoWayBinding<Vector3>(jump.SupportVelocity, player.Character.SupportVelocity));
jump.Add(new Binding<Vector2>(jump.AbsoluteMovementDirection, player.Character.MovementDirection));
jump.Add(new Binding<WallRun.State>(jump.WallRunState, wallRun.CurrentState));
jump.Add(new Binding<float>(jump.Rotation, rotation.Rotation));
jump.Add(new Binding<Vector3>(jump.Position, transform.Position));
jump.Add(new Binding<Vector3>(jump.FloorPosition, floor));
jump.Add(new Binding<float>(jump.MaxSpeed, player.Character.MaxSpeed));
jump.Add(new Binding<float>(jump.JumpSpeed, player.Character.JumpSpeed));
jump.Add(new Binding<float>(jump.Mass, player.Character.Mass));
jump.Add(new Binding<float>(jump.LastRollKickEnded, rollKickSlide.LastRollKickEnded));
jump.Add(new Binding<Voxel>(jump.WallRunMap, wallRun.WallRunVoxel));
jump.Add(new Binding<Direction>(jump.WallDirection, wallRun.WallDirection));
jump.Add(new CommandBinding<Voxel, Voxel.Coord, Direction>(jump.WalkedOn, footsteps.WalkedOn));
jump.Add(new CommandBinding(jump.DeactivateWallRun, (Action)wallRun.Deactivate));
jump.FallDamage = fallDamage;
jump.Predictor = predictor;
jump.Bind(model);
jump.Add(new TwoWayBinding<Voxel>(wallRun.LastWallRunMap, jump.LastWallRunMap));
jump.Add(new TwoWayBinding<Direction>(wallRun.LastWallDirection, jump.LastWallDirection));
jump.Add(new TwoWayBinding<bool>(rollKickSlide.CanKick, jump.CanKick));
jump.Add(new TwoWayBinding<float>(player.Character.LastSupportedSpeed, jump.LastSupportedSpeed));
 
wallRun.Add(new Binding<bool>(wallRun.IsSwimming, player.Character.IsSwimming));
wallRun.Add(new TwoWayBinding<Vector3>(player.Character.LinearVelocity, wallRun.LinearVelocity));
wallRun.Add(new TwoWayBinding<Vector3>(transform.Position, wallRun.Position));
wallRun.Add(new TwoWayBinding<bool>(player.Character.IsSupported, wallRun.IsSupported));
wallRun.Add(new CommandBinding(wallRun.LockRotation, (Action)rotation.Lock));
wallRun.Add(new CommandBinding<float>(wallRun.UpdateLockedRotation, rotation.UpdateLockedRotation));
vault.Add(new CommandBinding(wallRun.Vault, delegate() { vault.Go(true); }));
wallRun.Predictor = predictor;
wallRun.Add(new Binding<float>(wallRun.Height, player.Character.Height));
wallRun.Add(new Binding<float>(wallRun.JumpSpeed, player.Character.JumpSpeed));
wallRun.Add(new Binding<float>(wallRun.MaxSpeed, player.Character.MaxSpeed));
wallRun.Add(new TwoWayBinding<float>(rotation.Rotation, wallRun.Rotation));
wallRun.Add(new TwoWayBinding<bool>(player.Character.AllowUncrouch, wallRun.AllowUncrouch));
wallRun.Add(new TwoWayBinding<bool>(player.Character.HasTraction, wallRun.HasTraction));
wallRun.Add(new Binding<float>(wallRun.LastWallJump, jump.LastWallJump));
wallRun.Add(new Binding<float>(player.Character.LastSupportedSpeed, wallRun.LastSupportedSpeed));
player.Add(new Binding<WallRun.State>(player.Character.WallRunState, wallRun.CurrentState));
 
input.Bind(rollKickSlide.RollKickButton, settings.RollKick);
rollKickSlide.Add(new Binding<bool>(rollKickSlide.EnableCrouch, player.EnableCrouch));
rollKickSlide.Add(new Binding<float>(rollKickSlide.Rotation, rotation.Rotation));
rollKickSlide.Add(new Binding<bool>(rollKickSlide.IsSwimming, player.Character.IsSwimming));
rollKickSlide.Add(new Binding<bool>(rollKickSlide.IsSupported, player.Character.IsSupported));
rollKickSlide.Add(new Binding<Vector3>(rollKickSlide.FloorPosition, floor));
rollKickSlide.Add(new Binding<float>(rollKickSlide.Height, player.Character.Height));
rollKickSlide.Add(new Binding<float>(rollKickSlide.MaxSpeed, player.Character.MaxSpeed));
rollKickSlide.Add(new Binding<float>(rollKickSlide.JumpSpeed, player.Character.JumpSpeed));
rollKickSlide.Add(new Binding<Vector3>(rollKickSlide.SupportVelocity, player.Character.SupportVelocity));
rollKickSlide.Add(new TwoWayBinding<bool>(wallRun.EnableEnhancedWallRun, rollKickSlide.EnableEnhancedRollSlide));
rollKickSlide.Add(new TwoWayBinding<bool>(player.Character.AllowUncrouch, rollKickSlide.AllowUncrouch));
rollKickSlide.Add(new TwoWayBinding<bool>(player.Character.Crouched, rollKickSlide.Crouched));
rollKickSlide.Add(new TwoWayBinding<bool>(player.Character.EnableWalking, rollKickSlide.EnableWalking));
rollKickSlide.Add(new TwoWayBinding<Vector3>(player.Character.LinearVelocity, rollKickSlide.LinearVelocity));
rollKickSlide.Add(new TwoWayBinding<Vector3>(transform.Position, rollKickSlide.Position));
rollKickSlide.Predictor = predictor;
rollKickSlide.Bind(model);
rollKickSlide.VoxelTools = voxelTools;
rollKickSlide.Add(new CommandBinding(rollKickSlide.DeactivateWallRun, (Action)wallRun.Deactivate));
rollKickSlide.Add(new CommandBinding(rollKickSlide.Footstep, footsteps.Footstep));

我遇到了一大堆的问题。我创建了绑定循环,导致死循环。我发现初始化顺序常常很重要。然而,初始化是数据绑定的噩梦,一些属性在添加绑定时被多次初始化。

当添加动画的时候,我发现数据绑定使得在两个状态之间动画化,变得困难和不直观。不仅仅是我遇到了这个问题。在解释任何时候运行动画都必须关闭数据绑定之前,先看一下视频 Netflix 的这个演讲——人们侃侃而谈 React 是多么伟大的技术。

I我也意识到需要打开或关闭数据绑定,所以我添加了一个新的变量:

class Binding<T>
{
    public bool Enabled;
}

不幸的是,这破坏了数据绑定的目的。我想摆脱 UI 状态,这段代码实际上添加了一些。我要怎样消除这个状态?

我知道!数据绑定!

class Binding<T>
{
    public Property<bool> Enabled = new Property<bool> { Value = true };
}

是的,我真的试了一下。 它一直是绑定着的。 我很快意识到这是多么的疯狂。

我们该如何改进数据绑定呢? 尝试让你的 UI 变成真正的功能性的和状态无关的。dear imgui 是一个很好的例子。尽可能分离行为和状态。避免那些让创建状态变得容易的技术。创建状态是需要付出代价的。

总结

还有很多很多更尴尬的错误需要讨论。我发现了另一种「创造性」的方法来避免全局变量;有一段时间,我和闭包纠缠不清;我设计过「实体」、「组件」、「系统」,这种可以是任何东西的对象;我曾试过用多线程的方式运行一个体素引擎,通过在每个使用它的地方进行加锁。

以下是这些经验的总结:

  • 提前处理,而不是简单地将问题留给电脑。
  • 分离行为和状态。
  • 编写单一功能函数。
  • 先写客户端代码。
  • 写无聊的代码。

以上是我的编程故事。欢迎大家分享自己的编程糗事~

本文文字及图片出自 伯乐在线

余下全文(1/3)
分享这篇文章:

请关注我们:

发表回复

您的电子邮箱地址不会被公开。 必填项已用 * 标注