feat: integrate room framework and isolated subgame bundles

Complete room UI and protocol integration, move game definitions and resources behind bundle entries, publish authoritative version XML, and document single-game builds. Include all current resource changes and experiment artifacts.
This commit is contained in:
2026-09-09 03:13:18 +08:00
parent a7ec8aa6a7
commit 35216f75e7
1028 changed files with 835556 additions and 21415 deletions
@@ -0,0 +1,17 @@
# Youle Version Manifest
Cocos Creator 3.8.8 Web Desktop / Web Mobile 构建扩展,无需编译扩展或手动复制 XML。
在 YouleNexus 编辑器「扩展 → 扩展管理器 → 项目」中刷新并启用 **Youle Version Manifest**。已启用后,正常使用「项目 → 构建发布」构建 Web Desktop 或 Web Mobile。新增扩展若未出现,刷新扩展列表或重开工程。构建日志应出现 `[youle-version-manifest] <gameKey>: <输出路径>/version.xml`。
扩展通过构建任务 `startScene` 和 Asset DB `query-path` 获取真实启动场景,只读检查其中唯一、启用且节点层级激活的 `SubgameAssets.gameKey`,不另设游戏选择配置。按应用约定,从 `assets/games/*/resources/<gameKey>/version.xml` 查找唯一 XML;使用应用的 `version-manifest.js` 解析器验证。XML 字节原样写到 Cocos `result.paths.output`,与实际 `index.html` 同级,不使用构建路径猜测。
`resources.meta` 必须通过编辑器配置为 `userData.isBundle=true`、`userData.bundleName="version-<gameKey>"`,否则立即终止构建。运行时先加载 `version-<gameKey>` bundle,再从该 bundle 加载 `<gameKey>/version` TextAsset;嵌套 resources 文件夹不会自动进入全局 resources bundle。
构建前保存场景、脚本 UUID 元数据、bundle 元数据与 XML 快照;构建后核对这些源文件、启动场景配置及 `result.settings.launch.launchScene`,并检查 `result.settings.assets.projectBundles` 包含 `version-<gameKey>`,再发布 XML。缺失、无效、重复、未选择、bundle 被构建配置排除或构建期间发生变化会抛错终止构建(`throwError=true`)。失败构建不应发布。
源 XML 同时由 Cocos 作为该游戏 `version-<gameKey>` bundle 的 `<gameKey>/version` 导入;发布的根目录 XML 供外部读取。二者只有这一份源 XML。扩展不修改服务器、运行时配置、场景或资源元数据。
开发验证:在仓库根目录运行 `node --test cocoscreator_projects/scripts/test/version-manifest-build.test.mjs`。测试使用临时输出目录验证两个游戏选择、字节保真及错误路径;真实编辑器构建还需检查日志与输出 XML。
API 核对来源:本机 Creator 3.8.8 `resources/app.asar.unpacked/builtin/builder/build-plugin-template`、`@types/public/build-plugin.d.ts`、`@types/public/build-result.d.ts`。
@@ -0,0 +1,6 @@
'use strict';
exports.configs = {
'web-desktop': {hooks: './hooks'},
'web-mobile': {hooks: './hooks'},
};
@@ -0,0 +1,100 @@
'use strict';
const fs = require('node:fs');
const path = require('node:path');
const {parseVersionManifest} = require('../../assets/app/composition/version-manifest.js');
function fail(message) {throw new Error(`[youle-version-manifest] ${message}`);}
// Creator scene scripts use UUIDs compressed to 23 characters (first five hex digits unchanged).
function scriptType(uuid) {
if (typeof uuid !== 'string' || !/^[\da-f]{8}(-[\da-f]{4}){3}-[\da-f]{12}$/i.test(uuid)) fail('Invalid SubgameAssets script UUID');
const hex = uuid.replace(/-/g, '');
const base64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
let compressed = hex.slice(0, 5);
for (let i = 5; i < hex.length; i += 3) {
const bits = Number.parseInt(hex.slice(i, i + 3), 16);
compressed += base64[bits >> 6] + base64[bits & 63];
}
return compressed;
}
function gameKeyFromScene(bytes, uuid) {
const scene = JSON.parse(bytes.toString('utf8'));
if (!Array.isArray(scene)) fail('Startup scene must contain serialized scene objects');
const type = scriptType(uuid);
const selectors = scene.filter(item => item && (item.__type__ === type || item.__type__ === uuid));
if (selectors.length !== 1) fail('Startup scene must contain exactly one SubgameAssets selector');
const selector = selectors[0];
if (selector._enabled !== true) fail('Startup SubgameAssets must be enabled');
let node = scene[selector.node?.__id__];
const visited = new Set();
while (node && !visited.has(node)) {
visited.add(node);
if (node._active !== true) fail('Startup SubgameAssets must be on an active node hierarchy');
if (node.__type__ === 'cc.Scene') break;
node = scene[node._parent?.__id__];
}
if (!node || node.__type__ !== 'cc.Scene') fail('Startup SubgameAssets has no valid scene hierarchy');
if (typeof selector.gameKey !== 'string' || !/^[A-Za-z0-9_-]+$/.test(selector.gameKey)) fail('Startup SubgameAssets.gameKey must be explicitly serialized');
return selector.gameKey;
}
function manifestPath(projectPath, gameKey) {
const games = path.join(projectPath, 'assets/games');
const matches = [];
// The application contract is resources/<gameKey>/version. No independent game selector or registry.
for (const game of fs.readdirSync(games, {withFileTypes: true})) {
if (!game.isDirectory()) continue;
const candidate = path.join(games, game.name, 'resources', gameKey, 'version.xml');
if (fs.existsSync(candidate)) matches.push(candidate);
}
if (matches.length !== 1) fail(`Expected exactly one resources/${gameKey}/version.xml; found ${matches.length}`);
return matches[0];
}
function createSnapshot({projectPath, scenePath, startScene}) {
if (typeof startScene !== 'string' || !startScene) fail('Build startScene is required');
if (typeof scenePath !== 'string' || path.extname(scenePath) !== '.scene') fail('Build startScene did not resolve to a scene asset');
const relative = path.relative(path.join(projectPath, 'assets'), scenePath);
if (relative.startsWith('..') || path.isAbsolute(relative)) fail('Build startScene must be inside project assets');
const scriptMetaPath = path.join(projectPath, 'assets/app/composition/SubgameAssets.ts.meta');
const scriptMetaBytes = fs.readFileSync(scriptMetaPath);
const sceneBytes = fs.readFileSync(scenePath);
const gameKey = gameKeyFromScene(sceneBytes, JSON.parse(scriptMetaBytes.toString('utf8')).uuid);
const xmlPath = manifestPath(projectPath, gameKey);
const bundleMetaPath = path.dirname(path.dirname(xmlPath)) + '.meta';
const bundleMetaBytes = fs.readFileSync(bundleMetaPath);
const bundleData = JSON.parse(bundleMetaBytes.toString('utf8')).userData;
if (bundleData?.isBundle !== true || bundleData.bundleName !== 'version-' + gameKey) {
fail(`Manifest resources must be a bundle named version-${gameKey}: ${bundleMetaPath}`);
}
const xmlBytes = fs.readFileSync(xmlPath);
const identity = parseVersionManifest(xmlBytes.toString('utf8'));
return {projectPath, scenePath, startScene, sceneBytes, scriptMetaPath, scriptMetaBytes, bundleMetaPath, bundleMetaBytes,
sceneUrl: `db://assets/${relative.split(path.sep).join('/')}`, gameKey, xmlPath, xmlBytes, identity};
}
function publishSnapshot(snapshot, options, result) {
if (!snapshot) fail('Missing pre-build manifest snapshot');
if (options.startScene !== snapshot.startScene) fail('Build startScene changed during build');
const launchScene = result.settings?.launch?.launchScene;
if (launchScene !== snapshot.sceneUrl && launchScene !== snapshot.startScene) fail('Built launch scene does not match selected startup scene');
const projectBundles = result.settings?.assets?.projectBundles;
if (!Array.isArray(projectBundles) || !projectBundles.includes('version-' + snapshot.gameKey)) {
fail(`Built project is missing selected version-${snapshot.gameKey} bundle`);
}
for (const [file, bytes] of [[snapshot.scenePath, snapshot.sceneBytes], [snapshot.scriptMetaPath, snapshot.scriptMetaBytes], [snapshot.bundleMetaPath, snapshot.bundleMetaBytes], [snapshot.xmlPath, snapshot.xmlBytes]]) {
if (!fs.readFileSync(file).equals(bytes)) fail(`Source changed during build: ${file}`);
}
if (manifestPath(snapshot.projectPath, snapshot.gameKey) !== snapshot.xmlPath) fail('Manifest selection changed during build');
const output = result.paths?.output;
if (typeof output !== 'string' || !path.isAbsolute(output)) fail('Builder did not provide an absolute output directory');
if (!fs.statSync(path.join(output, 'index.html')).isFile()) fail('Web output index.html is missing');
const destination = path.join(output, 'version.xml');
fs.writeFileSync(destination, snapshot.xmlBytes);
if (!fs.readFileSync(destination).equals(snapshot.xmlBytes)) fail('Published version.xml differs from source');
return destination;
}
module.exports = {createSnapshot, publishSnapshot};
@@ -0,0 +1,21 @@
'use strict';
const {createSnapshot, publishSnapshot} = require('./core');
let snapshot;
exports.throwError = true;
exports.onBeforeBuild = async function(options) {
snapshot = undefined;
const scenePath = await Editor.Message.request('asset-db', 'query-path', options.startScene);
snapshot = createSnapshot({projectPath: Editor.Project.path, scenePath, startScene: options.startScene});
};
exports.onAfterBuild = async function(options, result) {
try {
const destination = publishSnapshot(snapshot, options, result);
console.log(`[youle-version-manifest] ${snapshot.gameKey}: ${destination}`);
} finally {
snapshot = undefined;
}
};
exports.onError = function() {snapshot = undefined;};
exports.unload = function() {snapshot = undefined;};
@@ -0,0 +1,8 @@
{
"name": "youle-version-manifest",
"title": "Youle Version Manifest",
"package_version": 2,
"version": "1.0.0",
"description": "Publish the selected startup game's authoritative version.xml in Web builds.",
"contributions": {"builder": "./builder.js"}
}