这篇文章是关于游戏开发的架构原则,以下是翻译结果:
开始之前
这篇文章是为那些写过游戏代码的人准备的,尤其是那些觉得自己的代码很混乱但不知道如何改善的人。
这篇文章不是为:
- 无编程经验的人
- 编程经验不多的人
- 只使用 AI 生成代码并且苦于调试的人
我知道很多人已经不再编写代码了。但是我还是想写这篇文章,因为:
- 即使你只给 AI 生成代码,可能会生成更少的混乱代码。
- 我自己也曾经很长时间内苦于这个问题。
如果你是 "All in Claude" — 这篇文章的目标受众就是你的 Claude。把它塞进系统提示中。
数据管理
游戏有很多数据。尤其是静态数据。数据放在哪里是一个很大的问题。以下是答案。
新手提示:
静态数据 = 不会改变的数据。例如 "皮卡丘的基础生命值是 35", "火球攻击 25 点伤害"。玩家当前生命值会改变,所以那不是静态数据。
如果你有很少的数据
直接硬编码它。
是的,只要硬编码它。不要因为你读了两个关于架构的博客文章而建立一个数据驱动的管道来存储三个人物的统计数据。
保持简单 — 你拥有的技术栈越小,越容易维护。一个常数对象,导入并直接使用。
如果你有很多数据
在 Excel 中写入数据,然后导出为常见格式。
我知道有很多库可以直接读取 Excel 文件,但我认为这不是一个好主意。它们的大小更大,结构更不确定,而且它们不友好于 Git。
只让你的代码代理写一个导出脚本,输出 YAML 或 JSON,带有类型检查和外键验证。
如果这听起来太麻烦,不用担心 — Someone 已经做了:
当然,采用一个新的工具意味着学习成本增加,但你可以随时问你的代码代理。
我的推荐导出格式是 YAML,因为 YAML 可以手写。当你有数据无法用 spreadsheet 表达时,可以不用任何特殊的工作-around 来手写它。
不要追求完美的数据驱动设计
这是独立开发者中最大的陷阱之一。
有人总是会有一个灵感 — "数据驱动是正确的!" — 然后就开始把所有游戏逻辑都塞进 JSON 文件:
- 行为树在数据中
- 脚本回调在数据中
- 条件分支在数据中
最终,数据文件变成... 一个语言。一个不舒服、未类型化、不可完成的语言,只有你才能读懂。
恭喜你,你已经发明了一个新语言 — 但它比所有现有的语言都糟糕。
事实是:你需要一个真正的编程语言。一个有类型系统、调试器、LSP 和一大堆类似问题的答案都可以在 Stack Overflow 上找到。为什么你要把所有这些都放弃掉 JSON?
此外,你已经在用一个编程语言写游戏了。
大规模、结构化、spreadsheet友好的数据 — 敌人统计、物品信息、技能数据 — 天然地适合表格。spreadsheet 确实很有效,使用 Excel 是对你的心理健康很有益。
对于复杂、分支、异质的数据 — 技能效果是最经典的例子。火球是 "伤害 + 热伤",闪避是 "teleport + invincibility frames",刺骨盾牌是 "反射伤害在活动期间"。他们的逻辑结构完全不同。强迫它们进入相同的表格抽象是灾难。这些情况下,写代码。使用一个字符串标签在数据中标记效果类型并使用一个简单的 switch 来分发。
DI
我使用 DI,但只作为一个服务定位器,并且几乎只使用单例。
新手提示:
DI (依赖注入)听起来可怕,但它只是意味着 我不自己创建东西 — 它们从外部被传入。
你不需要一个 DI 库;简单地将依赖项传递为函数参数就已经是 DI 了。
但很多初学者,试图避免单例,会将 AudioManager 从主菜单通过五个函数层传递到战斗场景 — 这比单例还要糟。
我知道 "单例"听起来不健康。但是游戏确实有很多单例:资源管理器、音频管理器、保存管理器。它们是自然地全局唯一的 — 整个游戏生命周期需要一个实例。使用一个 DI 容器来管理它们。
class CombatManager {
val mobTemplates: DataTable<MobTemplate> by inject()
}
注入任何时候、任何地方。没有长的构造函数参数列表。没有将 AudioManager 通过五个中间层传递。
听起来不错,但你可能会想 — 为什么不直接这样:
object Global {
var mobTemplates: DataTable<MobTemplate>
}
好吧。我的理由是:
- 如果你的语言支持可空类型,你会失去 null 安全性。
- 你会失去一些高级 DI 容器功能,例如键注入或工厂。
当然,不要滥用它。我不是在将一个敌人的健康控制器放入 DI 中。那样太过分了。
状态管理
将你的游戏建模为一个状态机。
我说 激进 地将它建模为一个状态机。
但在你去寻找 "游戏状态机框架" 之前 — 你不需要一个 ultimate 库带有可视化编辑、转换守卫和分支子状态。 你只需要用类型系统表达互斥状态:
type PlayerState =
| { kind: "Idle"; mood: "Joy" | "Sad" }
| { kind: "Moving"; direction: Vector2; speed: number }
| { kind: "Attacking"; comboStep: number }
标记联合、总和类型 — 调它什么都可以。 当你错过一个分支时, switch (state.kind),编译器会立即警告你。这是一个状态错误的最好武器, 比任何状态机库都更可靠,零运行时开销和零学习成本。
isJumping && isSliding 都为 true?那就是一个编译错误。神秘的冻结、动画错误、输入无响应 — 很多都会消失。
UI 也一样。主菜单、设置、游戏HUD、暂停菜单 — 都由一个单一的 UIState 联合类型管理。 没有需要屏幕栈。
函数式编程
你不需要成为一个函数式编程的狂热者。 只要写纯函数时就写。 如果你有零 FP 背景:
纯函数:同样的输入总是产生同样的输出;不修改或依赖外部状态。
游戏中的最经典例子是伤害计算。输入攻击者统计、防御者统计、技能参数 — 输出伤害值和一系列副作用。 这个函数不应该读取一个全局 RNG(将 RNG 传递为参数代替),也不应该直接修改目标的 HP。 它只计算结果。 应用伤害、播放效果、显示数字 — 这是调用者的工作。
不可变数据结构? 是的,非常好 — 但不要追求 100% 不可变性,否则它会变成一个头痛。
抽象
我要说实话: "过度抽象" 并不是你想的那么可怕。
"你只会用一次,不需要抽象" — 这句话被过度使用了。 抽象的目的不是 "可扩展性" — 它是语义的。 它是关于看到 movementController.update(delta) 并立即知道它做了什么,而不是凝视三十行的混乱的代码,处理输入处理、光标检查和动画切换,然后花五分钟时间反向工程意图。
你认为 "我只会用一次" 的代码今天会被使用,但是在三个月后会被重新访问。 未来你会感谢过去你花费的额外十分钟给逻辑起一个合适的名字。
当然,我不是建议将人物移动抽象为策略模式 + 工厂 + 适配器来支持一个可能永远不会存在的游泳功能。 那是疯狂的。 合理的、有意图的抽象和过度抽象是两种完全不同的东西。
本地化
使用 gettext。 这就是这个部分的结束。
如果你想了解更多:
常见的问题是每个其他方法都有一个:你在源代码中看到的是一个冷的关键字符串,如 "menu.start_game" 而不是实际可读的文本。
你想知道按钮实际上是什么?去挖掘一下字符串表。
你想添加一个新的文本?创建一个关键在表中,引用它,处理关键冲突 — 开发体验是痛苦地分散的。
是的,真正的问题是:你必须手动维护一个巨大的字典。
gettext 是唯一真正友好的本地化解决方案。
gettext 如何工作:在源代码中直接写入原文本: _("Start Game")。 工具会自动提取它到 .po 文件中。 翻译者会看到原文本并填写翻译。 代码会保持可读性,不需要 ID 映射,不需要关键冲突。 你甚至可以在代码中写出翻译者的注释:
// TRANSLATORS: 主菜单开始按钮,保持短
_("Start Game")
这个注释会被自动提取到 .po 文件中。 翻译者会立即看到它。
多数形式、变量插值 — gettext 支持它们。 不需要手动字符串连接,不需要尴尬的 "1 个苹果"。 这个工具链已经被测试了三十年,几乎每个语言都有成熟的实现。
调试
为自己建立一个 DebugMenu,否则你只是在折磨自己。
你总是需要在运行时进行欺骗:神奇模式、添加金币、跳过关卡... 如果每个参数调整都需要改变代码、重新编译和运行到特定场景,你就白白浪费了生命。
ImGui 在几乎每个语言中都有绑定,API 很简单:
ImGui::SliderFloat("Move Speed", &moveSpeed, 1.0f, 20.0f);
没有 XML 布局,没有事件系统,没有担心 UI 到游戏状态的同步 — 因为每一帧都会重绘。 隐藏它在一个快捷键或一个编译标志中。 它不会污染你的生产代码。
此外,你也想有快速测试的快捷键。 使用命令行参数进行-profile: --no-steam、--quickStart、--debug。 例如,--quickStart 可以跳过主菜单。 解析这些是简单的 — 它们只是一个字符串数组。
任务运行器
新手提示:
构建脚本自动化重复的日常任务,如: Excel 到 YAML 转换、打包游戏到 .exe、上传到 Steam/Itch。
在自己的编程语言中写构建脚本。 不要学习另一个 DSL。
游戏开发充满了自动化任务:打包、部署、本地化提取... 传统方法是使用 Make 或流行的 Just,然后花两天时间来学习它们的插件生态。 我的建议是:不要。
在项目已经使用的语言中写这些脚本。 我使用 Bun + TypeScript:
async function performBuild(isRelease = false) {
const isWin = isWindows()
const task = isRelease
? "desktopApp:createReleaseDistributable"
: "desktopApp:createDistributable"
const subDir = isRelease ? "main-release" : "main"
const buildFolder = isWin ? "build-win" : "build-linux"
const gradleGenPath = `desktopApp/${buildFolder}/compose/binaries/${subDir}/app/GoodIdleGame`
const platformTag = isWin ? "windows-x64" : "linux-x64"
const finalOutputPath = `${CONFIG.paths.outputBase}/${platformTag}/${isRelease ? "release" : "debug"}`
log.step(`Running Gradle task: ${task}`)
await $`./gradlew ${task}`.throws(true)
log.info(`Preparing directory: ${finalOutputPath}`)
await rm(finalOutputPath, { recursive: true, force: true })
await mkdir(finalOutputPath, { recursive: true })
log.info(`Moving artifacts...`)
await cp(gradleGenPath, finalOutputPath, { recursive: true })
const libs = isWin
? [
`${CONFIG.paths.libs.win}/steam_api64.dll`,
`${CONFIG.paths.libs.win}/steamworks4j64.dll`,
]
: [
`${CONFIG.paths.libs.linux}/libsteam_api.so`,
`${CONFIG.paths.libs.linux}/libsteamworks4j.so`,
]
for (const lib of libs) {
const fileName = basename(lib)
const dest = join(finalOutputPath, fileName)
await cp(lib, dest).catch(() => log.error(`Missing lib: ${lib}`))
}
if (!isRelease) {
await cp(
`${CONFIG.paths.libs.base}/steam_appid.txt`,
join(finalOutputPath, "steam_appid.txt"),
)
}
return finalOutputPath
}
主要好处是你正在真正使用一个编程语言。 你可以轻松地写真实的逻辑。 想象一下在 Just 中实现上述流程。 或者想象一下在不使用任务运行器的情况下发布的痛苦。
或者一个更简单的场景:你想打包的 artifact 名称为 [game_name]_[version].zip — 你可以轻松地从你的游戏代码库中提取版本。
2026 年了。 你可能不再编写代码。但是架构决策仍然是你的 — 因为 AI 生成的代码质量主要取决于你给它的上下文。 这些原则在 AI 上也很有效。
原文发表于我的博客:blog
评论 (0)