跳转至

第十六章:面向对象设计与编程风格指南

本章导言

随着游戏功能日益复杂,仅靠“能跑的代码”已远远不够。一个好的游戏项目,往往需要清晰的结构、合理的职责划分、可扩展的架构和统一的编码风格。否则,项目越做越乱,后期维护和扩展将举步维艰。本章将带你进入游戏架构设计的核心地带,面向对象设计与编程风格规范。

我们将首先学习面向对象分析与设计(OOAD)的方法论,借助 《Flappy Bird》 游戏案例,演示如何识别对象、分配责任、设计交互,并使用 UML 图清晰建模。接着深入讲解面向对象编程的四大核心特性(抽象、封装、继承、多态),结合《Battle Tank》的实际开发案例和 Godot 内置节点体系,帮助你真正理解这些抽象概念如何服务于实际开发。

然后,我们引入SOLID 设计原则和常见设计模式,这些原则和模式不仅提升代码的可维护性与可复用性,也是迈向专业开发者的重要基石。我们还将比较不同的项目目录组织方式,并总结一套适用于 Godot 的GDScript 编码规范,使你在团队协作中更高效,独立开发时更从容。

通过本章的学习,你将不仅掌握写代码的技巧,更建立起设计系统、构建结构、管理复杂性的工程能力,为打造高质量游戏打下坚实基础。

1 面向对象分析与设计(OOAD)

在游戏开发中,写出一个“能跑的功能”不难,但构建一个清晰、易维护、可扩展的系统,则是高级开发者的能力分水岭。很多初学者在项目初期会一股脑把所有功能塞进一个脚本,比如在玩家脚本 Player.gd 中既控制输入、又处理得分、还处理游戏失败逻辑,刚开始运行正常,但一旦要修改跳跃规则或增加新功能,整个项目就变得难以管理。这就是缺乏结构设计所带来的“维护地狱”。

而 OOAD(面向对象分析与设计)正是一套从思维方式上引导我们在动手编码前,先理清“谁负责什么”以及“如何协作”的系统方法。它是将创意变成代码架构的桥梁。本节将通过 Flappy Bird 的完整案例,带你体验一次真实的 OOAD 思考过程:你将学会如何在写代码之前,先拆分对象、分配责任,并最终形成一套可直接指导编码的类结构设计。

1.1 什么是OOAD?

OOAD(Object-Oriented Analysis and Design)是面向对象的软件开发方法,核心目标是:

  • 用“对象”表示现实中的角色;
  • 用“交互”构建系统的行为;
  • 用“设计”保证代码结构清晰、职责明确。

它包含两个阶段:

阶段 含义 目标
面向对象分析(OOA) 从功能需求出发,识别游戏中的对象与职责 构建概念模型
面向对象设计(OOD) 定义类的属性与方法,明确对象之间的交互关系 构建类结构蓝图

初学者常见的一个误区是:在还没想清楚“有哪些对象、谁负责什么”之前,就直接开始设计类和写代码。OOA 与 OOD 的分离,正是为了避免这种“边想边写”的混乱过程。OOAD 并不是写代码的“额外负担”,而是让你在项目还没写完之前就看清了它的结构轮廓,避免走进混乱实现的陷阱。而且,让系统在需求变化时仍然可控、可扩展、可理解。

1.2 面向对象分析(OOA)

在第五章中我们已经实现了 Flappy Bird 游戏。现在我们“反过来”从设计角度出发,模拟一个专业团队在开发前进行的系统分析过程。

第一步:识别核心对象

我们先从游戏的视觉与交互中,找出所有“具备行为和状态”的独立角色。它们就是系统的核心对象。如下是 Flappy Bird游戏中简化后的核心对象:

对象名 说明
Bird 玩家控制的小鸟,受物理控制
Pipe 成对的障碍物,从右向左移动
GameManager 控制游戏状态:开始、结束、重启

此阶段关注“存在的对象”本身,而不是具体的代码实现。

第二步:识别行为责任

接下来要根据功能需求,进一步识别系统中需要完成的“行为责任”。每个行为应由一个合适的对象“负责”,从而实现清晰的职责划分。以下是 Flappy Bird 中的若干关键行为责任分配表:

责任(行为) 应承担对象
处理玩家输入 Bird
应用重力 Bird
移动障碍物 Pipe
碰撞检测 Pipe
增加得分 GameManager
游戏失败处理 GameManager

我们发现,原本容易混合在一起的逻辑,现在被分解到了各自对象中,形成了清晰的职责边界。

第三步:定义交互

在完成对象识别和责任划分后,我们需要进一步思考这些对象如何协作完成游戏功能。这种交互关系不仅决定了代码结构的可读性,还直接影响系统的耦合度与扩展性。以Flappy Bird 游戏中的核心玩法为例,小鸟飞跃管道、获取分数、失败重启,我们可以抽象出如下若干交互流程:

事件 发送者 接收者 交互说明
玩家点击屏幕 玩家 Bird 控制小鸟上升
Bird 撞到 Pipe Pipe GameManager 游戏结束
Bird 成功通过 Pipe GameManager 游戏得分
游戏失败 GameManager 所有对象 停止所有动作

这些清晰的交互关系,不仅提升系统的可理解性,也为下一步的类图结构设计打下基础。接下来,我们将使用 UML 工具,把分析结果转化为可操作的类关系图与流程图。

1.3 面向对象设计(OOD)

经过前面的分析阶段,我们已经识别出游戏中的主要对象,明确了它们的行为责任与交互关系。接下来,我们要将这些“角色与行为”的抽象概念,进一步转化为可实现的系统结构图,这就是面向对象设计(Object-Oriented Design,简称 OOD)的任务。

在 OOD 阶段,我们的核心工作是:

  • 明确每个对象的属性与方法;
  • 定义类之间的关系(依赖、包含、调用);
  • 可视化地表达这些设计,让团队理解并实现它。

在面向对象设计中,我们常用图示工具来表达系统结构与行为。这些图示通常采用 UML(Unified Modeling Language,统一建模语言) 来绘制。

UML 是一种用于描述、可视化和沟通软件系统设计的标准化图形语言,它并不关心具体使用哪种编程语言或引擎,而是关注系统中“有哪些对象”“对象之间如何协作”以及“行为是如何发生的”。通过 UML 图,我们可以在正式编写代码之前,以直观的方式梳理系统结构,降低设计歧义,提高团队沟通效率。对于游戏开发而言,UML 图并不是为了追求形式上的完整,而是作为一种帮助开发者理清架构思路、指导实现顺序的设计工具。

在实际开发中,最常用、也最适合游戏项目的 UML 图主要有两类:

图类型 目的 表达的内容
类图(Class Diagram) 描述“系统中有哪些类,它们有什么结构” 对象的属性、方法、继承、依赖关系
顺序图(Sequence Diagram) 描述“在某一行为流程中,对象怎么交互” 函数调用顺序、消息流动

UML 类图

类图是面向对象系统的“结构蓝图”。它用于描述系统中有哪些类、每个类承担什么职责,以及类与类之间的静态关系。在设计阶段,类图关注的是结构与责任分配,而不是具体的实现细节。

一个类图的基本构成通常包括三个部分:

  • 类名:表示系统中的一个对象类型;
  • 属性(Attributes):描述对象所持有的状态数据;
  • 方法(Methods):描述对象能够执行的行为。

通过将属性和方法集中在同一个类中,类图体现了面向对象设计中“数据与行为封装在一起”的核心思想。下面是基于 Flappy Bird 游戏的核心结构类图简化版本:

classDiagram
class Bird {
  max_speed: Vector2
  is_dead: bool
  _ready()
  _physics_process()

}

class Pipe {
  passed: bool
  _process()
  on_exited()
}

class GameManager {
  score: int
  add_score()
  on_game_over()
}


Bird --> Pipe : 检查碰撞
Pipe --> GameManager : 通知失败
Pipe --> GameManager : 通知得分

在这张类图中,我们可以清楚地看到:Bird 类专注于玩家控制和自身运动状态;Pipe 类负责障碍物的移动与通过判定;GameManager 类集中管理得分和游戏状态变化。类与类之间的箭头表示它们在逻辑上的依赖或协作关系,而不是简单的“谁调用谁”。这样的类结构设计,将具体行为绑定在最合适的对象之内,使系统职责划分清晰,降低了模块之间的耦合度,也为后续功能扩展留下了空间。

UML 顺序图

如果说类图回答的是“系统长什么样”,那么顺序图回答的则是“系统是如何运行的”。顺序图用于描述对象之间在时间顺序上的交互过程,它关注的是在某一个具体功能或场景中,对象是如何依次发送消息、触发行为并做出响应的。顺序图特别适合用于分析和说明游戏中的核心玩法流程。下面是一段 Flappy Bird 中典型玩法流程的顺序图示例:

sequenceDiagram
actor Player
Player ->> Bird: 玩家控制小鸟
Bird ->> Bird: 计算速度和移动
Bird ->> Pipe: 碰撞检测
alt 碰撞发生
  Pipe ->> GameManager: 游戏结束
else 成功通过
  Pipe ->> GameManager: 得分
end

从这张顺序图中,我们可以直观地看到:玩家输入首先触发 Bird 的行为;Bird 在自身逻辑中完成物理计算和移动;随后与 Pipe 发生交互,判断是否发生碰撞或成功通过;最终由 GameManager 统一处理得分或游戏结束逻辑。顺序图将一个完整的玩法流程拆解为清晰的步骤,使每个对象在流程中的“出场时机”和“责任边界”一目了然。

这两种 UML 图示在面向对象设计中各司其职、相互配合。在团队协同时,类图帮助你向队友说明“系统中有哪些类、它们各自负责什么”;顺序图则帮助你表达“某个功能在运行时会经历哪些步骤”。它们也是项目的设计蓝图。在开始编码前,类图指引你应该创建哪些类和方法;顺序图则提醒你在何处触发行为、如何组织对象之间的调用顺序。通过合理地使用类图和顺序图,我们可以在动手写代码之前,先对系统的结构和运行方式形成清晰、可控的整体认识,从而显著降低实现过程中的试错成本。

1.4 在 Godot 中实践 OOAD

OOAD 是一种语言无关的设计方法,而 Godot 引擎本身的结构设计也非常契合面向对象编程的理念。在 Godot 中,每个 节点和场景均可以看成是一个“类”或者“对象”,而 Node 之间的组合关系就构成了对象协作的结构图。

我们来看看,OOAD 中的三大核心问题,在 Godot 中是如何实现的:

OOAD 概念 Godot 表达方式
对象(类) 每个节点或场景都是一个对象,(可以有属性、方法和信号)
责任(行为) 每个节点的脚本定义它的行为与职责
交互(协作) 通过函数调用、信号连接等机制实现对象之间的通信

在开发一个新游戏项目时,可以通过如下步骤实践OOAD思想:

  • 分析与建模:在编写任何代码之前,先从游戏设计需求出发,用一张白纸、草图或简单列表,列出游戏中可能存在的对象或功能模块,并思考它们各自的职责。此阶段的目标是理清“系统由谁组成”,而不是考虑具体的节点类型或实现细节。模块之间的依赖应尽可能低,理想状态下,修改一个模块不应连锁影响其他模块。

  • 绘制设计图:使用类图和顺序图,将前一步中识别出的对象进一步结构化。类图帮助你明确每个对象应包含哪些属性和方法;顺序图则帮助你梳理某个核心玩法流程中,对象之间的交互顺序。这一步的作用是把模糊的想法转化为清晰的设计蓝图。

  • 构建场景树:根据设计图,将每个核心对象创建为一个独立的场景。在场景内部,通过子节点的方式拆分职责,例如将视觉表现、碰撞体、检测区域等功能分离到不同节点中。这种“组合而非堆叠”的方式,有助于保持场景结构的清晰和灵活。

  • 编写脚本逻辑:为每个节点或场景编写对应的脚本,将设计阶段明确的职责转化为可执行的行为逻辑。每个脚本应尽量只关注自身对象的行为,而不直接承担其他对象的职责,从而保持代码的高内聚性。

  • 设定交互机制:对于父子节点或强关联对象之间的直接控制逻辑,可以使用函数调用来实现;对于跨场景、跨模块的事件通知,则应优先使用信号机制。信号可以有效降低模块之间的耦合度,使对象之间的协作更加灵活和安全。

  • 逐步测试协作:在实现过程中,应不断验证各个模块是否能够独立运行,并逐步检查它们在整体流程中的协作是否符合设计预期。通过频繁的小规模测试,可以尽早发现职责划分不合理或交互设计不清晰的问题。

OOAD 并不是一套束缚开发者的规则,而是一种帮助我们将混沌的创意逐步转化为有结构、有边界系统实现的思维方法。

1.5 设计驱动与原型驱动

在软件工程和游戏开发中,常常可以听到这样两种看似对立的开发方式:

  • 先进行完整设计,再开始编码;
  • 先快速做出原型,通过测试和反馈不断调整设计。

前者强调结构清晰、职责明确,后者强调验证想法、降低试错成本。事实上,它们并不是非此即彼的选择,而是适用于不同阶段、不同不确定性程度的两种策略。

先设计后编码

“先设计后编码”是 OOAD 所倡导的经典流程。通过充分的分析与设计,在编码之前就明确系统中有哪些对象、每个对象负责什么,以及它们之间如何协作。这种方式特别适合以下场景:

  • 游戏规则和玩法已经比较明确;
  • 系统规模较大、模块较多;
  • 需要长期维护或多人协作的项目。

在这些情况下,提前进行设计可以显著降低后期修改成本,避免逻辑混乱和结构失控。然而,这种方式也存在风险:如果在需求尚不明确时就过度设计,很可能会在尚未验证玩法之前,就投入大量时间构建并不适合实际体验的结构。

过早优化的问题。在开发领域中,有一句广为流传的经验之谈:过早优化是万恶之源。这句话并不是在否定设计本身,而是在提醒开发者,不要在尚未验证需求和体验之前,就对结构、性能或扩展性进行过度投入。在游戏开发中,“过早优化”常表现如下。在原型阶段就引入复杂的抽象层;为尚未出现的需求设计过多通用接口;为性能或扩展性编写暂时用不到的代码。这些行为往往会增加理解成本,降低修改速度,反而阻碍创意的快速迭代。

原型驱动开发

在游戏开发中,玩法体验往往比结构设计更具不确定性。很多“看起来很棒”的创意,只有在真正可玩之后,才能判断是否成立。因此,另一种常见的方法是:

  • 先设计一个“足够好”的初步结构,
  • 再快速编程做出可运行的原型,
  • 通过测试和反馈不断修正想法。

这种方式的核心目标不是结构完美,而是尽快获得真实反馈,它非常适合以下情况:

  • 新玩法、新机制尚未验证;
  • 需要频繁调整参数和规则;
  • 项目处于探索或实验阶段。

原型阶段的代码允许存在一定程度的“粗糙”和“临时性”,其价值在于帮助开发者判断:这个玩法是否有趣?是否值得继续投入?

重构:连接原型与成熟系统的桥梁

原型稳定之后,系统结构往往会逐渐暴露出问题,例如职责混乱、模块耦合过高、重复逻辑增多。这时,引入重构(Refactoring)就显得尤为重要。重构的核心目标是:在不改变外部行为的前提下,持续改善代码结构。通过重构,我们可以将原型阶段积累的经验,反过来指导 OOAD 的重新应用:重新识别核心对象;调整不合理的职责划分;引入更清晰的模块边界和交互方式。

这样,开发流程就形成了一个健康的循环:设计 → 原型 → 反馈 → 重构 → 稳定结构。在实际项目中,推荐采用如下分阶段的策略:

  • 轻量设计:在编码前进行基本的 OOAD 思考,避免明显的结构错误;
  • 快速原型:尽快做出可运行、可玩的版本,验证核心玩法;
  • 稳定重构:在玩法和需求相对明确后,对系统结构进行系统性重构;
  • 持续演进:在后续迭代中,根据新需求不断微调设计。

这种方式既保留了 OOAD 带来的结构优势,又充分尊重了游戏开发中“体验优先”的现实需求。设计不是一次性的行为,而是贯穿整个开发过程的持续活动。 真正成熟的开发者,并不是坚持某一种流程,而是能够根据项目阶段和不确定性程度,在设计与实践之间做出合理取舍。

2 面向对象编程基本思想

在上一节中,我们通过 OOAD 方法从需求出发,分析并设计了游戏中的对象结构与交互关系。而要将这些设计真正变成可运行的游戏系统,我们还需要掌握面向对象编程(Object-Oriented Programming, OOP)的基本思想。

OOP 是一种程序组织方式,它使用“类”和“对象”将数据与行为封装在一起,使得程序结构更清晰、可维护性更高。Godot 的 GDScript 是一门高度面向对象的语言,具备所有经典 OOP 特性。

2.1 抽象(Abstraction)

抽象是指从复杂的现实中提取出本质特征,忽略细节,构建出一个通用而清晰的接口或模型,以便更容易理解、使用和扩展程序。抽象的作用在于:

  • 屏蔽细节:让使用者不用关心内部如何实现,只需关注“能做什么”。
  • 统一接口:不同的对象可以通过相同的方式被调用,提高代码的可复用性。
  • 简化逻辑:将复杂系统划分为多个职责明确的部分,易于维护和扩展。

在Godot中,每个节点类型都是对游戏功能的某种“抽象表达”,而这正体现了面向对象编程中的“抽象”思想。例如:

节点类型 所属抽象类 抽象含义
Sprite2D Node2D 可以在二维空间中移动和旋转
Label CanvasItem 可以绘制在屏幕上的元素
Area2D CollisionObject2D 可检测物体进入的区域
AudioStreamPlayer Node 拥有播放音频的通用功能
CharacterBody2D PhysicsBody2D 具有运动能力和碰撞响应的物体

这是一种典型的“抽象到具体”的结构设计:每个子类都继承了父类的通用功能,同时可以定义自己的特殊行为。 举例来说,当你调用 queue_free()删除任何节点时,不必关心它具体是哪种类型,只需知道它是一个 Node,这正体现了抽象接口的价值。

2.2 封装(Encapsulation)

封装是指将数据(属性)与行为(方法)打包在一起,并隐藏内部实现细节,仅暴露必要的接口给外部使用者。它是面向对象编程中的核心思想之一,强调“对外提供功能,对内隐藏实现”。

封装带来的好处包括:

  • 信息隐藏:内部变量与逻辑对外不可见,减少误用和耦合;
  • 模块清晰:每个对象负责自己的行为,其他对象无需干涉其内部;
  • 可维护性高:当实现细节变化时,只要接口不变,外部代码无需修改;

Godot 中每个节点(Node)本身就是一个封装良好的对象,它把图形、行为、输入响应等都组织在自己内部,通过函数与信号与外界沟通。我们不需要知道它是如何绘制、如何移动的,只需调用它提供的接口即可。

在《Battle Tank》游戏中,玩家可以拾取不同类型的道具,例如提高射速道具、恢复生命道具等。虽然这些道具有不同的效果,但它们的基本行为是类似的:被拾取 → 触发效果 → 消失。我们可以将道具封装为一个独立的场景,通过统一的接口与外界交互,而将内部逻辑隐藏在脚本中。

学会封装,就能让你的每个节点像乐高积木一样,自带功能、可插可拔、独立运作,真正构建出结构清晰、易于协作的游戏系统。

2.3 继承(Inheritance)

继承是指一个类(子类)可以继承另一个类(父类)的属性和方法,从而复用已有功能,并在此基础上进行扩展或修改。它是构建类层次结构、实现代码复用的重要机制。继承的核心价值在于:

  • 复用已有功能,继承可以让子类自动拥有父类的属性和方法,避免重复编写相似代码。
  • 表达层级关系,继承帮助我们在代码中表达清晰的层级关系;
  • 便于扩展,可以给子类增加新的行为,而不需要修改父类的代码。

在《Battle Tank》游戏中中,我们创建了玩家和敌人两种子弹场景,但它们都是从一个通用的子弹场景继承而来,用于封装所有子弹共有的行为,例如移动、检测碰撞等。

Godot 的节点系统本身也是一套精妙的继承架构:

节点名称 继承自 描述
Sprite2D Node2D 可以进行 2D 平移、缩放、旋转
CharacterBody2D PhysicsBody2D 有碰撞体、速度控制和移动逻辑
AudioStreamPlayer Node 所有节点的基础类型,提供最小功能

继承让你站在前人的肩膀上工作,不必每次从零开始造轮子。在 Godot 或任何游戏开发中,合理使用继承可以让系统更加清晰、灵活且可维护。

2.4 多态(Polymorphism)

多态是指同一个接口或方法名,在不同对象上表现出不同的行为。它是继承与封装的自然延伸,允许我们编写统一的代码逻辑,而让具体行为交由子类决定,从而提高系统的灵活性和扩展性。

在《Battle Tank》游戏中,我们开发了不同的探测组件。有的使用扇形视野组件,有的使用360度探测组件,但均定义了一个统一的探测方法接口can_see_player(),用完全一样的代码来调用探测逻辑,不同的探测组件会表现出不同的行为。

多态让“接口调用方式保持一致,执行效果却可以因对象而异”,是构建灵活系统和插件式架构的核心机制。

2.5 组合优于继承(Composition over Inheritance)

组合(Composition)指的是一个对象中“包含另一个对象”,并通过该对象提供的功能来完成任务。与之相对的,继承(Inheritance)是通过类之间的“父子关系”共享行为。

比较项 继承 组合
关系类型 A is a B(A是B的一种) A has a B(A拥有一个B)
灵活性 结构固定,子类受限于父类 高度灵活,可以按需添加/替换组合组件
可扩展性 增加行为需创建子类,类数量膨胀 行为独立封装,易于替换与重用
典型应用场景 固定结构、通用功能复用 多变行为、插件式系统

组合的价值在于:

  • 去中心化:每个行为模块独立开发、测试、复用;
  • 配置灵活:可视化编辑器中搭配节点即可组合出新功能;
  • 避免类爆炸:不再需要为每个功能组合创建新类;
  • 易于扩展:新增行为只需实现一个新组件,挂载即可使用。

在《Battle Tank》游戏中,我们在创建敌方坦克场景时,就利用了组合大于继承的优势,在场景中组合了探测组件、武器组件、导航组件等模块,从而实现了敌方坦克的复杂行为。

通过Godot的组合方法,开发者可以高效地构建、实验和迭代游戏功能。它简化了开发过程,使开发者能够在不受传统结构限制的情况下,探索更具创造性的解决方案。

3 SOLID设计原则

在复杂游戏项目中,如何让系统既功能强大又容易扩展和维护?SOLID 是面向对象设计中被广泛认可的五大原则,它为构建健壮、灵活、可维护的代码结构提供了可靠指导。SOLID 是五个英文单词首字母缩写:

  • S:单一职责原则(Single Responsibility Principle)
  • O:开放封闭原则(Open/Closed Principle)
  • L:里氏替换原则(Liskov Substitution Principle)
  • I:接口隔离原则(Interface Segregation Principle)
  • D:依赖反转原则(Dependency Inversion Principle)

3.1 单一职责原则(SRP)

该原则是指每个类或模块应该只有一个职责(功能),且该职责应该被完整封装在一个地方。其目的在于降低耦合,每个模块只做一件事,更容易测试、复用、重构。

在《Battle Tank》游戏中,我们的组件设计就是符合单一职责原则,每个组件只负责一个功能。武器组件专注于攻击,导航组件专注于寻路导航,探测组件专注于搜索敌对单位,血量组件专注于血量管理。

生活中的类比:假设你是一个厨师,同时还要负责洗碗、扫地、擦桌子等多项任务。结果在做饭的同时还得兼顾擦桌子,擦完桌子又得洗手,特别麻烦。如果把每个任务分配给不同的人,就不会互相干扰,效率更高。

3.2 开放封闭原则(OCP)

该原则是指程序中的类应对扩展开放,对修改封闭。也就是说,在不改动已有代码的基础上,通过扩展的方式实现新功能,避免破坏已有功能的稳定性。

在《Battle Tank》游戏中,我们开发承伤组件的方式就是开放封闭原则的一个例子。所有需要承伤的单位都会挂载使用同一个基本的承伤组件,它实现了通用的承伤逻辑。但是不同的单位碰撞形状不一样,我们不去修改承伤组件,而是通过扩展的方式在组件下挂载不同的CollisionShape2D节点,从而实现不同的碰撞形状。

生活中的类比:如果你有一台经典游戏主机,它的硬件结构和系统逻辑设计在最初就已完成,并且保持稳定。而你想玩更多新游戏时,只需要更换或插入新的卡带即可。假如你每增加一个游戏都要拆开主机焊接电路,必然非常低效、不稳定、不可靠。

3.3 里氏替换原则(LSP)

该原则是指子类应该能够替换掉父类,且不影响程序的正确性。换句话说:只要代码中用的是父类,换成任意子类也必须能正常工作。

在我们的《Battle Tank》项目中,使用了有限状态机来控制敌人单位的行为逻辑。我们设计了一个通用的 State 基类,其中包含更新函数physics_update()。每个具体状态类(如巡逻、追击、搜索)都继承自这个 State,并实现了自己的更新函数。按照LSP,只要具体状态类都是 State 的子类,且都正确实现了 physics_update(),状态机无需知道它们是哪种状态,就可以正常运行。

生活中的类比:Type-C 是一种通用的充电接口标准,那么父类就是 Type-C 接口规范,子类是不同厂商实现的充电宝。当你带着支持Type-C手机出行时,你可以任意使用不同品牌的充电宝,只要子类宣称“我支持 Type-C”,就必须真正做到:能插进去,能供电,行为一致,不出错。

3.4 接口隔离原则(ISP)

该原则是指不应该强迫一个类去实现它不需要的接口。应该将庞大接口拆分为多个小接口,按需组合。

在《Battle Tank》游戏中,敌人单位包括了坦克和炮塔。如果我们设计时使用了一个EnemyBase基础类,统一定义了包括攻击、移动在内的所有行为方法。那么在编码时就需要为所有单位实现所有的行为。但是在游戏中,敌人炮塔只需要攻击,不需要移动。结果炮塔被迫实现了 move() 方法。这会导致接口臃肿,逻辑不清晰。更好的做法是将大的接口拆分为组件,然后根据需要自由组合。

生活中的类比:健身房里提供三个项目:游泳、健身、跑步。当你去健身房时,可能只对其中一个项目感兴趣,比如只想去跑步。如果健身房强制所有用户必须为所有项目统一付费,那就相当于让用户为自己根本不会用的功能买单。这不仅浪费资源,还降低了用户体验。

3.5 依赖反转原则(DIP)

该原则是指程序中的上层功能模块不应该直接依赖底层实现。而是依赖一套稳定的接口或抽象层。这样,具体实现可以自由替换,而不会破坏主程序结构。

在《Battle Tank》游戏中,EnemyManager负责管理敌人单位的创建,它需要依赖不同的敌人单位场景。如果我们在EnemyManager脚本中直接写死了敌人场景的加载路径,那后续每次更换敌人单位时都要手动改代码。所以我们使用@export定义了PackedScene类型的变量,这样主逻辑只依赖一个“抽象的敌人”,在实际游戏中使用哪种敌人单位,在检查器面板中拖拽设置即可。

生活中的类比:当你用手机去连接WiFi上网,你只需要关心 WiFi(抽象接口)的设置,网络底层是哪个路由器(实现细节)都不重要,只要它遵循 WiFi 标准协议(抽象),你就能正常使用。

掌握使用上述这些原则帮助我们写出更加灵活、易扩展和易维护的代码,使得游戏系统能够更好地适应变化和新增功能。通过这些原则的实践,代码的可维护性和可测试性大大提高,也能有效避免技术债务和复杂度积累。

4 常见的设计模式

在游戏编程中,设计模式是一些经过验证的解决方案,用于处理在软件开发中常见的设计问题。设计模式并不是固定的代码模板,而是一套应对特定情境的“设计经验总结”。在复杂游戏开发中,合理运用设计模式可以使系统更稳定、扩展更容易、团队协作更顺畅。本节将介绍游戏开发中最常用的四种设计模式:单例模式、观察者模式、工厂模式、状态模式。

4.1 单例模式

单例模式确保在整个游戏中一个类只有一个实例,并且是全局可访问的。单例就像是一个“导演”,整个游戏里只会有一个“导演”,他管理全局数据、控制游戏流程。不过导演身上的功能可以分拆成多个单例,比如有的导演负责管理游戏进程,有的导演负责管理音效,有的导演负责管理数据等。

单例的作用在于:

  • 统一管理游戏的全局状态;
  • 节省资源,避免重复实例;
  • 任意地方都能访问该对象,便于控制流程。

Autoload 是 Godot 实现单例模式的工具,它可以让我们方便地创建和使用全局唯一的对象。像就像内置的变量一样,在游戏的任何地方都可以访问它。而且会自动加载,不需要手动实例化。我们在《Battle Tank》游戏中就将GameManager和SoundManager设置为全局单例。

此外,Godot 自带的一些常用单例,例如Input单例负责管理键盘、鼠标、手柄输入。DisplayServer单例负责管理窗口、显示器、鼠标、键盘等系统级别的显示和输入设备功能。Engine单例负责管理引擎状态,比如是否在编辑器中运行等。

4.2 观察者模式

观察者模式是一种发布-订阅机制:当一个对象状态发生变化,会自动通知依赖它的其他对象,而不需要相互强耦合。就像是你订阅了微信公众号,一旦有新消息,公众号自动推送通知,你不必主动去刷信息。

观察者模式的作用在于:

  • 实现模块间的解耦通信;
  • 支持事件驱动编程;
  • 提高系统的灵活性与可扩展性。

Godot的信号机制就是观察者模式的完美体现。对象通过signal关键字定义信号,其他对象通过 connect()进行信号的订阅或监听,发布者通过emit()函数发布信号,所有订阅了该信号的观察者都会收到通知并作出反应。我们在《Battle Tank》游戏中就大量运用了信号机制,让游戏的各个模块之间可以互相通信,从而实现了模块间的解耦。

开发者可以通过连接信号到函数的方式,让游戏逻辑对这些事件作出反应,无需持续轮询状态。这种机制实现了松耦合的事件驱动架构,是Godot中非常重要的编程范式。

4.3工厂模式

工厂模式是指通过一个工厂方法,集中管理对象的创建逻辑,而不是在各处手动 load 和 instantiate。工厂模式的本质是“通过统一接口创建多种对象,而不暴露具体构造细节”。举个例子,我们每天使用的外卖平台。你不需要知道每家店是怎么炒菜的,甚至不知道餐厅在哪,只关心最终能否拿到想要的饭菜。这种“屏蔽细节、按需生成”的服务模式,就是工厂设计模式的现实体现。

工厂模式的作用在于:

  • 集中管理对象创建:减少重复的 newinstantiate 操作,统一入口。
  • 屏蔽细节,简化调用:使用者不需要关心对象初始化的细节,只需调用接口。
  • 便于扩展与维护:新增特效或特定类型时,只需在工厂方法中新增处理逻辑,无需全局修改。

在《Battle Tank》项目中,我们使用了爆炸特效管理器VFXManager 来集中管理爆炸动画和粒子效果的生成。它根据不同事件,动态实例化预设的动画资源或粒子系统,实现了特效的统一创建、播放与释放流程。这个过程本质上就是一个“轻量级的工厂”,调用者无需关注动画场景如何构建、播放逻辑如何组织。通过这种封装方式,我们实现了“生成特效”的职责集中化,代码整洁、易于测试、扩展性强。

4.4 状态模式

状态模式是指将对象的不同状态封装为独立的状态类,并通过状态管理器进行切换。

状态模式的作用在于:

  • 避免庞杂的条件分支;
  • 每种状态独立开发、测试、维护;
  • 行为灵活、结构清晰,符合开放封闭原则。

想象一下你正在使用的手机。当手机处于锁屏状态时,你点击屏幕,它只会亮起,并提示你滑动解锁;当手机处于解锁状态时,点击屏幕可能是打开 App、滚动页面或执行其他操作;当手机正在通话中,按下电源键则会关闭屏幕而不中断通话;而当手机进入勿扰状态,通知可能被静默处理。从用户角度来看,他们的“输入”很可能是一样的,但手机的响应行为却完全不同。

状态模式的核心思想就是:对象在不同的状态下,拥有不同的行为逻辑;而这些状态逻辑被封装在独立的状态类中,对象本身只负责根据当前状态转发行为。

在《Battle Tank》游戏项目中,敌方坦克就有三种不同的状态,分别是巡逻状态、搜索状态和攻击状态,每一种状态都实现了physics_update方法,函数名一样但内部的行为逻辑不同。状态逻辑互不干扰,修改巡逻逻辑不会影响攻击状态,提高系统稳定性。

虽然状态模式(State Pattern)和有限状态机(Finite State Machine, FSM)都用于处理对象在不同状态下的行为变化,但两者的出发点和关注重点有所不同:FSM 是一种逻辑建模方式,强调用“状态 + 转移规则”来控制流程。而状态模式则是一种面向对象的设计思想,强调将每种状态的行为逻辑封装为独立类,突出代码的解耦与可扩展性。在实际开发中,两者可以结合使用。用状态模式封装每个状态,用 FSM 管理状态之间的切换流程,实现既清晰又灵活的状态管理架构。

5 GDScript 代码风格规范

清晰的代码风格不仅让自己事后更容易理解,还能让他人快速读懂、协作无碍。在团队开发中,良好的风格是代码质量的保障;在个人学习中,它有助于你形成严谨的逻辑思维。下面是Godot官方推荐的GDScript编码规范:

命名约定

类型 示例 命名风格说明
变量名 player_speed 使用小写字母 + 下划线
函数名 move_and_jump() 小写字母 + 下划线,动词优先
类名 GameManager 使用驼峰式命名法(PascalCase)
常量 MAX_HEALTH 全大写 + 下划线
信号名 hit_detected 和函数一样,用下划线风格
节点路径变量 @onready var bullet = $Bullet 命名应清晰反映节点用途

函数设计

  • 避免编写重复的代码,将重复的逻辑提取成独立的函数
  • 保持函数职责单一,长度不宜超过 30 行;超长应拆解。
  • 每行代码不应超过80个字符,如果超过,应该分成多行,尤其是在处理长函数调用时。
  • 函数名以动词开头,清晰说明功能。
  • 私有方法推荐用_前缀,如 _reset_timer()
  • 在函数之间使用空行,以提高可读性。
  • 函数内部重要的逻辑分块时也应该有空行进行分隔。
  • 尽量避免写复杂的多重嵌套条件语句。可以使用早期返回来减少嵌套层级
  • 使用 # 添加注释,注释应简洁明了,说明原因或目的而非“这行做了什么”。

脚本组织建议

脚本内部代码结构的顺序推荐如下:

  • class_nameextends、信号定义
  • 常量声明
  • 导出变量 @export
  • 成员变量与 @onready
  • 生命周期方法 _init()_enter_tree()_ready()
  • 核心逻辑函数
  • 私有辅助函数

类型提示

推荐使用类型注解,来明确变量和函数的类型。这样有助于提高代码的可读性和易调试性。例如:

@export var speed: float = 300.0

func move_player(delta: float) -> void:
           # code

空值检查

永远不要盲目信任节点或对象存在。使用 is_instance_valid()!= null 来检查节点或对象是否可用。

遵循上述这些代码风格规范,不仅能提升个人编码质量,还能让项目具备更强的长期维护能力。

6 项目目录结构建议

在使用 Godot 开发游戏时,如何组织项目文件是非常关键的一步。不同的结构方式,会影响到资源的查找效率、脚本的重用性和多人协作时的清晰度。实践中,主要有两种常见方案,各有优劣。

方案一:按功能模块划分目录

这种方式以“功能类型”为中心组织项目,例如将所有场景、脚本、素材分别放在不同目录中。

res://
├── scenes/              # 所有场景文件
│   ├── player/
│   ├── enemy/
│   └── ui/
├── scripts/             # 所有 GDScript 脚本
│   ├── components/
│   ├── managers/
│   ├── states/
│   └── enemy/
├── assets/              # 所有资源素材
│   ├── images/
│   ├── audio/
│   └── fonts/
├── effects/             # 动画、粒子、特效
└── main.tscn
这种方案的优缺点如下:

  • 分工清晰:不同类型的文件各归其类,便于维护;
  • 易于重用:脚本组件、粒子、UI 可独立迁移或跨项目使用;
  • 更适合中大型项目和多人协作。
  • 脚本、场景、图片被拆散,查找“某个对象完整结构”需跳多个目录;
  • 每增加一个新角色或单位,要记得分配到多个子目录中。

方案二:按对象划分目录

这种方式以“游戏对象”为中心组织目录,将同一对象的场景、脚本、资源统一归到一个目录中。

res://
├── player/
│   ├── player.gd
│   ├── player.tscn
│   ├── player.png
│   └── shoot_sound.wav
├── enemy/
│   ├── enemy.gd
│   ├── enemy.tscn
│   ├── enemy.png
│   └── death_anim.tres
├── ui/
│   ├── hud.gd
│   ├── hud.tscn
│   └── font.tres
└── main.tscn
这种方案的优缺点如下:

  • 聚合紧凑:一个对象的所有文件都集中在一起,便于查阅与管理;
  • 初学者友好:逻辑上更贴近“一个对象一组资源”的思维;
  • 有利于模块打包:将某个角色或道具整体迁移变得非常方便。
  • 不同对象间可能出现资源冗余,多人协作中容易重复创建结构相似的内容
  • 通用脚本可能分散在多个目录中;

实战建议:

  • 学习阶段或原型开发:推荐使用按对象划分的目录结构,更快上手、更直观;
  • 长期项目或多人协作:建议转为模块化结构,统一管理、利于扩展;
  • 也可以尝试混合方案:例如 UI 独立放在 ui/ 中,而玩家角色使用对象聚合结构。

就像整理房间一样,小物件可以按人分类放在抽屉里(按对象划分),也可以统一归类放进工具箱(模块化)。选择结构时,关键判断在于:项目是否可维护、是否可拓展、是否便于协作。

本章小结

本章我们从思想方法到实践操作,系统介绍了面向对象设计(OOAD)、面向对象编程(OOP)以及编程风格规范在游戏开发中的重要价值。我们以 Flappy Bird 和坦克大战为例,讲解了如何识别对象、分配责任、构建类图与交互图,并将这一分析方法落地到 Godot 的节点系统中,强调了信号与函数调用在解耦架构中的合理使用。

我们进一步深入了 OOP 的四大特性:抽象、封装、继承与多态,结合 Godot 内置节点系统与实际游戏案例,帮助读者建立更直观的理解。此外,通过“组合优于继承”的原则,我们反思了继承可能带来的僵化设计,转而倡导模块化、松耦合的架构。

接着,我们学习了SOLID 五大设计原则,通过生动的类比和坦克大战的实际实现,展示了这些原则如何让游戏项目更易于维护与扩展。而在“常见设计模式”部分,我们掌握了单例、观察者、工厂与状态模式,并理解了它们在 Godot 引擎和具体项目中的应用方式。

最后,我们强调了代码风格规范和项目目录结构管理的重要性,不仅帮助团队协作,也让项目更具可维护性。无论是使用功能分区方式,还是以游戏对象组织资源,合理的结构都是游戏开发顺利推进的重要保障。

掌握本章内容后,你将具备用“系统化的思维”组织复杂游戏项目的能力,也为后续高质量开发打下了坚实基础。