这两篇文章是我用了一周时间系统整理的实践笔记,希望能为你的开发工作提供一些参考价值。继续接着往下讲,把这套 Cocos Creator 3 的情怀棋牌源代码,从“能跑”讲到“好维护、好扩展、好交接”。
资源管理、打包和热更新

情怀这种体量的工程,资源管理如果不提前想明白,后面就是无穷无尽的手工搬砖。所以在 Cocos Creator 3 版本里,我最先做的是把资源分层和热更新边界划清楚。
我把资源简单分成三类:
核心公共资源:字体、基础按钮、通用特效、通用音效等
大厅资源:大厅 UI、图标、活动入口、游戏入口卡片等
子模块资源:各模块的牌面、牌桌、特效、逻辑预制体等
在 assets 目录下就是这种结构:
assets/
common/ 公共 bundle
lobby/ 大厅 bundle
games/
game_a/ 子模块 A
game_b/ 子模块 B
...
Cocos Creator 3 的好处是,一个目录就是一个独立 bundle。打热更新包时,我按照 bundle 维度来划分版本:
common 基本长期不变
lobby 更新频率中等
games 下的各模块单独维护版本号
这样你要给某个模块做一个大改动,只需要重新导出那一个 bundle,对其他模块没有影响。
一个最简单的资源加载示例:
import { assetManager, Prefab, instantiate, Node } from 'cc';
export async function loadPrefabFromBundle(bundleName: string, path: string): Promise<Node> {
return new Promise((resolve, reject) => {
assetManager.loadBundle(bundleName, (err, bundle) => {
if (err || !bundle) {
return reject(err);
}
bundle.load(path, Prefab, (err2, prefab) => {
if (err2 || !prefab) {
return reject(err2);
}
const node = instantiate(prefab);
resolve(node);
});
});
});
}
进入某个模块时,只需要:
const node = await loadPrefabFromBundle('games/game_a', 'prefabs/GameAEntry');
this.node.addChild(node);
整套逻辑很直白,但配合 bundle 结构之后,资源边界就很清晰,对后面做热更新、做模块级灰度都有帮助。
热更新逻辑的实现思路
热更新这块,不同项目会有不同复杂度,这里我说一种基础版的方案,实现成本不高,稳定性还可以。
做法是:
每个 bundle 有一个 manifest 文件,记录版本号、资源列表和 hash
客户端启动时先请求服务器的 manifest,对比本地
有差异的 bundle 拉取对应的资源包下载并替换
一个简化版的 manifest 结构:
{
"bundle": "games/game_a",
"version": "1.0.3",
"files": {
"import/0a/0aabb....json": "hash1",
"native/1b/1bccc....png": "hash2"
}
}
客户端可以用一个非常朴素的代码:
interface BundleManifest {
bundle: string;
version: string;
files: Record<string, string>;
}
async function fetchManifest(bundle: string): Promise<BundleManifest> {
const res = await fetch(`https://example.com/version/${bundle}.json`);
return res.json();
}
async function checkAndUpdateBundle(bundle: string) {
const remote = await fetchManifest(bundle);
const local = loadLocalManifest(bundle); // 自己实现一个简单的存储
if (!local || local.version !== remote.version) {
await downloadBundleAssets(remote);
saveLocalManifest(remote);
}
}
核心是“bundle 粒度的版本控制”,工具层可以慢慢做复杂。先把这条链打通,比什么都重要。
工具链:协议生成和代码检查
一套大型工程如果完全靠手工维护协议,一定会出问题。我在这一版情怀工程里加了一点最少化的工具链,目的只有一个:减少重复劳动。
协议定义我没有上 gRPC,也没有自研 DSL,只是用 TypeScript 做轻量的定义,然后用脚本扫描生成枚举。
比如在 里写:
shared/proto
// shared/proto/cmd.ts
export enum Cmd {
Ping = 'ping',
Login = 'login',
EnterRoom = 'enter_room',
LeaveRoom = 'leave_room',
Play = 'play',
Chat = 'chat',
}
然后客户端和服务端都 import 这个枚举:
// client
net.send(Cmd.Login, { token });
// server
if (msg.cmd === Cmd.Login) {
// 处理登录
}
最开始我只是这么用,后来加了一层简单的类型封装:
// shared/proto/payload.ts
import { Cmd } from './cmd';
export interface LoginReq {
token: string;
}
export interface LoginRes {
userId: string;
name: string;
}
export type ReqPayloadMap = {
[Cmd.Login]: LoginReq;
[Cmd.EnterRoom]: { roomId: string };
[Cmd.LeaveRoom]: { roomId: string };
[Cmd.Play]: { roomId: string; card: number };
};
export type ResPayloadMap = {
[Cmd.Login]: LoginRes;
// 其他返回结构
};
然后客户端封一个泛型方法:
// client/src/net/TypedClient.ts
import { Cmd } from '../../../shared/proto/cmd';
import { ReqPayloadMap, ResPayloadMap } from '../../../shared/proto/payload';
export class TypedClient {
constructor(private raw: NetClient) {}
send<C extends Cmd>(cmd: C, data: ReqPayloadMap[C]) {
this.raw.send(cmd, data);
}
on<C extends Cmd>(cmd: C, fn: (data: ResPayloadMap[C]) => void) {
this.raw.on(cmd, fn as any);
}
}
这一套写下来只多了几十行代码,但是协议前后端同步的风险大幅下降。以后新增命令,只需要在 里补上类型定义,TS 编译器会帮你把所有没改到的地方报红。
payload.ts
工具链部分我只做了两件事:
在 CI 脚本里对 shared 和 client/server 做一次 TypeScript 编译检查
对前端资源做一次简单的未引用检查(找孤立资源)
示例 里的脚本:
package.json
{
"scripts": {
"lint": "eslint shared client server --ext .ts,.tsx",
"build:shared": "tsc -p shared/tsconfig.json",
"build:server": "tsc -p server/tsconfig.json",
"test:types": "pnpm build:shared && pnpm build:server"
}
}
这类脚本听上去不起眼,但长期来看,比写花哨框架有价值。
部署:从手工安装到容器化
部署这块我尽量保持克制,没有直接上 Kubernetes,而是先走 Docker + 简单脚本。
我自己的习惯是先搭一个最小的 docker-compose,让整套流程“从零到跑起来”只需要一个命令。
示意:
docker-compose.yml
version: '3.8'
services:
qh_gateway:
build: ./server
command: ["node", "dist/gateway/start.js"]
environment:
- NODE_ENV=production
- REDIS_URL=redis://redis:6379
depends_on:
- redis
ports:
- "9001:9001"
qh_room:
build: ./server
command: ["node", "dist/room/start.js"]
environment:
- NODE_ENV=production
- REDIS_URL=redis://redis:6379
depends_on:
- redis
redis:
image: redis:7
ports:
- "6379:6379"
这样整个服务端从构建到跑起来就是两步:
pnpm build:server
docker-compose up -d
后面你要上 K8S、要拆分更多服务,都可以在这个基础上慢慢演化。
客户端的部署我习惯分两块:
Web/H5 版本直接构建出静态资源,放到 Nginx
原生 App 则用 Creator 3 自带构建,然后通过热更新机制控制版本
一个非常干净的 Nginx 配置足够用了:
server {
listen 80;
server_name qh.example.com;
root /var/www/qinghuai_web;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
location /version/ {
root /var/www/qinghuai_version;
}
location /api/ {
proxy_pass http://127.0.0.1:9002/;
}
}
整套流程没什么花样,重点是:
一开始就让部署具备“环境可复制”的特性
不让工程寄托在某个人“手动搭建经验”上
只要这一条做到,后面不管谁接盘,难度都不会太大。
调试和排错:录像、回放和日志
项目跑起来以后,最现实的问题是:出了问题怎么查。
我在这套情怀工程里,只做了两件看似简单的事:
每局对局记录关键事件
每个事件保持足够轻量的数据快照
在 shared 里定义一个通用的事件结构:
// shared/model/event.ts
export interface GameEvent {
seq: number; // 序号
time: number; // 时间戳
type: string; // 事件类型
payload: any; // 数据主体
}
服务端在处理消息时,顺手把事件记录下来:
// server/src/game/service.ts
import { GameEvent } from '../../../shared/model/event';
function pushEvent(room, ev: GameEvent) {
room.events.push(ev);
}
export function onPlayerMessage(room, pid, msg) {
if (msg.cmd === 'play') {
// 检查规则...
// 应用结果...
pushEvent(room, {
seq: room.events.length + 1,
time: Date.now(),
type: 'play',
payload: { pid, card: msg.card }
});
}
}
这部分数据存在哪里,可以灵活选择:内存、Redis、数据库都可以。
有了完整事件序列,后面做“复盘工具”就很自然。一个简单的播放器思路是:
客户端拉取这一局的事件列表
初始化一个空状态
从头一个个把事件应用回去
通过 UI 渲染出对应的牌面变更
在前期不做成 UI 工具的情况下,你也可以在 Node 环境下写一个纯文本的回放脚本,不依赖 Cocos:
// tools/replay.ts
import { RoomState } from '../shared/model/room';
import { GameEvent } from '../shared/model/event';
import { applyPlayEvent } from '../shared/rules/applyEvents';
function replay(events: GameEvent[]): RoomState {
let state = createInitialState();
for (const ev of events) {
if (ev.type === 'play') {
state = applyPlayEvent(state, ev);
}
}
return state;
}
这类脚本在查“某一局到底错在哪”时非常有用,尤其是前后端规则都统一在 shared 之后,排错可以更靠近逻辑层,而不是盯着 UI 猜。
二开体验:从“谁都不敢动”到“谁都能改一点”
老情怀工程,一个常见的现象是:知道问题在哪,但没人敢改。原因很简单:
一个模块改动,构建半天
逻辑分散在多个文件里
改挂了也不容易复现
Cocos Creator 3 版的情怀工程,我是按“二开友好”这个目标来搭的。
对子模块的规范主要只有几条:
所有模块入口统一用“模块 ID + 入口场景名”的方式注册
所有对子模块的调用统一通过
GameRegistry
模块内部不要访问全局单例,而是通过注入的上下文拿东西
模块对 shared 的依赖尽量只使用 model 和 rules,不要乱扩展
示意一个模块的入口:
// client/src/games/game_a/GameARoot.ts
import { _decorator, Component } from 'cc';
import { useGameAStore } from './store';
const { ccclass } = _decorator;
@ccclass('GameARoot')
export class GameARoot extends Component {
store = useGameAStore();
onLoad() {
this.store.init();
}
onClickPlayCard(card: number) {
this.store.playCard(card);
}
}
然后在模块内部有自己的 store:
// client/src/games/game_a/store.ts
import { reactive } from 'vue';
import { canPlayCard } from '../../../../shared/rules/canPlay';
import { RoomState } from '../../../../shared/model/room';
import { net } from '../../net/globalClient';
const state = reactive({
room: null as RoomState | null,
});
export function useGameAStore() {
function init() {
// 从服务器拉房间状态
}
function playCard(card: number) {
if (!state.room) return;
const ok = canPlayCard(state.room, state.room.curPlayerId, card);
if (!ok) return;
net.send('play', { card });
}
return {
state,
init,
playCard,
};
}
一个模块需要改逻辑时,开发者只用关心:
自己模块下的 store
与 shared 之间的规则调用
不会碰到大厅侧的逻辑,也不会改到其他模块。
项目命名规则和目录习惯
很多人不太重视命名和目录,我反而觉得这东西比框架重要。一个工程做得久了,名字就是团队的记忆。
这套情怀工程里,我尽量保证几条简单的规则:
文件名用小写加中划线或驼峰,不用拼音
类型定义放在 目录,规则放在
model 目录
rules
与 UI 强绑定的逻辑放在 或模块下,纯逻辑不要引入 Cocos 的 API
scenes
所有跨模块复用的逻辑都往 shared 收拢
目录结构最终看上去比较平淡,但很干净。新来的同事打开工程,不需要先看文档,只要沿着目录就能猜到大致结构。
关于老情怀版本和新版本的关系
最后回到开头那个想法。
老情怀版本算是那个时代的产物,用当年的眼光看,它已经把“功能堆起来并跑通”做得很好了。但从今天的视角看,它更多是一套“资源合集 + 能跑的基础工程”。
新这一套基于 Cocos Creator 3 的情怀源代码,我要求自己做的是另外一件事:
不是把老工程搬到新引擎上
而是用现在这一代的工程方法,把“情怀这一类大型项目”重新做一遍,把该抽象的抽象掉,把该分层的分层清楚,把可维护性考虑进去
老版本的价值,在于资源和经验;
新版本的价值,在于结构和长期成本。
如果你手上也有一套类似体量的老工程,其实不一定要马上全盘迁到 Cocos 3,但有几个方向是可以参考这套情怀工程来做的:
把规则抽出来,放到一个独立模块或独立仓库里
把大厅和子模块在资源和逻辑上做一定程度的解耦
尝试搭建一套 shared 风格的前后端共享模型
把部署和搭建流程用文档和脚本固化下来,减少口口相传
文章仅限交流,拒绝商用!!
