1.3 版本管理与协作流程
Cocos 工程进版本库这件事,比纯代码工程麻烦。原因有三:一是工程根目录里有一堆编辑器生成的缓存目录(library/、temp/),几百 MB,提交进去毫无意义;二是 .scene / .prefab 是 JSON,但它们的合并冲突几乎无法手工解决;三是贴图、音频这类二进制资源会迅速把仓库撑爆。这一节的目标是:让「clone 下来、打开编辑器、能跑」成为常态,而不是惊喜。
本节给出四样东西:一份能直接用的
.gitignore、一套 Git LFS 与协作分工约定、一条 CI 类型检查流水线、一个把版本号带进运行时的组件。做完之后,团队里任何人都能用同一条命令拿到一致的工程状态。这也是 16.1「类型校验」与 17.1「灰度」的前置条件——没有干净的版本管理,后面所有自动化都无从谈起。
1.3.1 判断标准:能重建的不提交
上一节已经给出了判断标准,这里把它落成规则:凡是能由 assets/ + settings/ 重新生成的,都不进版本库。
| 目录 | 能否重建 | 提交 |
|---|---|---|
assets/ | 不能(源) | ✅ |
settings/ | 不能(源) | ✅ |
package.json / tsconfig.json | 不能(源) | ✅ |
library/ | 能(导入缓存) | ❌ |
temp/ | 能 | ❌ |
local/ | 能(个人设置) | ❌ |
profiles/ | 能 | ❌ |
build/ | 能(构建产物) | ❌ |
把 library/ 提交进去的代价不是「多占空间」这么简单:它会让每次 git status 出现上百个变动文件,真正的代码改动淹没在噪声里;而且不同机器生成的缓存不一致,合并时全是冲突。
1.3.2 一份完整的 .gitignore
放在工程根目录,逐条都有理由:
# Cocos Creator 生成,可重建
/library/
/temp/
/local/
/build/
/profiles/
# 依赖
/node_modules/
# 系统与编辑器
.DS_Store
Thumbs.db
*.log
前缀的 / 很关键:它把匹配锚定在工程根目录,避免误伤 assets/ 下同名的子目录(比如某个美术资源恰好叫 temp)。写完 .gitignore,用 git status 确认:应该只剩 assets/、settings/、package.json、tsconfig.json、.gitignore 这几项是「未跟踪」或「已修改」。
1.3.3 大文件走 Git LFS
贴图和音频是二进制,Git 对二进制不做增量存储,改十次就存十份。.gitattributes 里把常见资源类型交给 Git LFS:
*.png filter=lfs diff=lfs merge=lfs -text
*.jpg filter=lfs diff=lfs merge=lfs -text
*.mp3 filter=lfs diff=lfs merge=lfs -text
*.ogg filter=lfs diff=lfs merge=lfs -text
*.psd filter=lfs diff=lfs merge=lfs -text
-text 是关键:它告诉 Git 这些文件不是文本,不要尝试做行尾转换。注意 .meta 文件不要进 LFS——它们是纯文本 JSON,需要正常 diff(2.3 会讲为什么 .meta 必须提交、且必须能 diff)。
团队里每个人 clone 之后要先跑一次 git lfs install,否则拉下来的是 LFS 指针而不是真文件,编辑器导入时会报「资源损坏」。
另一个常被忽略的点:.gitattributes 必须在第一次提交之前就位。 如果先提交了 PNG 再补配置,历史里的二进制已经进库了,后续只能靠 git lfs migrate 重写历史——那是一件让所有人重新 clone 的痛苦事。
1.3.4 初始提交与克隆流程
规矩定好之后,第一次提交按下面的顺序来,别用「一键把整个目录 add 进去」:
cd block-dash
git init
git lfs install
git add .gitignore .gitattributes package.json tsconfig.json
git add assets settings
git commit -m "chore(project): 初始化 block-dash 工程骨架"
git remote add origin git@example.com:team/block-dash.git
git push -u origin main
注意是显式 git add assets settings,而不是 git add .。后者会把 library/、temp/ 也扫进去——即使有 .gitignore,也容易在 .gitignore 写错时翻车。显式列目录能让你在 git status 里亲眼确认「进库的就是这几个」。
新同事 clone 下来之后,只做三件事:
git lfs install(拉真资源);- 用 Cocos Creator 3.8.8 打开工程,等编辑器重建
library/; - 打开
assets/scenes/main.scene,点运行,能跑就对了。
1.3.5 为什么 .scene 冲突这么难解决
.scene 和 .prefab 是 JSON,看起来应该能 merge,实际上几乎不能。原因是它们靠 __id__ 编号互相引用:
以下片段需 Cocos Creator 3.8 编辑器打开,本节不可命令行复现:
{
"__type__": "cc.SceneAsset",
"_name": "main",
"scene": {
"__id__": 1
}
}
__id__: 1 指向文件里第二个对象,那个对象又用 __id__ 指向子节点、组件、资源。两个人同时往场景里加节点,各自的编号体系都会整体偏移——A 的 __id__: 7 和 B 的 __id__: 7 很可能不是同一个东西。git 按行 merge 出来的结果,编号对不上,编辑器一打开就是满屏报错。
在编辑器里打开一个冲突后的 main.scene,你会看到:层级管理器里节点树是错的(子节点挂到了别的父节点下),控制台刷出一堆「引用丢失」。这时手工改 JSON 基本救不回来。
对策有三条,按优先级:
- 一个场景一个人负责。 在团队里明确
main.scene归谁,别人要改就走预制体。 - 把可复用的东西做成预制体。 玩家、金币、障碍都是独立
.prefab,改动落在预制体上,冲突面小得多。 - 场景拆小。 主菜单、关卡、结算各用一个场景,而不是塞进一个巨型场景。
1.3.6 提交信息与分支约定
提交信息用 <type>(<scope>): <subject>,scope 用目录层名,一眼能看出改了哪块:
| type | 用途 | 例子 |
|---|---|---|
feat | 新功能 | feat(game): 新增二段跳 |
fix | 修 bug | fix(ui): 修复 HUD 分数溢出 |
perf | 性能 | perf(render): 合并 UI 图集降低 Draw Call |
refactor | 重构 | refactor(core): 抽出难度曲线函数 |
chore | 杂项 | chore(build): 升级到 3.8.8 |
分支上,main 永远可构建,功能走 feat/* 分支,发布打 tag(如 v0.1.0)。.scene / .prefab 的改动尽量单独成一个提交,方便冲突时整块回滚,而不是和逻辑改动混在一起。
一个可执行的细则:提交前先跑一次 git status,确认没有 library/ 下的文件被误加。 如果 .gitignore 已生效,这一项应该是空的;一旦出现,说明某条路径规则写错了,趁早修。
1.3.7 美术、策划、程序各自碰什么
三个人在同一棵树里干活,最容易撞车。按目录把「谁碰哪块」写清楚,冲突面立刻小一半:
| 角色 | 主要目录 | 冲突源 | 约定 |
|---|---|---|---|
| 美术 | textures/ audio/ | 同名资源覆盖 | 命名带来源前缀,如 player_ ui_ |
| 策划 | configs/ | 数值被并发修改 | 只改 JSON,源表在工程外 |
| 程序 | scripts/ prefabs/ | .prefab 冲突 | 一个预制体一个负责人 |
| 所有人 | scenes/ | .scene 冲突 | 主场景锁定一人维护 |
关键的一条是策划只碰 configs/:数值调整不进脚本、不进场景,全走 JSON。这样改一次数值不需要动任何 .scene,冲突概率接近零。8.1 会把这条约定落成一张配置表。
1.3.8 CI:把类型检查与单测挡在合并前
版本管理的最后一块拼图是自动化。在 .github/workflows/ci.yml 里放一条最小流水线,每次 push 都跑:
name: ci
on: [push, pull_request]
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 26
- run: npm ci
- run: npx tsc --noEmit
- run: npx vitest run
这条流水线能挡住两类错误:tsc --noEmit 挡住类型错误(包括 2.2 会讲的 @property 类型不匹配),vitest run 挡住 core/ 里纯逻辑的回归。它挡不住 Cocos 编辑器里的东西——场景搭错、预制体引用丢失,CI 看不出来,那要靠 16.2 的冒烟截图兜底。
1.3.9 版本号与存档迁移
版本号不只是给包管理器看的,它还决定旧存档能不能被新版本读。所以版本比较这段逻辑必须是纯的、可测的——它属于 core/:
export function compareVersion(a: string, b: string): number {
const pa = a.split('.').map((s) => Number.parseInt(s, 10));
const pb = b.split('.').map((s) => Number.parseInt(s, 10));
for (let i = 0; i < 3; i += 1) {
const diff = (pa[i] ?? 0) - (pb[i] ?? 0);
if (diff !== 0) return diff > 0 ? 1 : -1;
}
return 0;
}
用法是:存档里写一个 schemaVersion,启动时若 compareVersion(存档版本, 当前版本) < 0,就跑迁移链。11.1 会把这条链展开,这里先把「比较」这个最基础的动作做成可单测的函数。
1.3.10 把版本号带进运行时
线上出问题时,第一句话往往是「你装的是哪个版本」。所以构建时要把版本号打进运行时,让崩溃上报和日志都带上它:
import { _decorator, Component, director } from 'cc';
const { ccclass, property } = _decorator;
@ccclass('BuildStamp')
export class BuildStamp extends Component {
@property
buildVersion = '0.1.0';
start(): void {
const scene = director.getScene();
const sceneName = scene ? scene.name : 'unknown';
console.log(`[block-dash] v${this.buildVersion} scene=${sceneName}`);
}
}
buildVersion 是 @property,所以它可以在编辑器里被赋值(2.2 会讲属性面板与序列化的关系);CI 构建时用一个脚本把它改成 package.json 里的版本。这样一条日志就能回答「哪个版本、在哪个场景」——比让用户截图强得多。
1.3.11 本节自检清单
- 根目录有
.gitignore,且git status里没有library/temp/build/ -
.gitattributes已把*.png*.mp3等交给 LFS,且带-text -
.meta文件没有进 LFS(保持文本可 diff) - 团队约定:一个场景一个负责人
- 提交信息遵循
<type>(<scope>): <subject> - CI 里
tsc --noEmit与vitest run都能过 -
compareVersion是纯函数,能被 vitest 断言 -
BuildStamp在启动时打印版本与场景名 -
.gitattributes在第一次提交前就已就位(历史里没有裸二进制)
1.3.12 工程视角的收口
这一节之后,工程里多了 .gitignore、.gitattributes、一条 CI 流水线、一份提交与分支约定、一个 compareVersion 纯函数、一个 BuildStamp 组件。第 1 章到此结束——我们从「产品规格」走到「磁盘结构」再走到「版本纪律」,工程骨架已经完整:有目标、有目录、有协作规则、有自动化。
下一章我们真正进编辑器:场景、节点、预制体到底是怎么被搭出来、又怎么被存成文件的。这是从「配置工程」跨到「使用编辑器」的一步,也是全书第一次大量接触 L3 工程文件——你会看到 .scene 与 .prefab 里那些 __type__ 和 __id__ 到底意味着什么。
阅读导航:上一节:1.2 工程结构与目录约定 · 下一节:2.1 场景、节点与预制体 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。