第十六章:面向对象设计与编程风格指南
本章导言
随着游戏功能日益复杂,仅靠“能跑的代码”已远远不够。一个好的游戏项目,往往需要清晰的结构、合理的职责划分、可扩展的架构和统一的编码风格。否则,项目越做越乱,后期维护和扩展将举步维艰。本章将带你进入游戏架构设计的核心地带,面向对象设计与编程风格规范。
我们将首先学习面向对象分析与设计(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。工厂模式的本质是“通过统一接口创建多种对象,而不暴露具体构造细节”。举个例子,我们每天使用的外卖平台。你不需要知道每家店是怎么炒菜的,甚至不知道餐厅在哪,只关心最终能否拿到想要的饭菜。这种“屏蔽细节、按需生成”的服务模式,就是工厂设计模式的现实体现。
工厂模式的作用在于:
- 集中管理对象创建:减少重复的
new或instantiate操作,统一入口。 - 屏蔽细节,简化调用:使用者不需要关心对象初始化的细节,只需调用接口。
- 便于扩展与维护:新增特效或特定类型时,只需在工厂方法中新增处理逻辑,无需全局修改。
在《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_name、extends、信号定义- 常量声明
- 导出变量
@export - 成员变量与
@onready - 生命周期方法
_init()、_enter_tree()、_ready() - 核心逻辑函数
- 私有辅助函数
类型提示
推荐使用类型注解,来明确变量和函数的类型。这样有助于提高代码的可读性和易调试性。例如:
空值检查
永远不要盲目信任节点或对象存在。使用 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 引擎和具体项目中的应用方式。
最后,我们强调了代码风格规范和项目目录结构管理的重要性,不仅帮助团队协作,也让项目更具可维护性。无论是使用功能分区方式,还是以游戏对象组织资源,合理的结构都是游戏开发顺利推进的重要保障。
掌握本章内容后,你将具备用“系统化的思维”组织复杂游戏项目的能力,也为后续高质量开发打下了坚实基础。