// split.js - 将 main..js 切割为多文件加载结构 // 优化: 资源模块(标准 t.exports=i.p+"..." 格式)用 JSON + 模板替代,不生成独立文件 // 用法: node split.js [bundle路径] [输出目录] [--clean] [--no-hornet] // 未指定 bundle 时自动取项目根目录下最新的 main..js // 未指定输出目录时默认 3dtank.com/play/static/js // --clean 先删除旧产物(unpacked/module_*.js、resources.json、旧 main.*.js;保留 hornet_*.js) // --no-hornet 不在主文件末尾注入 hornet_bundle 自动加载 // 输出: // <输出目录>/main..js (主文件: 加载器 + runtime) // <输出目录>/unpacked/resources.json (资源模块 ID→路径映射) // <输出目录>/unpacked/module_.js (仅代码模块) const fs = require('fs'); const path = require('path'); const acorn = require('acorn'); const walk = require('acorn-walk'); const prettier = require('prettier'); const ROOT = path.resolve(__dirname, '..'); // 自动取最新的 main..js(排除输出目录里的加载器) function AutoDetectBundle() { const cands = fs.readdirSync(ROOT) .filter((f) => /^main\.[0-9a-f]+\.js$/.test(f)) .map((f) => path.join(ROOT, f)) .filter((f) => fs.statSync(f).isFile()) .sort((a, b) => fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs); return cands[0] || null; } const argv = process.argv.slice(2); const flags = argv.filter((a) => a.startsWith('--')); const positional = argv.filter((a) => !a.startsWith('--')); const CLEAN = flags.indexOf('--clean') >= 0; const WITH_HORNET = flags.indexOf('--no-hornet') < 0; const LOADER_ONLY = flags.indexOf('--loader-only') >= 0; // 只重生成主文件,复用已有模块文件 const autoBundle = positional[0] ? null : AutoDetectBundle(); const BUNDLE_PATH = path.resolve(positional[0] || autoBundle || ''); const OUT_DIR = path.resolve(positional[1] || path.join(ROOT, '3dtank.com', 'play', 'static', 'js')); const UNPACKED_DIR = path.join(OUT_DIR, 'unpacked'); const MAIN_PATH = BUNDLE_PATH ? path.join(OUT_DIR, path.basename(BUNDLE_PATH)) : ''; const RESOURCES_JSON_PATH = path.join(UNPACKED_DIR, 'resources.json'); // UMD 包装模板 const UMD_PREFIX = `var __script_base_url__=(typeof document!=="undefined"&&document.currentScript&&document.currentScript.src)?document.currentScript.src.replace(/[^\\/]+$/,""):"./";!function(t,n){"object"==typeof exports&&"object"==typeof module?module.exports=n():"function"==typeof define&&define.amd?define([],n):"object"==typeof exports?exports.Launcher=n():t.Launcher=n()}(globalThis,async()=>{`; const UMD_SUFFIX = (bundlePath) => `})//# sourceMappingURL=${path.basename(bundlePath)}.map\n`; // 资源模块识别:标准格式 (t,n,i)=>{"use strict";t.exports=i.p+"path"} // 提取 path function tryExtractResource(valueSrc) { // 标准格式: t.exports = i.p + "..." const m = valueSrc.match( /\{\s*"use strict";\s*(?:t|n|i)\.exports\s*=\s*(?:t|n|i)\.p\+"([^"]+)"\s*;?\s*\}/ ); if (m) return { type: 'std', path: m[1] }; // 变体: t.exports = i.p + "..." (无 use strict 或无大括号) const m2 = valueSrc.match( /(?:t|n|i)\.exports\s*=\s*(?:t|n|i)\.p\+"([^"]+)"\s*;?/ ); if (m2 && valueSrc.length < 200) return { type: 'std', path: m2[1] }; return null; } (async () => { if (!BUNDLE_PATH || !fs.existsSync(BUNDLE_PATH)) { console.error('找不到待切割的 bundle:' + (BUNDLE_PATH || '(未自动探测到 main..js)')); process.exit(1); } if (!fs.existsSync(UNPACKED_DIR)) fs.mkdirSync(UNPACKED_DIR, { recursive: true }); if (CLEAN) { console.log('[0/7] 清理旧产物...'); let removed = 0; for (const dir of [UNPACKED_DIR, OUT_DIR]) { if (!fs.existsSync(dir)) continue; for (const f of fs.readdirSync(dir)) { const isModule = /^module_\d+\.js$/.test(f); const isResources = f === 'resources.json'; const isOldMain = /^main\.[0-9a-f]+\.js$/.test(f) && path.join(dir, f) !== MAIN_PATH; if (isModule || isResources || isOldMain) { fs.unlinkSync(path.join(dir, f)); removed++; } } } console.log(` 已删除 ${removed} 个旧文件(保留 hornet_*.js)`); } console.log('[1/7] 读取原 bundle...'); console.log(` 文件: ${BUNDLE_PATH}`); const source = fs.readFileSync(BUNDLE_PATH, 'utf8'); console.log(` 大小: ${(source.length / 1024 / 1024).toFixed(2)} MB`); console.log('[2/7] 解析 AST...'); const ast = acorn.parse(source, { ecmaVersion: 'latest', sourceType: 'script', allowReturnOutsideFunction: true, }); let modulesDecl = null; walk.simple(ast, { VariableDeclarator(node) { if (node.id && node.id.name === '__webpack_modules__' && node.init && node.init.type === 'ObjectExpression') { modulesDecl = node; } }, }); if (!modulesDecl) throw new Error('未找到 __webpack_modules__'); const objStart = modulesDecl.init.start; const objEnd = modulesDecl.init.end; console.log('[3/7] 分类提取模块 (资源模块 vs 代码模块)...'); const props = modulesDecl.init.properties; console.log(` 共 ${props.length} 个模块`); const resourceMap = {}; // id -> path (资源模块) const codeModuleIds = []; // 代码模块 ID 列表 let idx = 0; let failCount = 0; let resourceCount = 0; let codeCount = 0; for (const prop of props) { idx++; // 计算 ID let keyRaw; let keyNum; if (prop.key.type === 'Identifier') { keyRaw = prop.key.name; keyNum = Number(keyRaw); if (isNaN(keyNum)) keyNum = keyRaw; } else if (prop.key.type === 'Literal') { keyRaw = String(prop.key.value); keyNum = typeof prop.key.value === 'number' ? prop.key.value : keyRaw; } else { keyRaw = `unknown_${idx}`; keyNum = keyRaw; } const valueSrc = source.slice(prop.value.start, prop.value.end); // 尝试识别为资源模块 const resource = tryExtractResource(valueSrc); if (resource) { // 资源模块:存入 resourceMap,不生成独立文件 const idKey = typeof keyNum === 'number' ? keyNum : keyRaw; resourceMap[idKey] = resource.path; resourceCount++; continue; } // 代码模块:美化并写入独立文件 const paddedId = typeof keyNum === 'number' ? String(keyNum).padStart(5, '0') : keyRaw; const idLiteral = typeof keyNum === 'number' ? keyNum : JSON.stringify(keyRaw); if (LOADER_ONLY && fs.existsSync(path.join(UNPACKED_DIR, `module_${paddedId}.js`))) { codeModuleIds.push(keyNum); codeCount++; continue; } let beautified = valueSrc; try { beautified = await prettier.format('(' + valueSrc + ')', { parser: 'babel', semi: true, singleQuote: false, printWidth: 110, tabWidth: 2, }); beautified = beautified.trim(); if (beautified.startsWith('(') && beautified.endsWith(')')) { beautified = beautified.slice(1, -1).trim(); } while (beautified.endsWith(';')) beautified = beautified.slice(0, -1).trimEnd(); } catch (e) { failCount++; } const filePath = path.join(UNPACKED_DIR, `module_${paddedId}.js`); const fileContent = `// ============================================================\n` + `// Module ID: ${keyRaw}\n` + `// ============================================================\n` + `;(window.__modules__ = window.__modules__ || {})[${idLiteral}] = ${beautified};\n`; fs.writeFileSync(filePath, fileContent, 'utf8'); codeModuleIds.push(keyNum); codeCount++; if (idx % 200 === 0) { console.log(` 已处理 ${idx}/${props.length} (资源 ${resourceCount}, 代码 ${codeCount})`); } } console.log(` 完成。资源模块: ${resourceCount}, 代码模块: ${codeCount}, 美化失败: ${failCount}`); console.log('[4/7] 写入 resources.json...'); if (LOADER_ONLY && fs.existsSync(RESOURCES_JSON_PATH)) { console.log(' --loader-only:复用已有 resources.json'); } else { fs.writeFileSync( RESOURCES_JSON_PATH, JSON.stringify( { type: 'webpack-resource-map', generatedAt: new Date().toISOString(), totalResources: resourceCount, resourceMap, }, null, 0 ), 'utf8' ); } console.log(` resources.json 大小: ${(fs.statSync(RESOURCES_JSON_PATH).size / 1024).toFixed(1)} KB`); console.log('[5/7] 提取原 webpack runtime...'); const runtimeStart = objEnd; const returnIdx = source.lastIndexOf('return __webpack_exports__'); if (returnIdx === -1) throw new Error('找不到 return __webpack_exports__'); const returnEnd = returnIdx + 'return __webpack_exports__'.length; let runtimeCode = source.slice(runtimeStart, returnEnd); if (runtimeCode.startsWith(',')) { runtimeCode = ';var ' + runtimeCode.slice(1); } console.log(` runtime 代码长度: ${runtimeCode.length} 字符`); console.log(`[6/7] 生成主文件 ${path.basename(BUNDLE_PATH)}...`); const codeIdsArrayLiteral = codeModuleIds .map((id) => (typeof id === 'number' ? id : JSON.stringify(id))) .join(','); const loaderCode = ` // ===== 切割版模块加载器 ===== window.__modules__ = {}; const __base_url__ = window.__script_base_url__ || './'; // 6.0 单包(unpacked.pack.json):一次请求拿到 resources.json + 全部代码模块, // 避免 126 个小文件碎请求;没有包时自动回退逐文件加载(见 6.2) const __pack_files__ = await (async () => { try { const __pack_resp__ = await fetch(__base_url__ + 'unpacked.pack.json', { cache: 'no-cache' }); if (!__pack_resp__.ok) { console.warn('[Hornet] 单包不可用(HTTP ' + __pack_resp__.status + '),回退逐文件加载'); return null; } const __pack__ = await __pack_resp__.json(); console.log('[Hornet] 单包已载入: ' + (__pack__.count || 0) + ' 个文件 版本=' + __pack__.version); return __pack__.files || null; } catch (__pack_err__) { console.warn('[Hornet] 单包不可用,回退逐文件加载: ' + (__pack_err__ && __pack_err__.message)); return null; } })(); // 6.1 加载资源模块 (resources.json;优先从单包里取) let __resource_data__ = null; if (__pack_files__ && typeof __pack_files__['resources.json'] === 'string') { __resource_data__ = JSON.parse(__pack_files__['resources.json']); } else { const __resource_resp__ = await fetch(__base_url__ + 'unpacked/resources.json'); if (!__resource_resp__.ok) throw new Error('resources.json load failed: HTTP ' + __resource_resp__.status); __resource_data__ = await __resource_resp__.json(); } const __resource_map__ = __resource_data__.resourceMap; for (const __id__ in __resource_map__) { if (!Object.prototype.hasOwnProperty.call(__resource_map__, __id__)) continue; const __path__ = __resource_map__[__id__]; const __id_num__ = Number(__id__); window.__modules__[__id_num__] = (function(__p__) { return (t, n, i) => { "use strict"; t.exports = i.p + __p__; }; })(__path__); } // 6.2 加载代码模块:单包命中直接 eval(无网络请求);缺的/没包时回退逐文件 fetch const __code_ids__ = [${codeIdsArrayLiteral}]; const __mod_name__ = (__id__) => 'module_' + (typeof __id__ === 'number' ? String(__id__).padStart(5, '0') : __id__) + '.js'; const __missing__ = []; for (const __id__ of __code_ids__) { const __name__ = __mod_name__(__id__); const __src__ = __pack_files__ ? __pack_files__[__name__] : null; if (typeof __src__ === 'string') (0, eval)(__src__); else __missing__.push(__id__); } if (__missing__.length) { if (__pack_files__) console.warn('[Hornet] 单包缺少 ' + __missing__.length + ' 个模块,回退逐文件加载'); await Promise.all(__missing__.map(async (__id__) => { const __url__ = __base_url__ + 'unpacked/' + __mod_name__(__id__); const __resp__ = await fetch(__url__); if (!__resp__.ok) throw new Error('Module ' + __id__ + ' load failed: HTTP ' + __resp__.status + ' @ ' + __url__); (0, eval)(await __resp__.text()); })); } var __webpack_modules__ = window.__modules__; `; // 6.3 hornet 工具链自动加载(注入在 runtime 入口模块加载之后、return 之前) const hornetCode = WITH_HORNET ? ` // ===== 暴露 webpack require(hornet 工具链 hook_gamemode/hornet_lib 依赖)===== window.__wp_require__ = __webpack_require__; // ===== 出错时不自动刷新页面(**默认开启**;localStorage.hornetBlockReload='0' 关闭)===== // 游戏在致命错误/断线后会调用 location.reload(),err toast 一闪就被刷掉、看不到原因。 // 拦截自动刷新并保留页面:① 接管 location.reload;② 不可写时用 beforeunload 守卫兜底。 // 需要刷新时按 F5:先放行一次守卫,再用原始 reload 真正刷新。 // ⚠️ 拦截期间游戏无法靠刷页重连,只能反复内部重连(临时排查用;稳定后设 hornetBlockReload='0')。 var __hornetRealReload__ = window.location.reload.bind(window.location); var __reloadHijacked__ = false; if (localStorage.getItem('hornetBlockReload') !== '0') { try { window.location.reload = function () { console.warn('[Hornet] 已拦截自动刷新(保留页面,便于查看 err toast)。调用栈:\\n' + new Error().stack); }; __reloadHijacked__ = (window.location.reload !== __hornetRealReload__); } catch (__reloadErr__) { __reloadHijacked__ = false; } window.addEventListener('beforeunload', function (e) { if (window.__hornetAllowUnload__) { window.__hornetAllowUnload__ = false; return; } if (__reloadHijacked__) return; e.preventDefault(); e.returnValue = ''; }); } console.log('[Hornet] 自动刷新拦截: ' + (__reloadHijacked__ ? '已启用(会阻断断线重连)' : (localStorage.getItem('hornetBlockReload') === '0' ? '已关闭(hornetBlockReload=0)' : 'beforeunload 守卫'))); // ===== F5 刷新页面(游戏会吞掉 F5,这里在捕获阶段兜底)===== window.addEventListener('keydown', function (e) { if (e.key === 'F5') { e.preventDefault(); e.stopImmediatePropagation(); window.__hornetAllowUnload__ = true; __hornetRealReload__(); } }, true); // ===== 加载 hornet_bundle(默认加载;纯净页面用 #nohornet)===== // 开关必须用 hash,不能用查询串:游戏在 module_13431 里把 location.search 的所有查询参数 // 注册成 config 参数,于是 ?hornet=1 会让 Hash Request 多出资源名 "hornet",真实服务器直接关闭连接(1005)。 if (/[?&]hornet=1(&|$)/.test(location.search)) { console.warn('[Hornet] 请勿使用 ?hornet=1:查询参数会被游戏当成 config 参数,导致上游握手失败(1005)'); } if (!/#nohornet/.test(location.hash) && !window.__hornetTools__) { try { const __hornet_url__ = __base_url__ + 'unpacked/hornet_bundle.js'; const __hornet_resp__ = await fetch(__hornet_url__); if (__hornet_resp__.ok) { const __hornet_code__ = await __hornet_resp__.text(); (0, eval)(__hornet_code__); console.log('[Hornet] bundle 已加载'); } else { console.warn('[Hornet] bundle 加载失败: HTTP ' + __hornet_resp__.status); } } catch (__hornet_err__) { console.error('[Hornet] bundle 加载异常:', __hornet_err__); } } // ===== Service Worker:已从模板移除(排查断联用,避免单包缓存接管干扰)===== // 需要恢复时见 git 历史;hornet_sw.js 本体与 serve.js 的 /hornet_sw.js 路由保留未删。 ` : ''; let runtimeWithHornet = runtimeCode; if (WITH_HORNET) { const retIdx = runtimeWithHornet.lastIndexOf('return __webpack_exports__'); if (retIdx >= 0) { runtimeWithHornet = runtimeWithHornet.slice(0, retIdx) + hornetCode + runtimeWithHornet.slice(retIdx); } else { console.warn(' [WARN] 未找到 return __webpack_exports__,跳过 hornet 注入'); } } const newBundle = UMD_PREFIX + '\n' + loaderCode + '\n// ===== 原 webpack runtime (一字未改) =====\n' + runtimeWithHornet + ';\n' + UMD_SUFFIX(BUNDLE_PATH); fs.writeFileSync(MAIN_PATH, newBundle, 'utf8'); console.log(` 主文件大小: ${(newBundle.length / 1024).toFixed(1)} KB`); console.log('[7/7] 验证语法...'); try { acorn.parse(newBundle, { ecmaVersion: 'latest', sourceType: 'script', allowReturnOutsideFunction: true, }); console.log(' ✓ 主文件语法正确'); } catch (e) { console.error(` ✗ 语法错误: ${e.message}`); process.exit(1); } console.log('\n=== 完成 ==='); console.log(` 总模块数: ${props.length}`); console.log(` 资源模块: ${resourceCount} (存入 resources.json)`); console.log(` 代码模块: ${codeCount} (独立文件)`); console.log(` 美化失败: ${failCount}`); // ===== 自动同步 index.html 里的主文件引用 ===== // 主文件名带 bundle hash(main..js),而 index.html 里写的是固定名; // 重新拆分后必须一起改,否则页面请求旧名 404 → 新主文件(含 HORNET 注入/自动刷新拦截)全部失效。 try { const HTML_PATH = path.resolve(__dirname, '..', '3DTank', 'web', 'index.html'); if (fs.existsSync(HTML_PATH)) { const MainName = path.basename(MAIN_PATH); const Html = fs.readFileSync(HTML_PATH, 'utf8'); const NewHtml = Html.replace(/\/play\/static\/js\/main\.[0-9a-f]{8}\.js/g, '/play/static/js/' + MainName); if (NewHtml !== Html) { fs.writeFileSync(HTML_PATH, NewHtml, 'utf8'); console.log(` index.html: 已更新主文件引用 → ${MainName}`); } else { console.log(` index.html: 引用已是最新 (${MainName})`); } } } catch (e) { console.log(' index.html: 同步失败(不影响拆分结果): ' + e.message); } console.log(` 主文件: ${MAIN_PATH}`); console.log(` 资源 JSON: ${RESOURCES_JSON_PATH}`); console.log(` 代码模块目录: ${UNPACKED_DIR}\\`); })().catch((err) => { console.error('Fatal:', err); process.exit(1); });