
简介这是一份基于Cocos Creator开发的《夜幕降临》游戏完整项目源码目标是帮助个人开发者、小公司与初中级游戏编程人员研究上线级游戏的真实代码结构。项目主要使用TypeScript与JavaScript编写涵盖场景关卡、预制体、配置数据、资源管理等多个模块代码组织方式贴近实际商业项目便于对照理解从游戏启动到核心玩法实现的全过程。压缩包共858个文件zip格式下整体仅7.64MB极其轻量。文件类型以465个meta元数据、269个png图片素材、64个prefab预制体为主体同时包含28个ts脚本、20个json配置以及fire场景、mp3/wav音频、js与mtl等辅助文件能清晰呈现场景搭建、UI预制体、动画事件、音频配置和资源引用关系适合想研究小体积完整项目、快速上手Cocos Creator的开发者。目前已有1459人学习下载。这份源码可以直接运行参考读者既能通过prefab与png对照学习美术资源的拼接方式也能从ts脚本和json配置中理解玩法逻辑与数值设计还可以借鉴fire场景中的动画与关卡流程编排省去从零搭建工程的时间是学习Cocos Creator项目实战的实用素材。1. 收到 cocos creator 源码包先别急着双击场景运行收到一个 cocos creator 源码包里面同时出现 GameEntry.fire、game05.json、JSbridge.js 这样一批文件你要做的第一件事不是双击场景点运行而是先读懂它们之间的启动关系。“夜幕降临”这个项目包的典型特征就是入口场景与关卡配置完全分离GameEntry.fire 只负责拉起全局逻辑game05 等场景配合同名 JSON 动态装配内容JSbridge.js 再为打包后的原生环境提供消息通道。对个人学习和小公司做项目参考来说这套结构比堆满节点的单场景写法有营养得多。它解决的是大多数 Cocos Creator 项目做到中期都会撞上的问题场景越堆越多节点层级越叠越深最终不敢动主场景。下面我按文件拓扑、JSON 装配、JSB 桥接三个层面把这个包拆开。2. .fire 场景与 GameEntry 启动链源码包的文件拓扑2.1 先把文件分成三类再进编辑器在 Cocos Creator 2.x 里.fire 是场景文件的标准序列化格式里面虽然有“源码”字样但它不是可执行的 JS 逻辑而是节点树、组件和资源引用的 JSON 描述。看到 GameEntry.fire 这种命名基本可以判定这是整个工程的启动场景game05.fire、game04.fire 这些是业务子场景game05.json、game09.json 等一组 JSON 更像配置表而不是场景文件。建议先不要双击打开场景先把文件按下面的表格归档文件/目录类型在这个框架里的角色GameEntry.firecc.SceneAsset启动入口场景通常只挂 GameEntry 脚本与少量全局节点game05.fire、game04.fire 等场景文件业务章节场景和同名 JSON 配置配对使用game05.json、game09.json 等JsonAsset关卡数据记录出生点、怪物、背景资源路径、事件触发条件JSbridge.js纯逻辑脚本原生环境与引擎脚本之间的消息通道这种“场景 JSON 配置”的组合方式在 cocos2d 系引擎的小团队项目里非常常见。场景当作容器JSON 当作数据输入策划调关卡难度不用频繁进编辑器改 prefab只要改配置就能影响场景生成结果。我拿到这类源码包会先确认一件事JSON 放在 resources 目录还是 Asset Bundle因为后面动态加载的路径算法完全不一样。打开工程前先用 Node 快速检查 .fire 的可解析性和节点规模。因为 .fire 本质是 JSON写一个最简脚本就能拿到场景根节点的第一层子节点数量node -e const fsrequire(fs);const sJSON.parse(fs.readFileSync(assets/GameEntry.fire,utf8));console.log(children,s.scene._children.length)输出的是场景最外层子节点数量。如果这个数是 1说明 GameEntry 场景里只有一个承载入口脚本的节点如果是几十个说明这个场景直接堆了大量 UI耦合度偏高。我判断一个 Cocos Creator 源码工程是否好改第一眼就看这类数字。另一个值得注意的点是 .fire 里的脚本引用通过__uuid__关联脚本文件改名或移动后编辑器会报 missing script所以看源码时可以大胆读改文件名要谨慎。2.2 GameEntry入口脚本与场景切换顺序GameEntry.fire 通常挂着一个同名组件脚本负责三件事初始化 SDK 或原生桥、读取启动配置、切换第一个业务场景。在这套源码包里GameEntry 单独占一个场景而不是做成常驻节点说明项目走的是“场景切换、脚本单例化”的路子。这种写法有个优势入口场景干净所有业务逻辑从 game05 才开始生效调试时跳过启动阶段只要替换场景名即可。常见实现是把入口逻辑收敛到一个常驻节点上cc.game.on(cc.game.EVENT_GAME_INITED, () { cc.director.loadScene(game05); });这里EVENT_GAME_INITED是引擎初始化完成事件loadScene的传参必须和 .fire 文件名一致且不带扩展名。如果需要在进第一个场景前先拉远程配置应该把loadScene放进配置回调里否则会出现“场景先跑、数据后到”的时序问题。我一般会在 GameEntry 脚本里加一个_ready标记只有配置返回后才允许切场景如果 3 秒还没返回打一条level config timeout并直接进默认关卡避免真机上白屏卡死。2.3 JSbridge.js 为什么出现在入口场景JSbridge.js 在这套包里不是场景而是被 GameEntry 场景里某个节点挂载或 require 的纯逻辑脚本。放在入口场景里是为了在第一个场景渲染前就把window.JSBridge这类全局方法准备好。移动端 WebView 或打包 apk 后的原生环境里原生代码随时可能主动调用 JS 方法如果入口阶段没把桥接挂上去切换到 game05 后再初始化边缘时机就会报undefined is not a function。确定脚本是否被场景挂载可以查 .fire 里的组件__type__字段。序列化后字段名可能被压缩更快的做法是直接搜索引用grep -rn JSbridge assets/ --include*.fire如果结果为空说明 JSbridge.js 是被其他 JS 文件 require 的不是被场景直接挂载。这两种挂载方式决定初始化时机场景挂载走组件的onLoadrequire 则按模块首次被依赖的时机执行。排错时先分清是哪一种不然找半天“为什么桥接没生效”其实方向就错了。3. gameXX.json 关卡配置与动态场景装配3.1 理解 JSON 在 Cocos Creator 资源系统中的加载路径Cocos Creator 的 JsonAsset 默认只能从 resources 目录或 Asset Bundle 下动态加载。在这套源码包里game05.json、game09.json 这类文件如果放在assets/resources/config下加载时写成cc.resources.load(config/game05, cc.JsonAsset, callback)如果放在assets/resources根目录就写成cc.resources.load(game05, cc.JsonAsset, callback)。路径不能带resources/前缀也不能带.json后缀这两点是新手最容易踩的坑。一个典型的关卡 JSON 会包含这些字段{ id: 5, name: nightfall_level_5, bgm: audio/bgm/stage5, background: textures/bg/night_sky, player: { spawn: [0, -240], hp: 3 }, entities: [ { prefab: prefabs/enemy_a, x: 180, y: -20, count: 3 }, { prefab: prefabs/enemy_b, x: -220, y: 60, count: 2 } ] }打开源码包后第一件事是找一个结构最简单的 gameXX.json看实际字段与上面这套差多远。字段命名不同没关系关键是有没有独立的entities、spawn、background结构。如果 JSON 里直接写了坐标和 prefab 路径说明这个工程的场景生成是数据驱动的完全可以把这套思路抄到自己项目里。如果 JSON 只是零散的数值表那它大概率只服务于某个具体系统复用价值会低不少。3.2 用 JsonAsset 数据实例化场景节点拿到 JSON 后装配逻辑通常写在一个 LevelLoader 组件里。完整的可跑流程是先在编辑器里把加载脚本挂到空节点再把 JSON 文件放进 resources 目录最后运行下面的代码const LevelLoader cc.Class({ extends: cc.Component, properties: { levelId: 5, itemPool: cc.Prefab }, onLoad() { this._loadLevel(this.levelId); }, _loadLevel(id) { const path config/game${String(id).padStart(2, 0)}; cc.resources.load(path, cc.JsonAsset, (err, asset) { if (err) { cc.error([LevelLoader] 加载关卡配置失败: , err.message); return; } this._composeLevel(asset.json); }); }, _composeLevel(data) { cc.log([LevelLoader] 关卡 ${data.id} 背景: ${data.background}); data.entities.forEach((cfg) { cc.resources.load(cfg.prefab, cc.Prefab, (pErr, prefab) { if (pErr) { cc.warn([LevelLoader] 预制体缺失 , cfg.prefab); return; } const node cc.instantiate(prefab); node.setPosition(cfg.x, cfg.y); this.node.addChild(node); }); }); } });这段代码里有三个容易出错的地方。第一String(id).padStart(2, 0)会把 5 变成05和源码包里 game05.json 的命名对上如果命名是 game5.json这里必须去掉补零逻辑。第二cc.resources.load的第二个参数是资源类型JSON 对应cc.JsonAssetPrefab 对应cc.Prefab类型传错回调不会执行。第三动态加载的 prefab 路径必须在 resources 下否则加载器返回的是Can not find错误而不是业务代码里能捕获的异常。场景装配完成后节点坐标的基准是 Canvas 的坐标系原点在屏幕中心x 向右为正y 向上为正。JSON 里写的坐标看起来像是从其他工具导出的视觉坐标需要先用真机或浏览器预览验证一两个点位。如果所有动态创建的节点都挤在屏幕中心大概率是父节点坐标系和 JSON 数据的原点定义不一致要么把 JSON 坐标整体平移要么给挂载节点加一个偏移组件。3.3 关卡编号与 JSON 文件名的映射约定这套源码里 game02、game04、game05、game06、game09、game10、game14 并没有连续编号。线上项目出现这种断档可能是删过中间关卡也可能是编号本身就是配置表里的外层 key。无论哪种原因代码里都不要写死game05而是维护一个关卡顺序数组const LEVEL_SEQUENCE [2, 4, 5, 6, 9, 10, 14]; const currentIndex LEVEL_SEQUENCE.indexOf(this.levelId); const nextLevelId LEVEL_SEQUENCE[currentIndex 1];这样后续加关卡只改数组和 JSON 文件不用动场景逻辑。把文件名编号当成纯数据而不是逻辑是这类项目维护成本最低的做法。如果 JSON 里存在跳关数据比如通关 game05 后直接进 game09那映射关系就不要写死在代码里而是放在一个level_link.json里让策划按关卡表自由调整连线关系。4. JSbridge.js 与原生通信打包 APK 前后的关键一环4.1 JSB 与浏览器环境到底差在哪里Cocos Creator 本身是带编辑器的工作流工具它的脚本运行时本质是 JavaScript但在浏览器预览和打包 apk 后的环境完全不同。浏览器里 JS 跑在 WebView原生层可以执行evaluateJavaScript调用 window 上的全局方法打包成 apk 后JS 跑在 Cocos 自带的 JSB 环境里必须通过jsb.reflection才能跳到 Java 层。源码包里单独放一个 JSbridge.js就是为这种双环境调用准备的统一封装。先看一个常见的桥接层实现const Bridge { _cbMap: {}, call(method, params, callback) { const token ${Date.now()}_${Math.floor(Math.random() * 1000)}; if (callback) this._cbMap[token] callback; if (cc.sys.isNative) { if (cc.sys.os cc.sys.OS_ANDROID) { jsb.reflection.callStaticMethod( org/cocos2dx/javascript/AppActivity, nativeCall, (Ljava/lang/String;Ljava/lang/String;)V, method, JSON.stringify({ token, params }) ); } else if (cc.sys.os cc.sys.OS_IOS) { jsb.reflection.callStaticMethod(AppDelegate, nativeCall:withParams:, method, JSON.stringify(params)); } } else if (window.JSBridge) { window.JSBridge(method, JSON.stringify({ token, params })); } else { cc.warn([Bridge] 当前环境没有可用桥接, method); } }, onNativeMessage(msgStr) { const msg typeof msgStr string ? JSON.parse(msgStr) : msgStr; const cb this._cbMap[msg.token]; if (cb) { cb(msg.data); delete this._cbMap[msg.token]; } } }; window.JSBridge Bridge.onNativeMessage.bind(Bridge); module.exports Bridge;这段代码比简单写一个window.JSBridge function(){}多做了三件事。第一用 token 把异步回调对应起来避免多个原生回调互相覆盖这在有计费、签到、分享回调的游戏里几乎是必须的。第二用cc.sys.isNative和cc.sys.os做环境分流浏览器预览走 window 注入打包 apk 后走 Java 静态方法。第三统一用 JSON 字符串做消息格式Java、Objective-C、JavaScript 三端都只处理字符串不跨语言传对象引用。4.2 原生端反向调用 JS 的时机与写法桥上最难排查的是“原生主动找 JS”。Android 场景里Java 层拿到消息后通过Cocos2dxJavascriptJavaBridge回到 JS 层Cocos2dxJavascriptJavaBridge.evalString( window.JSBridge window.JSBridge(JSON.stringify({token: t1, data: {status: 0}})) );iOS 侧则用 WKWebView 的evaluateJavaScript或 JavaScriptCore 执行同一段逻辑。注意这里要判空再执行因为 JS 侧的window.JSBridge是入口场景挂载后才赋值的原生调用发生在启动阶段太早时桥可能还没准备好。我一般会在原生端等 WebView 导航完成后再允许 SDK 回调或者让 JS 端主动发一条bridgeReady消息原生收到这条消息前抛给 JS 的调用全部排进队列。原生和 JS 的调用差异可以整理成一张表方便后面定位问题调用方向浏览器预览Android 打包iOS 打包JS 调原生window.JSBridge 注入jsb.reflection.callStaticMethodjsb.reflection.callStaticMethod原生调 JSevaluateJavaScriptevalStringevaluateJavaScript参数格式JSON 字符串JSON 字符串 JNI 签名字符串或 NSDictionary提示Android 静态方法的 JNI 签名(Ljava/lang/String;Ljava/lang/String;)V表示两个 String 参数、void 返回方法重载时签名写错会在调用时直接崩日志里看到NoSuchMethodError时要先对照 Java 方法定义检查签名。4.3 回调丢失与异步时序的处理把 JSbridge.js 迁移到新项目时踩得最多的坑是回调丢失。原因很简单原生回调回来时业务场景可能已经切换原来的组件被销毁但_cbMap里的引用还留着造成泄漏和重复回调。稳妥做法是给 token 加上场景名前缀call(method, params, callback) { const scene cc.director.getScene() ? cc.director.getScene().name : boot; const token ${scene}_${Date.now()}_${Math.floor(Math.random() * 1000)}; // 其余逻辑不变 }场景切换时清掉全部残留回调也是可行的但会带来一个新问题原生 SDK 正在进行的支付或广告流程可能因为 JS 侧清注册表而收不到结果。所以我只清超过 5 秒仍未返回的 token顺手把超时日志打出来用数据判断是原生慢还是回调链路断了。这个超时策略在真机联调阶段非常有用建议即使在发布包里也保留 DEBUG 日志不然线上环境出了问题只能全靠抓崩溃日志猜。5. 拿到源码包后先做这三件事用命令验证启动链路第一件grep 出所有动态加载路径确认哪些资源是运行时加载的哪些是场景静态引用的grep -rn cc.resources.load assets/ --include*.js | head -20 find assets -name *.json -path *resources* | sort前一条命令列出所有运行时加载点后一条列出 resources 下真实存在的 JSON。两边核对后如果代码里加载了config/game05但文件却在assets/resources/根目录路径就错了运行时必然报加载失败。这个检查和引擎版本无关最快暴露源码包的目录结构问题。第二件用 Node 脚本验证 .fire 文件能否被正常解析并输出节点规模防止某个场景文件损坏导致编辑器打不开node -e const fsrequire(fs); const files[GameEntry.fire,game05.fire]; for(const f of files){ const sJSON.parse(fs.readFileSync(assets/f,utf8)); console.log(f: childrens.scene._children.length); } 能 parse 不代表引擎能正常加载但 parse 失败基本说明文件在传输或解压过程中损坏了。正常项目的 .fire 文件都能被当作 JSON 解析如果这里报错先重新解压源码包再看。第三件在 GameEntry 场景脚本里临时加一段启动链路日志定位是配置加载慢还是场景切换慢const t0 Date.now(); cc.resources.load(config/game05, cc.JsonAsset, (err, asset) { cc.log([GameEntry] json ready: ${Date.now() - t0}ms, err${err ? err.message : none}); cc.director.loadScene(game05); }); cc.director.on(cc.Director.EVENT_AFTER_SCENE_LAUNCH, () { cc.log([GameEntry] scene launch done, total${Date.now() - t0}ms); });真机上如果 json ready 的耗时超过 500ms说明 JSON 文件被塞进了首包要拆到 Asset Bundle 里做分包加载如果 scene launch done 一直不出现说明 game05 场景里有组件在onLoad里抛异常用浏览器的性能面板或真机日志按时间线逐帧排查。最后把这个日志保留在 DEBUG 版本里等确认json ready和scene launch done两条日志稳定出现后再做性能优化。本文还有配套的精品资源点击获取