feat(garage): 战局空闲预热车库资源,静态资源启用长缓存与异步读
This commit is contained in:
1 parent
ce1ffbeb60
commit
fcc4901a1f
6 files changed
+559
-6
No files matched your search
@@ -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();
|
||||
+46
-6
@@ -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);
|
||||
|
||||
@@ -6730,3 +6730,133 @@
|
||||
console.log('[3DTank] 控制面板已就绪,按 Alt+T 打开面板,HUD/面板/窗口可拖动,热键已加载');
|
||||
})();
|
||||
|
||||
|
||||
// ===== hornet_prefetch.js =====
|
||||
// ===== 车库资源预热模块 =====
|
||||
// 目的:从战局返回车库时,车库界面要一次性加载大量贴图/图标/坦克模型,
|
||||
// 首次全部现场拉取会明显卡顿。这里在**战局空闲时**后台把这些资源拉进浏览器缓存。
|
||||
//
|
||||
// 预热两批:
|
||||
// ① /play/<static/...> —— 车库/大厅 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();
|
||||
})();
|
||||
|
||||
@@ -6730,3 +6730,133 @@
|
||||
console.log('[3DTank] 控制面板已就绪,按 Alt+T 打开面板,HUD/面板/窗口可拖动,热键已加载');
|
||||
})();
|
||||
|
||||
|
||||
// ===== hornet_prefetch.js =====
|
||||
// ===== 车库资源预热模块 =====
|
||||
// 目的:从战局返回车库时,车库界面要一次性加载大量贴图/图标/坦克模型,
|
||||
// 首次全部现场拉取会明显卡顿。这里在**战局空闲时**后台把这些资源拉进浏览器缓存。
|
||||
//
|
||||
// 预热两批:
|
||||
// ① /play/<static/...> —— 车库/大厅 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();
|
||||
})();
|
||||
|
||||
@@ -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 = """// ============================================================
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
// ===== 车库资源预热模块 =====
|
||||
// 目的:从战局返回车库时,车库界面要一次性加载大量贴图/图标/坦克模型,
|
||||
// 首次全部现场拉取会明显卡顿。这里在**战局空闲时**后台把这些资源拉进浏览器缓存。
|
||||
//
|
||||
// 预热两批:
|
||||
// ① /play/<static/...> —— 车库/大厅 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();
|
||||
})();
|
||||
Reference in new issue
Block a user