From fcc4901a1fc3aa45dbae051be4b64121969a2711 Mon Sep 17 00:00:00 2001 From: TermiNexus Date: Tue, 20 Oct 2026 16:48:41 +0800 Subject: [PATCH] =?UTF-8?q?feat(garage):=20=E6=88=98=E5=B1=80=E7=A9=BA?= =?UTF-8?q?=E9=97=B2=E9=A2=84=E7=83=AD=E8=BD=A6=E5=BA=93=E8=B5=84=E6=BA=90?= =?UTF-8?q?=EF=BC=8C=E9=9D=99=E6=80=81=E8=B5=84=E6=BA=90=E5=90=AF=E7=94=A8?= =?UTF-8?q?=E9=95=BF=E7=BC=93=E5=AD=98=E4=B8=8E=E5=BC=82=E6=AD=A5=E8=AF=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- 3DTank/tools/prefetch_assets.js | 125 +++++++++++++++++ 3DTank/tools/serve.js | 52 ++++++- .../play/static/js/unpacked/hornet_bundle.js | 130 ++++++++++++++++++ 3dtank_bundle.js | 130 ++++++++++++++++++ build_bundle.py | 1 + tools/hornet_prefetch.js | 127 +++++++++++++++++ 6 files changed, 559 insertions(+), 6 deletions(-) create mode 100644 3DTank/tools/prefetch_assets.js create mode 100644 tools/hornet_prefetch.js diff --git a/3DTank/tools/prefetch_assets.js b/3DTank/tools/prefetch_assets.js new file mode 100644 index 0000000..d2253be --- /dev/null +++ b/3DTank/tools/prefetch_assets.js @@ -0,0 +1,125 @@ +// prefetch_assets.js - 预拉取车库/大厅所需的静态资源到本地镜像目录 +// 用法: node prefetch_assets.js [--concurrency 8] [--only images] +// +// 背景: +// 本地镜像只镜像了 JS,`/play/static/images/*` 全靠 serve.js 现场回源 3dtank.com 并落盘 +// (见 serve.js 的「自愈」分支)。从战局返回车库时车库界面要一次性加载大量贴图/图标, +// 首次全部走回源 → 明显卡顿。 +// 本脚本把 webpack 资源表(unpacked/resources.json)里的所有静态资源**预先下载**到 +// `3DTank/web/play/static/`,之后 serve.js 直接本地直出,返回车库秒开。 +// +// 产物位置与 serve.js 的 WEB_DIR 一致: +// URL /play/static/images/xxx.webp → 3DTank/web/play/static/images/xxx.webp + +const fs = require('fs'); +const path = require('path'); + +const PROJECT = path.resolve(__dirname, '..'); +const ROOT = path.resolve(PROJECT, '..'); +const WEB_DIR = path.join(PROJECT, 'web'); +const RES_JSON = path.join(ROOT, '3dtank.com', 'play', 'static', 'js', 'unpacked', 'resources.json'); +const BASE_URL = 'https://3dtank.com/play/'; + +const Argv = process.argv.slice(2); +const Arg = (Name, Def) => { + const I = Argv.indexOf('--' + Name); + return I >= 0 && Argv[I + 1] ? Argv[I + 1] : Def; +}; +const CONCURRENCY = Math.max(1, Number(Arg('concurrency', '8')) || 8); + +const HEADERS = { + 'user-agent': + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/152.0.0.0 Safari/537.36 Edg/152.0.0.0', + 'accept-language': 'zh-CN,zh;q=0.9,en;q=0.8', + origin: 'https://3dtank.com', + referer: 'https://3dtank.com/play/', +}; + +function ReadResourceMap() { + if (!fs.existsSync(RES_JSON)) { + console.error('[prefetch] 找不到资源表: ' + RES_JSON); + process.exit(1); + } + const Json = JSON.parse(fs.readFileSync(RES_JSON, 'utf8')); + const Map = Json.resourceMap || {}; + // 去重:多个模块可能引用同一个文件 + return Array.from(new Set(Object.values(Map).filter((V) => typeof V === 'string' && V))); +} + +function TargetFile(Rel) { + // 安全:禁止跳出 WEB_DIR + const Clean = Rel.replace(/^\/+/, ''); + const Out = path.resolve(WEB_DIR, 'play', Clean); + if (!Out.startsWith(path.resolve(WEB_DIR))) return null; + return Out; +} + +async function DownloadOne(Rel, Stats) { + const Out = TargetFile(Rel); + if (!Out) { + Stats.skip++; + return; + } + if (fs.existsSync(Out)) { + Stats.cached++; + return; + } + const Url = BASE_URL + Rel.replace(/^\/+/, ''); + for (let Try = 0; Try < 3; Try++) { + try { + const Res = await fetch(Url, { headers: HEADERS, redirect: 'follow' }); + if (!Res.ok) { + if (Res.status === 404) { + Stats.missing++; + console.warn('[404] ' + Rel); + return; + } + throw new Error('HTTP ' + Res.status); + } + const Buf = Buffer.from(await Res.arrayBuffer()); + fs.mkdirSync(path.dirname(Out), { recursive: true }); + fs.writeFileSync(Out, Buf); + Stats.downloaded++; + Stats.bytes += Buf.length; + if (Stats.downloaded % 50 === 0) { + console.log( + '[prefetch] 已下载 ' + Stats.downloaded + ' / ' + Stats.total + '(' + (Stats.bytes / 1048576).toFixed(1) + ' MB)', + ); + } + return; + } catch (Err) { + if (Try === 2) { + Stats.failed++; + console.warn('[fail] ' + Rel + ' : ' + Err.message); + } else { + await new Promise((R) => setTimeout(R, 400 * (Try + 1))); + } + } + } +} + +async function Main() { + const List = ReadResourceMap(); + const Stats = { total: List.length, downloaded: 0, cached: 0, missing: 0, failed: 0, skip: 0, bytes: 0 }; + console.log('[prefetch] 资源表条目: ' + List.length + ',并发: ' + CONCURRENCY); + console.log('[prefetch] 输出目录: ' + path.join(WEB_DIR, 'play')); + + let Index = 0; + async function Worker() { + while (Index < List.length) { + const Rel = List[Index++]; + await DownloadOne(Rel, Stats); + } + } + await Promise.all(Array.from({ length: CONCURRENCY }, Worker)); + + console.log( + '[prefetch] 完成:新下载 ' + Stats.downloaded + + ',已存在 ' + Stats.cached + + ',404 ' + Stats.missing + + ',失败 ' + Stats.failed + + ',合计 ' + (Stats.bytes / 1048576).toFixed(1) + ' MB', + ); +} + +Main(); diff --git a/3DTank/tools/serve.js b/3DTank/tools/serve.js index fc29736..d02874a 100644 --- a/3DTank/tools/serve.js +++ b/3DTank/tools/serve.js @@ -138,6 +138,12 @@ function MatchProxy(UrlPath) { const ProxyStats = { total: 0, cached: 0, failed: 0 }; +// 缓存策略:镜像资源(图片/模型/音频)路径稳定、内容不变,允许浏览器长期缓存。 +// 之前一律 no-cache,车库一次性加载上千个资源时每个都要回服务器验证 → 返回车库很慢。 +const LONG_CACHE = 'public, max-age=604800, immutable'; +const IsCacheableAsset = (P) => + /\.(webp|png|jpg|jpeg|gif|svg|ico|woff2?|ttf|mp3|ogg|wav|webm|mp4|3ds|a3d|bin|json)$/i.test(P); + function CachePath(Url) { const U = new URL(Url); const Rel = (U.hostname + U.pathname).replace(/[^\w.\-/]/g, '_'); @@ -151,14 +157,16 @@ async function HandleProxy(Rule, Req, Res) { } const CacheFile = Rule.SaveFile || CachePath(Rule.Url); if (VERBOSE) console.log('[代理] ' + Rule.Url); + const CacheHeader = Rule.Url.includes('config') ? 'no-cache' : LONG_CACHE; try { if (fs.existsSync(CacheFile)) { ProxyStats.cached++; - const Buf = fs.readFileSync(CacheFile); + // 异步读:车库会并发拉上千个资源,同步 readFile 会阻塞事件循环、把其他请求全排队 + const Buf = await fs.promises.readFile(CacheFile); Res.writeHead(200, { 'Content-Type': MIME[path.extname(new URL(Rule.Url).pathname).toLowerCase()] || 'application/octet-stream', 'Content-Length': Buf.length, - 'Cache-Control': 'no-cache', + 'Cache-Control': CacheHeader, 'X-Hornet-Proxy': 'cache', }); Res.end(Buf); @@ -172,8 +180,8 @@ async function HandleProxy(Rule, Req, Res) { Res.writeHead(Upstream.status, { 'Content-Type': 'text/plain; charset=utf-8' }).end('Upstream ' + Upstream.status); return; } - fs.mkdirSync(path.dirname(CacheFile), { recursive: true }); - fs.writeFileSync(CacheFile, Buf); + await fs.promises.mkdir(path.dirname(CacheFile), { recursive: true }); + await fs.promises.writeFile(CacheFile, Buf); Res.writeHead(200, { // 优先按扩展名给类型(config.xml 必须是 XML,否则 responseXML 为 null) 'Content-Type': @@ -181,7 +189,7 @@ async function HandleProxy(Rule, Req, Res) { Upstream.headers.get('content-type') || 'application/octet-stream', 'Content-Length': Buf.length, - 'Cache-Control': 'no-cache', + 'Cache-Control': CacheHeader, 'X-Hornet-Proxy': 'upstream', }); Res.end(Buf); @@ -299,6 +307,36 @@ const Server = http.createServer((Req, Res) => { }).end(Hook); return; } + // /__prefetch_list__:返回「最近访问过的 res.3dtank.com 资源」清单(本地缓存里按 mtime 倒序) + // 客户端在战局空闲时按这个清单预热,从战局返回车库时资源已在浏览器缓存里。 + if (UrlPath.split('?')[0] === '/__prefetch_list__') { + const Query = new URLSearchParams(UrlPath.split('?')[1] || ''); + const Limit = Math.max(1, Math.min(3000, Number(Query.get('n')) || 500)); + const Base = path.join(CACHE_DIR, 'res.3dtank.com'); + const Files = []; + const Walk = (D) => { + let Ents = []; + try { Ents = fs.readdirSync(D, { withFileTypes: true }); } catch (e) { return; } + for (const E of Ents) { + const P = path.join(D, E.name); + if (E.isDirectory()) { Walk(P); continue; } + // 带 query 的缓存文件名带 _hex 后缀,无法还原成原路径 → 跳过 + if (/_[0-9a-f]{12}$/i.test(E.name)) continue; + try { Files.push({ P, M: fs.statSync(P).mtimeMs }); } catch (e) {} + } + }; + Walk(Base); + Files.sort((A, B) => B.M - A.M); + const List = Files.slice(0, Limit).map(({ P }) => '/resources/' + path.relative(Base, P).split(path.sep).join('/')); + const Buf = Buffer.from(JSON.stringify({ count: List.length, list: List })); + Res.writeHead(200, { + 'Content-Type': 'application/json; charset=utf-8', + 'Content-Length': Buf.length, + 'Cache-Control': 'no-cache', + 'X-Hornet-Prefetch': 'list', + }).end(Buf); + return; + } // /probe_railgun.js:武器链路现场探针(只读采样 + WS 上行记录,数据 POST /__probe__ 落盘) // 由加载器自动引入(localStorage.hornetProbe='0' 可关闭),省去每次手粘控制台。 if (UrlPath.split('?')[0] === '/probe_railgun.js') { @@ -418,7 +456,9 @@ const Server = http.createServer((Req, Res) => { Res.writeHead(200, { 'Content-Type': Type, 'Content-Length': Stat.size, - 'Cache-Control': 'no-cache', + // 图片/模型/字体等静态资源允许长期缓存(文件名带 hash 或路径稳定), + // 车库一次加载上千个资源时不再逐个回服务器验证 + 'Cache-Control': IsCacheableAsset(File) ? LONG_CACHE : 'no-cache', }); if (VERBOSE) console.log('[200] ' + Stat.size + '\t' + UrlPath); fs.createReadStream(File).pipe(Res); diff --git a/3dtank.com/play/static/js/unpacked/hornet_bundle.js b/3dtank.com/play/static/js/unpacked/hornet_bundle.js index 96551d4..b9e7ffd 100644 --- a/3dtank.com/play/static/js/unpacked/hornet_bundle.js +++ b/3dtank.com/play/static/js/unpacked/hornet_bundle.js @@ -6730,3 +6730,133 @@ console.log('[3DTank] 控制面板已就绪,按 Alt+T 打开面板,HUD/面板/窗口可拖动,热键已加载'); })(); + +// ===== hornet_prefetch.js ===== +// ===== 车库资源预热模块 ===== +// 目的:从战局返回车库时,车库界面要一次性加载大量贴图/图标/坦克模型, +// 首次全部现场拉取会明显卡顿。这里在**战局空闲时**后台把这些资源拉进浏览器缓存。 +// +// 预热两批: +// ① /play/ —— 车库/大厅 UI 图片(由 resources.json 资源表枚举,本地直出) +// ② /resources/<...> —— res.3dtank.com 上最近访问过的模型/贴图 +// 清单由本地 serve.js 的 /__prefetch_list__ 提供(按缓存文件 mtime 倒序) +// +// 策略: +// - 只在捕获到 __gameMode__(已在战局)后 8 秒才开始,避开进入战局的加载高峰 +// - 串行 + 间隔限速,避免抢占战局带宽 +// - 10 分钟内只跑一次(localStorage 记录),可用 __hornetPrefetch__.stop() 手动停止 +// - localStorage.hornetPrefetch = '0' 可永久关闭 + +(function () { + var KEY_AT = 'hornetPrefetchAt'; + var KEY_OFF = 'hornetPrefetch'; + var GAP_STATIC = 40; // UI 图片间隔(ms) + var GAP_RES = 30; // 模型/贴图间隔(ms) + + var State = { running: false, stopped: false, timers: [] }; + + function LastRunAt() { + try { return Number(localStorage.getItem(KEY_AT) || 0) || 0; } catch (e) { return 0; } + } + + function MarkRun() { + try { localStorage.setItem(KEY_AT, String(Date.now())); } catch (e) {} + } + + // 串行限速加载;命中缓存的资源不会产生网络请求 + function LoadList(List, Gap, Done) { + var Index = 0, Ok = 0; + function Next() { + if (State.stopped) return; + if (Index >= List.length) { + if (Ok > 0) console.log('[预热] 完成 ' + Ok + '/' + List.length + ' 项'); + if (Done) Done(); + return; + } + var Url = List[Index++]; + fetch(Url, { cache: 'force-cache' }) + .then(function () { Ok++; }) + .catch(function () {}) + .then(function () { setTimeout(Next, Gap); }); + } + Next(); + } + + // ① 车库/大厅 UI 图片:从 webpack 资源表枚举 + function PreloadStatic() { + fetch('/play/static/js/unpacked/resources.json', { cache: 'force-cache' }) + .then(function (R) { return R.json(); }) + .then(function (Json) { + var Map = (Json && Json.resourceMap) || {}; + var Seen = {}; + var List = []; + for (var K in Map) { + if (!Object.prototype.hasOwnProperty.call(Map, K)) continue; + var V = Map[K]; + if (typeof V !== 'string' || Seen[V]) continue; + Seen[V] = 1; + List.push('/play/' + V); + } + console.log('[预热] 车库 UI 资源 ' + List.length + ' 项'); + LoadList(List, GAP_STATIC, PreloadRes); + }) + .catch(function () { PreloadRes(); }); + } + + // ② res.3dtank.com 资源:按本地缓存清单(最近访问优先) + function PreloadRes() { + fetch('/__prefetch_list__?n=600') + .then(function (R) { return R.json(); }) + .then(function (Json) { + var List = (Json && Json.list) || []; + if (!List.length) return; + console.log('[预热] 战局/车库资源 ' + List.length + ' 项'); + LoadList(List, GAP_RES, null); + }) + .catch(function () {}); + } + + function Run() { + if (State.stopped) return; + if (Date.now() - LastRunAt() < 10 * 60 * 1000) return; // 10 分钟内只跑一次 + MarkRun(); + PreloadStatic(); + } + + function _start() { + if (State.running) return; + State.running = true; + State.stopped = false; + + try { + if (localStorage.getItem(KEY_OFF) === '0') { + console.log('[预热] 已被 localStorage.hornetPrefetch=0 关闭'); + State.running = false; + return; + } + } catch (e) {} + + console.log('===== 车库资源预热启动 ====='); + + // 等进入战局:__gameMode__ 由 hook_gamemode.js 捕获 + var Wait = setInterval(function () { + if (State.stopped) { clearInterval(Wait); return; } + if (!window.__gameMode__) return; + clearInterval(Wait); + setTimeout(Run, 8000); + }, 2000); + State.timers.push(Wait); + + window.__hornetPrefetch__ = { + running: true, + stop: function () { + State.stopped = true; + for (var I = 0; I < State.timers.length; I++) clearInterval(State.timers[I]); + console.log('[预热] 已停止'); + } + }; + } + + _start(); +})(); + diff --git a/3dtank_bundle.js b/3dtank_bundle.js index 96551d4..b9e7ffd 100644 --- a/3dtank_bundle.js +++ b/3dtank_bundle.js @@ -6730,3 +6730,133 @@ console.log('[3DTank] 控制面板已就绪,按 Alt+T 打开面板,HUD/面板/窗口可拖动,热键已加载'); })(); + +// ===== hornet_prefetch.js ===== +// ===== 车库资源预热模块 ===== +// 目的:从战局返回车库时,车库界面要一次性加载大量贴图/图标/坦克模型, +// 首次全部现场拉取会明显卡顿。这里在**战局空闲时**后台把这些资源拉进浏览器缓存。 +// +// 预热两批: +// ① /play/ —— 车库/大厅 UI 图片(由 resources.json 资源表枚举,本地直出) +// ② /resources/<...> —— res.3dtank.com 上最近访问过的模型/贴图 +// 清单由本地 serve.js 的 /__prefetch_list__ 提供(按缓存文件 mtime 倒序) +// +// 策略: +// - 只在捕获到 __gameMode__(已在战局)后 8 秒才开始,避开进入战局的加载高峰 +// - 串行 + 间隔限速,避免抢占战局带宽 +// - 10 分钟内只跑一次(localStorage 记录),可用 __hornetPrefetch__.stop() 手动停止 +// - localStorage.hornetPrefetch = '0' 可永久关闭 + +(function () { + var KEY_AT = 'hornetPrefetchAt'; + var KEY_OFF = 'hornetPrefetch'; + var GAP_STATIC = 40; // UI 图片间隔(ms) + var GAP_RES = 30; // 模型/贴图间隔(ms) + + var State = { running: false, stopped: false, timers: [] }; + + function LastRunAt() { + try { return Number(localStorage.getItem(KEY_AT) || 0) || 0; } catch (e) { return 0; } + } + + function MarkRun() { + try { localStorage.setItem(KEY_AT, String(Date.now())); } catch (e) {} + } + + // 串行限速加载;命中缓存的资源不会产生网络请求 + function LoadList(List, Gap, Done) { + var Index = 0, Ok = 0; + function Next() { + if (State.stopped) return; + if (Index >= List.length) { + if (Ok > 0) console.log('[预热] 完成 ' + Ok + '/' + List.length + ' 项'); + if (Done) Done(); + return; + } + var Url = List[Index++]; + fetch(Url, { cache: 'force-cache' }) + .then(function () { Ok++; }) + .catch(function () {}) + .then(function () { setTimeout(Next, Gap); }); + } + Next(); + } + + // ① 车库/大厅 UI 图片:从 webpack 资源表枚举 + function PreloadStatic() { + fetch('/play/static/js/unpacked/resources.json', { cache: 'force-cache' }) + .then(function (R) { return R.json(); }) + .then(function (Json) { + var Map = (Json && Json.resourceMap) || {}; + var Seen = {}; + var List = []; + for (var K in Map) { + if (!Object.prototype.hasOwnProperty.call(Map, K)) continue; + var V = Map[K]; + if (typeof V !== 'string' || Seen[V]) continue; + Seen[V] = 1; + List.push('/play/' + V); + } + console.log('[预热] 车库 UI 资源 ' + List.length + ' 项'); + LoadList(List, GAP_STATIC, PreloadRes); + }) + .catch(function () { PreloadRes(); }); + } + + // ② res.3dtank.com 资源:按本地缓存清单(最近访问优先) + function PreloadRes() { + fetch('/__prefetch_list__?n=600') + .then(function (R) { return R.json(); }) + .then(function (Json) { + var List = (Json && Json.list) || []; + if (!List.length) return; + console.log('[预热] 战局/车库资源 ' + List.length + ' 项'); + LoadList(List, GAP_RES, null); + }) + .catch(function () {}); + } + + function Run() { + if (State.stopped) return; + if (Date.now() - LastRunAt() < 10 * 60 * 1000) return; // 10 分钟内只跑一次 + MarkRun(); + PreloadStatic(); + } + + function _start() { + if (State.running) return; + State.running = true; + State.stopped = false; + + try { + if (localStorage.getItem(KEY_OFF) === '0') { + console.log('[预热] 已被 localStorage.hornetPrefetch=0 关闭'); + State.running = false; + return; + } + } catch (e) {} + + console.log('===== 车库资源预热启动 ====='); + + // 等进入战局:__gameMode__ 由 hook_gamemode.js 捕获 + var Wait = setInterval(function () { + if (State.stopped) { clearInterval(Wait); return; } + if (!window.__gameMode__) return; + clearInterval(Wait); + setTimeout(Run, 8000); + }, 2000); + State.timers.push(Wait); + + window.__hornetPrefetch__ = { + running: true, + stop: function () { + State.stopped = true; + for (var I = 0; I < State.timers.length; I++) clearInterval(State.timers[I]); + console.log('[预热] 已停止'); + } + }; + } + + _start(); +})(); + diff --git a/build_bundle.py b/build_bundle.py index 791c57f..287acf1 100644 --- a/build_bundle.py +++ b/build_bundle.py @@ -25,6 +25,7 @@ FILES = [ "hornet_warsandbox.js", # 8. 战争沙盘模块 -> __hornetTools__.WarSandbox "hornet_mouse.js", # 9. 鼠标输入稳定器 -> __hornetTools__.Mouse "hornet_panel.js", # 10. 控制面板(Alt+T + HUD) + "hornet_prefetch.js", # 11. 车库资源预热(战局空闲时后台加载车库所需资源) ] HEADER = """// ============================================================ diff --git a/tools/hornet_prefetch.js b/tools/hornet_prefetch.js new file mode 100644 index 0000000..5455010 --- /dev/null +++ b/tools/hornet_prefetch.js @@ -0,0 +1,127 @@ +// ===== 车库资源预热模块 ===== +// 目的:从战局返回车库时,车库界面要一次性加载大量贴图/图标/坦克模型, +// 首次全部现场拉取会明显卡顿。这里在**战局空闲时**后台把这些资源拉进浏览器缓存。 +// +// 预热两批: +// ① /play/ —— 车库/大厅 UI 图片(由 resources.json 资源表枚举,本地直出) +// ② /resources/<...> —— res.3dtank.com 上最近访问过的模型/贴图 +// 清单由本地 serve.js 的 /__prefetch_list__ 提供(按缓存文件 mtime 倒序) +// +// 策略: +// - 只在捕获到 __gameMode__(已在战局)后 8 秒才开始,避开进入战局的加载高峰 +// - 串行 + 间隔限速,避免抢占战局带宽 +// - 10 分钟内只跑一次(localStorage 记录),可用 __hornetPrefetch__.stop() 手动停止 +// - localStorage.hornetPrefetch = '0' 可永久关闭 + +(function () { + var KEY_AT = 'hornetPrefetchAt'; + var KEY_OFF = 'hornetPrefetch'; + var GAP_STATIC = 40; // UI 图片间隔(ms) + var GAP_RES = 30; // 模型/贴图间隔(ms) + + var State = { running: false, stopped: false, timers: [] }; + + function LastRunAt() { + try { return Number(localStorage.getItem(KEY_AT) || 0) || 0; } catch (e) { return 0; } + } + + function MarkRun() { + try { localStorage.setItem(KEY_AT, String(Date.now())); } catch (e) {} + } + + // 串行限速加载;命中缓存的资源不会产生网络请求 + function LoadList(List, Gap, Done) { + var Index = 0, Ok = 0; + function Next() { + if (State.stopped) return; + if (Index >= List.length) { + if (Ok > 0) console.log('[预热] 完成 ' + Ok + '/' + List.length + ' 项'); + if (Done) Done(); + return; + } + var Url = List[Index++]; + fetch(Url, { cache: 'force-cache' }) + .then(function () { Ok++; }) + .catch(function () {}) + .then(function () { setTimeout(Next, Gap); }); + } + Next(); + } + + // ① 车库/大厅 UI 图片:从 webpack 资源表枚举 + function PreloadStatic() { + fetch('/play/static/js/unpacked/resources.json', { cache: 'force-cache' }) + .then(function (R) { return R.json(); }) + .then(function (Json) { + var Map = (Json && Json.resourceMap) || {}; + var Seen = {}; + var List = []; + for (var K in Map) { + if (!Object.prototype.hasOwnProperty.call(Map, K)) continue; + var V = Map[K]; + if (typeof V !== 'string' || Seen[V]) continue; + Seen[V] = 1; + List.push('/play/' + V); + } + console.log('[预热] 车库 UI 资源 ' + List.length + ' 项'); + LoadList(List, GAP_STATIC, PreloadRes); + }) + .catch(function () { PreloadRes(); }); + } + + // ② res.3dtank.com 资源:按本地缓存清单(最近访问优先) + function PreloadRes() { + fetch('/__prefetch_list__?n=600') + .then(function (R) { return R.json(); }) + .then(function (Json) { + var List = (Json && Json.list) || []; + if (!List.length) return; + console.log('[预热] 战局/车库资源 ' + List.length + ' 项'); + LoadList(List, GAP_RES, null); + }) + .catch(function () {}); + } + + function Run() { + if (State.stopped) return; + if (Date.now() - LastRunAt() < 10 * 60 * 1000) return; // 10 分钟内只跑一次 + MarkRun(); + PreloadStatic(); + } + + function _start() { + if (State.running) return; + State.running = true; + State.stopped = false; + + try { + if (localStorage.getItem(KEY_OFF) === '0') { + console.log('[预热] 已被 localStorage.hornetPrefetch=0 关闭'); + State.running = false; + return; + } + } catch (e) {} + + console.log('===== 车库资源预热启动 ====='); + + // 等进入战局:__gameMode__ 由 hook_gamemode.js 捕获 + var Wait = setInterval(function () { + if (State.stopped) { clearInterval(Wait); return; } + if (!window.__gameMode__) return; + clearInterval(Wait); + setTimeout(Run, 8000); + }, 2000); + State.timers.push(Wait); + + window.__hornetPrefetch__ = { + running: true, + stop: function () { + State.stopped = true; + for (var I = 0; I < State.timers.length; I++) clearInterval(State.timers[I]); + console.log('[预热] 已停止'); + } + }; + } + + _start(); +})();