feat(garage): 战局空闲预热车库资源,静态资源启用长缓存与异步读

This commit is contained in:
TermiNexus committed 2026-10-20 16:48:41 +08:00
1 parent ce1ffbeb60
commit fcc4901a1f
6 files changed
+559 -6

No files matched your search

+125
View File
@@ -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
View File
@@ -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();
})();
+130
View File
@@ -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();
})();
+1
View File
@@ -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 = """// ============================================================
+127
View File
@@ -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();
})();