《Cocos Creator 3 情怀棋牌源代码的全面重构与工程化实践》(下篇)

内容分享3周前发布
0 0 0

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

资源管理、打包和热更新

《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);
  }
}

这一套写下来只多了几十行代码,但是协议前后端同步的风险大幅下降。以后新增命令,只需要在
payload.ts
里补上类型定义,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 强绑定的逻辑放在
scenes
或模块下,纯逻辑不要引入 Cocos 的 API

所有跨模块复用的逻辑都往 shared 收拢

目录结构最终看上去比较平淡,但很干净。新来的同事打开工程,不需要先看文档,只要沿着目录就能猜到大致结构。


关于老情怀版本和新版本的关系

最后回到开头那个想法。

老情怀版本算是那个时代的产物,用当年的眼光看,它已经把“功能堆起来并跑通”做得很好了。但从今天的视角看,它更多是一套“资源合集 + 能跑的基础工程”。

新这一套基于 Cocos Creator 3 的情怀源代码,我要求自己做的是另外一件事:

不是把老工程搬到新引擎上

而是用现在这一代的工程方法,把“情怀这一类大型项目”重新做一遍,把该抽象的抽象掉,把该分层的分层清楚,把可维护性考虑进去

老版本的价值,在于资源和经验;
新版本的价值,在于结构和长期成本。

如果你手上也有一套类似体量的老工程,其实不一定要马上全盘迁到 Cocos 3,但有几个方向是可以参考这套情怀工程来做的:

把规则抽出来,放到一个独立模块或独立仓库里

把大厅和子模块在资源和逻辑上做一定程度的解耦

尝试搭建一套 shared 风格的前后端共享模型

把部署和搭建流程用文档和脚本固化下来,减少口口相传

文章仅限交流,拒绝商用!!

© 版权声明

相关文章

暂无评论

您必须登录才能参与评论!
立即登录
none
暂无评论...