6686 lines
267 KiB
JavaScript
6686 lines
267 KiB
JavaScript
// ============================================================
|
||
// 3DTank 工具链 Bundle (自动生成,请勿手动编辑)
|
||
// 构建: python build_bundle.py
|
||
// 用法: 全选粘贴到 3DTank 游戏页面控制台
|
||
// 操作: Alt+T 打开控制面板 | 左上角 HUD 显示状态
|
||
// ============================================================
|
||
|
||
// ===== hook_gamemode.js =====
|
||
// ===== GameMode Hook 脚本 v2(快速获取版)=====
|
||
// 改进:
|
||
// 1. 每次 yht 调用都更新引用(解决新战斗中旧引用失效问题)
|
||
// 2. ght.prototype 方法每次调用都更新
|
||
// 3. 定时器主动验证引用有效性,失效则重置
|
||
// 4. trySetGameMode 验证坦克列表,避免捕获未初始化实例
|
||
//
|
||
// 原理(新版 main.7cfa4058):
|
||
// module_87072.$_$.jc = dct 函数(接收 scene,返回 ght 实例) ← 旧版是 $_$.ac,新版键位漂移
|
||
// module_87072.$_$.ic = wct 类(ght / GameMode 组件类) ← 旧版是 $_$.zb
|
||
// module_87072.$_$.kc = gct 函数(接收 scene,返回 GameMode 实体)← 旧版是 $_$.bc
|
||
// dct(scene).m10b_1 = 坦克列表管理器
|
||
// 坦克列表 = dct(scene).m10b_1.j11y()
|
||
|
||
(function() {
|
||
console.log('===== GameMode Hook v2 开始 =====');
|
||
|
||
// 1. 检查 webpack 内部对象
|
||
if (!window.__wp_require__) {
|
||
console.error('[错误] window.__wp_require__ 不存在');
|
||
return;
|
||
}
|
||
|
||
// 2. 获取 module_87072 的导出
|
||
var mod = window.__wp_require__(87072);
|
||
if (!mod || !mod.$_$) {
|
||
console.error('[错误] module_87072 未加载或无 $_$ 导出');
|
||
return;
|
||
}
|
||
|
||
var ghtClass = mod.$_$.ic; // 新版:wct(旧版 $_$.zb)
|
||
var yhtFunc = mod.$_$.jc; // 新版:dct(旧版 $_$.ac)
|
||
|
||
console.log('[1] ght 类:', ghtClass ? ghtClass.name : '未找到');
|
||
console.log('[1] yht 函数:', typeof yhtFunc === 'function' ? '已找到' : '未找到');
|
||
|
||
if (!ghtClass) {
|
||
console.error('[错误] ght 类未导出');
|
||
return;
|
||
}
|
||
|
||
// ===== 辅助:设置 gameMode(无条件设置,坦克列表单独处理)=====
|
||
function trySetGameMode(gm, source) {
|
||
if (!gm) return false;
|
||
var updated = window.__gameMode__ !== gm;
|
||
window.__gameMode__ = gm;
|
||
// 尝试获取坦克列表(失败也不影响 gameMode 设置)
|
||
var tankCount = -1;
|
||
try {
|
||
var list = gm.m10b_1.j11y();
|
||
tankCount = list.e1();
|
||
window.__tankList__ = list;
|
||
} catch(e) {
|
||
// xzq_1 或 z11b 暂时不可用,不影响 gameMode 设置
|
||
}
|
||
if (updated) {
|
||
console.log('%c[Hook] ght 已更新 (via ' + source + ') 坦克数:' + tankCount, 'color:#0f0;font-weight:bold');
|
||
}
|
||
return true;
|
||
}
|
||
|
||
// ===== 3. Hook yht 函数 — 每次调用都更新 =====
|
||
if (typeof yhtFunc === 'function' && !window.__yht_hooked__) {
|
||
window.__yht_hooked__ = true;
|
||
var origYht = yhtFunc;
|
||
mod.$_$.jc = function(t) {
|
||
var result = origYht.call(this, t);
|
||
if (result) trySetGameMode(result, 'yht');
|
||
return result;
|
||
};
|
||
console.log('[2] yht Hook 已安装(每次调用都更新)');
|
||
} else if (window.__yht_hooked__) {
|
||
console.log('[2] yht Hook 已存在');
|
||
}
|
||
|
||
// ===== 4. Hook ght.prototype 方法 — 每次调用都更新 =====
|
||
if (!window.__ght_proto_hooked__) {
|
||
window.__ght_proto_hooked__ = true;
|
||
var proto = ghtClass.prototype;
|
||
var methodCount = 0;
|
||
Object.getOwnPropertyNames(proto).forEach(function(name) {
|
||
if (name === 'constructor') return;
|
||
var orig = Object.getOwnPropertyDescriptor(proto, name);
|
||
if (!orig || typeof orig.value !== 'function') return;
|
||
var origFn = orig.value;
|
||
methodCount++;
|
||
proto[name] = function() {
|
||
trySetGameMode(this, 'ght.' + name);
|
||
return origFn.apply(this, arguments);
|
||
};
|
||
});
|
||
console.log('[3] ght.prototype 方法 Hook 已安装(' + methodCount + ' 个方法)');
|
||
} else {
|
||
console.log('[3] ght.prototype Hook 已存在');
|
||
}
|
||
|
||
// ===== 5. 定时器主动验证引用有效性 =====
|
||
if (!window.__ght_validator__) {
|
||
window.__ght_validator__ = true;
|
||
var validateCount = 0;
|
||
setInterval(function() {
|
||
validateCount++;
|
||
if (window.__gameMode__) {
|
||
// 验证当前引用是否有效
|
||
try {
|
||
var list = window.__gameMode__.m10b_1.j11y();
|
||
window.__tankList__ = list;
|
||
} catch(e) {
|
||
// 引用失效(新战斗/退出战斗),重置等待重新捕获
|
||
console.log('%c[验证] ght 引用失效,重置等待重新捕获', 'color:#f80;font-weight:bold');
|
||
window.__gameMode__ = null;
|
||
window.__tankList__ = null;
|
||
}
|
||
} else {
|
||
// 未捕获,前5次每秒提示
|
||
if (validateCount <= 5) {
|
||
console.log('[验证] 等待 ght 捕获... (' + validateCount + ')');
|
||
}
|
||
}
|
||
}, 500);
|
||
console.log('[4] 定时验证器已启动(每500ms)');
|
||
} else {
|
||
console.log('[4] 定时验证器已存在');
|
||
}
|
||
|
||
// ===== 6. 状态显示 =====
|
||
if (window.__gameMode__) {
|
||
console.log('[5] 已有 ght 实例:', window.__gameMode__);
|
||
if (window.__tankList__) {
|
||
try {
|
||
console.log(' 当前坦克数量:', window.__tankList__.e1());
|
||
} catch(e) {}
|
||
}
|
||
} else {
|
||
console.log('[5] 等待 ght 实例捕获...');
|
||
console.log(' 提示: hook 已安装,战斗中触发事件会自动捕获');
|
||
}
|
||
|
||
// ===== 7. 辅助函数 =====
|
||
window.__listTanks__ = function() {
|
||
if (!window.__gameMode__) {
|
||
console.log('ght 实例未捕获,请等待');
|
||
return;
|
||
}
|
||
var list = window.__gameMode__.m10b_1.j11y();
|
||
var count = list.e1();
|
||
console.log('=== 坦克列表 (' + count + " 个) ===");
|
||
for (var i = 0; i < count; i++) {
|
||
var tank = list.g1(i);
|
||
console.log('[' + i + ']', tank);
|
||
}
|
||
return list;
|
||
};
|
||
|
||
window.__probeTank__ = function(idx) {
|
||
if (!window.__gameMode__) {
|
||
console.log('ght 实例未捕获');
|
||
return;
|
||
}
|
||
var list = window.__gameMode__.m10b_1.j11y();
|
||
var tank = list.g1(idx || 0);
|
||
console.log('坦克[' + (idx || 0) + ']:', tank);
|
||
console.log('字段:', Object.keys(tank));
|
||
Object.keys(tank).forEach(function(k) {
|
||
var v = tank[k];
|
||
var type = typeof v;
|
||
if (type === 'number' || type === 'string' || type === 'boolean') {
|
||
console.log(' ' + k + ' = ' + v + ' (' + type + ')');
|
||
} else if (v === null) {
|
||
console.log(' ' + k + ' = null');
|
||
} else if (type === 'object') {
|
||
console.log(' ' + k + ' = [object] (' + (v.constructor ? v.constructor.name : typeof v) + ')');
|
||
} else if (type === 'function') {
|
||
console.log(' ' + k + ' = [function]');
|
||
}
|
||
});
|
||
return tank;
|
||
};
|
||
|
||
// 主动刷新/检查 ght 实例
|
||
window.__refreshGameMode__ = function() {
|
||
if (window.__gameMode__) {
|
||
try {
|
||
var list = window.__gameMode__.m10b_1.j11y();
|
||
console.log('%c[刷新] ght 引用有效,坦克数:' + list.e1(), 'color:#0f0;font-weight:bold');
|
||
return window.__gameMode__;
|
||
} catch(e) {
|
||
console.log('%c[刷新] 旧引用失效,已重置', 'color:#f80;font-weight:bold');
|
||
window.__gameMode__ = null;
|
||
window.__tankList__ = null;
|
||
}
|
||
}
|
||
console.log('[刷新] 等待 hook 捕获...(战斗中触发事件即可)');
|
||
return null;
|
||
};
|
||
|
||
console.log('\n===== Hook v2 安装完成 =====');
|
||
console.log('辅助函数:');
|
||
console.log(' __listTanks() - 列出所有坦克');
|
||
console.log(' __probeTank(idx) - 探查指定坦克的字段');
|
||
console.log(' __refreshGameMode__() - 刷新/检查 ght 实例');
|
||
console.log(' window.__gameMode__ - ght 实例(自动更新)');
|
||
console.log(' window.__tankList__ - 坦克列表(自动更新)');
|
||
})();
|
||
|
||
|
||
// ===== hornet_lib.js =====
|
||
// ===== 3DTank公共库 - 工具文件共享辅助函数 =====
|
||
// 在浏览器控制台运行(需先运行 hook_gamemode.js)
|
||
// 功能:提供坦克查询、队伍判断、相机信息、3D投影等公共函数
|
||
// 用法:各工具文件通过 var lib = window.__hornetLib__ 访问
|
||
|
||
(function() {
|
||
console.log('===== 3DTank公共库加载 =====');
|
||
|
||
if (!window.__gameMode__) {
|
||
console.warn('[公共库] window.__gameMode__ 暂未捕获,工具启动时将失败。hook_gamemode.js 已安装 Hook,战斗中会自动捕获');
|
||
}
|
||
|
||
// ===== 缓存队伍判断函数(ZCt/m2Fn) =====
|
||
var ZCt = null, m2Fn = null;
|
||
|
||
// 加载队伍判断函数(从 module_87072 导出)
|
||
// 新旧差异(main.7cfa4058):
|
||
// 取队伍 $_$.vd = WIt(t) → g$(HIt(), t).b135_1(坦克无队伍时返回中立 q$())
|
||
// 同队判定 $_$.dl = R7(a, b) → a.equals(b) && !a.equals(q$())
|
||
// ⚠ 旧键 $_$.kd / $_$.rk 在新版已指向无关的构造器类(CCt / FIn),用了会"敌我不分"
|
||
var TEAM_GET_KEYS = ['vd', 'kd'];
|
||
var TEAM_EQ_KEYS = ['dl', 'rk'];
|
||
function LoadTeamFunctions() {
|
||
if (ZCt && m2Fn) return { ZCt: ZCt, m2Fn: m2Fn };
|
||
try {
|
||
var mod87072 = window.__wp_require__(87072);
|
||
if (mod87072 && mod87072.$_$) {
|
||
for (var i = 0; i < TEAM_GET_KEYS.length && !ZCt; i++) {
|
||
var G = mod87072.$_$[TEAM_GET_KEYS[i]];
|
||
if (typeof G === 'function') ZCt = G;
|
||
}
|
||
for (var j = 0; j < TEAM_EQ_KEYS.length && !m2Fn; j++) {
|
||
var E = mod87072.$_$[TEAM_EQ_KEYS[j]];
|
||
if (typeof E === 'function') m2Fn = E;
|
||
}
|
||
console.log('[公共库] 队伍判断函数已加载: ZCt=' + (typeof ZCt) + ', m2=' + (typeof m2Fn));
|
||
}
|
||
} catch(e) {
|
||
console.warn('[公共库] 加载队伍判断函数失败:', e.message);
|
||
}
|
||
return { ZCt: ZCt, m2Fn: m2Fn };
|
||
}
|
||
|
||
// ===== 从 entity 获取包含指定字段的组件 =====
|
||
// 遍历 entity.wyj_1.r2a_1(Map<组件标识, 组件列表>),value 是列表类(s_),g1(0) 获取实际组件
|
||
// 注意:用 !== undefined 检查字段存在性,避免 falsy 值(0/""/null/false)漏判
|
||
function FindComponentByField(entity, fieldName) {
|
||
if (!entity || !entity.qz1_1 || !entity.qz1_1.s2a_1) return null;
|
||
var result = null;
|
||
entity.qz1_1.s2a_1.forEach(function(valueList, key) {
|
||
if (result) return;
|
||
try {
|
||
var comp = (valueList && typeof valueList.g1 === 'function') ? valueList.g1(0) : valueList;
|
||
if (comp && comp[fieldName] !== undefined) result = comp;
|
||
} catch(e) {}
|
||
});
|
||
return result;
|
||
}
|
||
|
||
// ===== 获取坦克位置(物理组件里的位置向量) =====
|
||
// 新旧差异(main.7cfa4058 vs 旧版):
|
||
// 物理组件类 ejt(组件名 "tankPhysics")的字段整体平移,构造函数字段序列一一对应:
|
||
// 旧 vjt: zzg_1 azh_1 czh_1 dzh_1 ezh_1 fzh_1 **gzh_1** hzh_1 izh_1 ...
|
||
// 新 ejt: uzq_1 vzq_1 xzq_1 yzq_1 zzq_1 azr_1 **bzr_1** czr_1 dzr_1 ...
|
||
// njt() 里 `bzr_1.l27(上一物理态位置, 当前物理态位置, 插值)` —— bzr_1 就是世界位置。
|
||
// ⚠ xzq_1 是车体半尺寸(用于包围盒/出生点:`-xzq_1.h20_1/2 + 10`),绝不能当位置用。
|
||
// 返回 {x, y, z},x/y 水平面,z 高度
|
||
var POS_FIELDS = ['bzr_1', 'gzh_1', 'pz7_1'];
|
||
function GetTankPosition(tank) {
|
||
for (var i = 0; i < POS_FIELDS.length; i++) {
|
||
var Key = POS_FIELDS[i];
|
||
var Comp = null;
|
||
try { Comp = FindComponentByField(tank, Key); } catch (e) { Comp = null; }
|
||
var V = Comp && Comp[Key];
|
||
if (V && typeof V.f20_1 === 'number' && typeof V.g20_1 === 'number' && typeof V.h20_1 === 'number') {
|
||
// 数值有效性校验:NaN/超大值一律视为读取失败(避免雷达画到画布外)
|
||
if (isFinite(V.f20_1) && isFinite(V.g20_1) && isFinite(V.h20_1) &&
|
||
Math.abs(V.f20_1) < 1e7 && Math.abs(V.g20_1) < 1e7 && Math.abs(V.h20_1) < 1e7) {
|
||
return { x: V.f20_1, y: V.g20_1, z: V.h20_1 };
|
||
}
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
// ===== 获取坦克状态值(Yjt 组件的 vzr_1.k3_1) =====
|
||
// 状态枚举:0=DEAD, 1=DEAD_PHANTOM, 2=SEMI_ACTIVE, 3=ACTIVE, 4=DISABLED
|
||
// Yjt 特征:有 azs_1(状态机)和 vzr_1(状态枚举)字段
|
||
function GetTankState(tank) {
|
||
if (!tank || !tank.qz1_1 || !tank.qz1_1.s2a_1) return -1;
|
||
var tankComp = null;
|
||
tank.qz1_1.s2a_1.forEach(function(valueList, key) {
|
||
if (tankComp) return;
|
||
try {
|
||
var comp = (valueList && typeof valueList.g1 === 'function') ? valueList.g1(0) : valueList;
|
||
if (comp && comp.p10c_1 && comp.k10c_1 !== undefined) tankComp = comp;
|
||
} catch(e) {}
|
||
});
|
||
if (tankComp && tankComp.k10c_1) return tankComp.k10c_1.k3_1;
|
||
return -1;
|
||
}
|
||
|
||
// 可见/战斗中的坦克 = ACTIVE(3) 或 SEMI_ACTIVE(2)
|
||
// 依据:引擎用 tankComponent.s14j()(= SEMI_ACTIVE || ACTIVE)决定该坦克节点是否渲染,
|
||
// 只认 ACTIVE 会漏掉半活跃状态(出生/过渡)的坦克
|
||
function IsTankAlive(tank) {
|
||
var s = GetTankState(tank);
|
||
return s === 2 || s === 3;
|
||
}
|
||
|
||
// ===== 获取坦克名称(yot 用户信息组件) =====
|
||
// s120_1 = userName, u120_1 = clanTag(可为null), t120_1 = userRank
|
||
function GetTankName(tank) {
|
||
var userComp = FindComponentByField(tank, 'c12n_1');
|
||
if (!userComp) return null;
|
||
var name = userComp.c12n_1 || '';
|
||
var clanTag = userComp.e12n_1 || '';
|
||
return clanTag ? (clanTag + ' ' + name) : name;
|
||
}
|
||
|
||
// ===== 获取坦克血量(Vkt 血量组件) =====
|
||
// f122_1 = 最大血量(max), g122_1 = 当前血量(current, 死亡时=0)
|
||
function GetTankHealth(tank) {
|
||
var healthComp = FindComponentByField(tank, 'q12o_1');
|
||
if (!healthComp) return null;
|
||
var max = healthComp.p12o_1 || 0;
|
||
var cur = healthComp.q12o_1 || 0;
|
||
if (max <= 0) return null;
|
||
return { current: cur, max: max, percent: cur / max };
|
||
}
|
||
|
||
// ===== 装备分数(战斗 GS:BattleGearScoreCC.score 落在组件的 cjf_1 上) =====
|
||
// 来源:module_85183 的 BattleGearScoreCC(score = cjf_1);战斗内每辆坦克各有一份
|
||
function GetGearScore(tank) {
|
||
var c = FindComponentByField(tank, 'cjf_1');
|
||
if (!c || typeof c.cjf_1 !== 'number') return null;
|
||
return c.cjf_1;
|
||
}
|
||
|
||
// ===== 人机识别 =====
|
||
// 规则:**同一队伍**内,**装备分数相同**的坦克(且分数 < maxScore,默认 8000)判为人机。
|
||
// 组内唯一的分数不算(真人装备各不相同,机器人往往整队同款)。
|
||
// 用法:工具定期调用 ScanBots()(约每 0.3 秒一次即可),再用 IsBot(tank) 查询。
|
||
var BotSet = new Set();
|
||
function ScanBots(opt) {
|
||
opt = opt || {};
|
||
var MaxScore = typeof opt.maxScore === 'number' ? opt.maxScore : 8000;
|
||
BotSet = new Set();
|
||
var gm = window.__gameMode__;
|
||
if (!gm || !gm.m10b_1) return BotSet;
|
||
var playerTank = GetPlayerTank(gm);
|
||
var list, n;
|
||
try { list = gm.m10b_1.j11y(); n = list.e1(); } catch (e) { return BotSet; }
|
||
var Groups = { ally: {}, enemy: {} }; // 组 → { 分数 → [坦克...] }
|
||
for (var i = 0; i < n; i++) {
|
||
var tank = list.g1(i);
|
||
if (!tank) continue;
|
||
var gs = GetGearScore(tank);
|
||
if (gs === null || gs >= MaxScore) continue; // 只统计低于阈值的
|
||
var g = IsFriendly(tank, playerTank, gm) ? 'ally' : 'enemy';
|
||
(Groups[g][gs] = Groups[g][gs] || []).push(tank);
|
||
}
|
||
['ally', 'enemy'].forEach(function (g) {
|
||
var Bag = Groups[g];
|
||
Object.keys(Bag).forEach(function (gs) {
|
||
var Arr = Bag[gs];
|
||
if (Arr.length >= 2) Arr.forEach(function (t) { BotSet.add(t); }); // 同队同分 → 人机
|
||
});
|
||
});
|
||
return BotSet;
|
||
}
|
||
function GetBotSet() { return BotSet; }
|
||
function IsBot(tank) { return BotSet.has(tank); }
|
||
|
||
// ===== 自动诊断:一次性打印关键链路状态(供新版字段平移后快速定位) =====
|
||
function SelfDiagnose() {
|
||
var R = { gm: false, tanks: -1, posHit: {}, posFail: 0, camera: null, scene: false, errors: [] };
|
||
var g = window.__gameMode__;
|
||
R.gm = !!g;
|
||
if (!g) return R;
|
||
try { R.scene = !!g.lz4().jz1_1; } catch (e) { R.errors.push('scene: ' + e.message); }
|
||
var list = null;
|
||
try { list = g.m10b_1.j11y(); R.tanks = list.e1(); } catch (e) { R.errors.push('tankList: ' + e.message); }
|
||
if (list) {
|
||
var N = Math.min(list.e1(), 8);
|
||
for (var i = 0; i < N; i++) {
|
||
var T = null;
|
||
try { T = list.g1(i); } catch (e) { continue; }
|
||
if (!T) continue;
|
||
var P = GetTankPosition(T);
|
||
if (P) {
|
||
// 记录命中来源(候选字段名 或 fallback)
|
||
var Src = 'fallback';
|
||
for (var j = 0; j < POS_FIELDS.length; j++) {
|
||
var K = POS_FIELDS[j], C = null;
|
||
try { C = FindComponentByField(T, K); } catch (e) { C = null; }
|
||
if (C && C[K] && typeof C[K].f20_1 === 'number') { Src = K; break; }
|
||
}
|
||
R.posHit[Src] = (R.posHit[Src] || 0) + 1;
|
||
} else {
|
||
R.posFail++;
|
||
// 失败时把该坦克组件的字段名与"候选向量"打印出来,便于定位
|
||
try {
|
||
var Fields = [];
|
||
T.qz1_1.s2a_1.forEach(function (VL) {
|
||
var C = (VL && typeof VL.g1 === 'function') ? VL.g1(0) : VL;
|
||
if (!C) return;
|
||
for (var K2 in C) {
|
||
var V2 = C[K2];
|
||
if (V2 && typeof V2 === 'object') {
|
||
var Kind = (typeof V2.f20_1 === 'number') ? 'O$' : (typeof V2.length === 'number' && V2.length >= 15 ? 'M' : '');
|
||
if (Kind) Fields.push(K2 + ':' + Kind);
|
||
}
|
||
}
|
||
});
|
||
R.errors.push('tank' + i + ' 组件字段向量: ' + Fields.slice(0, 40).join(','));
|
||
} catch (e2) { R.errors.push('tank' + i + ' dump err: ' + e2.message); }
|
||
}
|
||
}
|
||
}
|
||
try { R.camera = GetCameraInfo(g); } catch (e) { R.errors.push('camera: ' + e.message); }
|
||
return R;
|
||
}
|
||
|
||
// ===== 获取玩家坦克 =====
|
||
// 本地拥有的坦克优先(u10b_1 = onTankPossessed 设置的那辆)。
|
||
// ⚠ 旧顺序先取 t10b_1(当前观察目标):玩家死亡后观察目标会切到别人(甚至敌人),
|
||
// 用它当队伍基准会导致「敌我一会儿对一会儿反」。
|
||
// checkPosition: 是否检查位置有效性(默认 true)
|
||
function GetPlayerTank(gm, checkPosition) {
|
||
if (checkPosition === undefined) checkPosition = true;
|
||
var t = gm.u10b_1;
|
||
if (t && (!checkPosition || GetTankPosition(t))) return t;
|
||
t = gm.t10b_1;
|
||
if (t && (!checkPosition || GetTankPosition(t))) return t;
|
||
try {
|
||
var tl = gm.m10b_1.j11y();
|
||
if (tl.e1() > 0) {
|
||
t = tl.g1(0);
|
||
if (t && (!checkPosition || GetTankPosition(t))) return t;
|
||
}
|
||
} catch(e) {}
|
||
return null;
|
||
}
|
||
|
||
// 本地队伍缓存:死亡后 u10b_1 会被清空(onTankUnpossessed),不能再用"当前观察目标"推断。
|
||
// 这里记住最后一次有效的本地队伍,整局沿用。
|
||
var LocalTeam = null;
|
||
var LocalTeamGm = null;
|
||
function RememberLocalTeam(gm) {
|
||
if (!ZCt) return;
|
||
if (gm !== LocalTeamGm) { LocalTeamGm = gm; LocalTeam = null; }
|
||
if (LocalTeam) return;
|
||
try {
|
||
var t = gm.u10b_1;
|
||
if (!t) return;
|
||
var Team = ZCt(t);
|
||
// 只缓存"非中立"队伍(m2Fn(Team,Team) 为 true 表示同队且非中立)
|
||
if (Team && (!m2Fn || m2Fn(Team, Team))) LocalTeam = Team;
|
||
} catch (e) {}
|
||
}
|
||
|
||
// ===== 判断是否友方 =====
|
||
// 优先用缓存的本地队伍;没有缓存时才退回 playerTank 的队伍
|
||
function IsFriendly(tank, playerTank, gm) {
|
||
if (!tank) return false;
|
||
if (gm) RememberLocalTeam(gm);
|
||
if (ZCt && m2Fn) {
|
||
try {
|
||
var tankTeam = ZCt(tank);
|
||
var BaseTeam = LocalTeam;
|
||
if (!BaseTeam && playerTank) BaseTeam = ZCt(playerTank);
|
||
if (tankTeam && BaseTeam) return m2Fn(tankTeam, BaseTeam);
|
||
} catch(e) {}
|
||
}
|
||
try {
|
||
if (gm && gm.v10b_1 && ZCt) {
|
||
var team = ZCt(tank);
|
||
if (team && gm.v10b_1) {
|
||
return team.equals(gm.v10b_1) && m2Fn ? m2Fn(team, gm.v10b_1) : team.equals(gm.v10b_1);
|
||
}
|
||
}
|
||
} catch(e) {}
|
||
return false;
|
||
}
|
||
|
||
// ===== 获取相机信息(场景主相机 scene.iyx_1) =====
|
||
// 新旧差异:旧版是找挂在实体上的相机组件(kym_1/lym_1),新版这些字段已被别的类占用;
|
||
// 新版直接暴露主相机对象:gm.lz4().jz1_1(场景).iyx_1(相机,类 Jf/lc)
|
||
// 相机位置 = t35_1(O$ 向量)
|
||
// 视图旋转 = d37_1(列主序 4x4,行·点)
|
||
// 视投影矩阵 = g37_1(world → clip,行·点,w>0 为可见)
|
||
// 视口 = w36_1.h29()/g29(),FOV = b37_1
|
||
// 朝向:视图矩阵第 2 列 = 相机 +Z 轴,相机看向 -Z → forward = -(d37_1[2],[6],[10])
|
||
// 返回 {pos, yaw, pitch, fov, heading, matrix, viewW, viewH}
|
||
// yaw/pitch 供 Project3D 使用(forward = (-sin(yaw), cos(yaw), sin(pitch)))
|
||
// heading 供雷达使用(forward = (sin(heading), cos(heading)),水平面)
|
||
function GetCameraInfo(gm) {
|
||
try {
|
||
var scene = gm.lz4().jz1_1;
|
||
var cam = scene && scene.iyx_1;
|
||
if (!cam || !cam.t35_1) return null;
|
||
var P = cam.t35_1;
|
||
var pos = { x: P.f20_1, y: P.g20_1, z: P.h20_1 };
|
||
var V = cam.d37_1;
|
||
var fx = 0, fy = 1, fz = 0;
|
||
if (V && typeof V.length === 'number' && V.length >= 11) {
|
||
fx = -V[2]; fy = -V[6]; fz = -V[10];
|
||
var len = Math.sqrt(fx * fx + fy * fy + fz * fz) || 1;
|
||
fx /= len; fy /= len; fz /= len;
|
||
}
|
||
var viewW = 0, viewH = 0;
|
||
try { viewW = cam.w36_1.h29(); viewH = cam.w36_1.g29(); } catch (e) {}
|
||
return {
|
||
pos: pos,
|
||
yaw: Math.atan2(-fx, fy),
|
||
pitch: Math.asin(Math.max(-1, Math.min(1, fz))),
|
||
heading: Math.atan2(fx, fy),
|
||
fov: cam.b37_1 || 1.0471976,
|
||
matrix: cam.g37_1 || null,
|
||
viewW: viewW,
|
||
viewH: viewH
|
||
};
|
||
} catch (e) {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
// ===== 获取全局碰撞检测器(uMt 组件,挂在 GameMode entity 上) =====
|
||
// g16x(startVec, dirVec, maxDist, resultObj, filterObj) = 全局射线检测
|
||
function GetUMtComponent(gm) {
|
||
try {
|
||
var gmEntity = gm.lz4();
|
||
if (!gmEntity || !gmEntity.qz1_1 || !gmEntity.qz1_1.s2a_1) return null;
|
||
var uMt = null;
|
||
gmEntity.qz1_1.s2a_1.forEach(function(valueList, key) {
|
||
if (uMt) return;
|
||
try {
|
||
var comp = (valueList && typeof valueList.g1 === 'function') ? valueList.g1(0) : valueList;
|
||
if (comp && typeof comp.a17p === 'function') uMt = comp;
|
||
} catch(e) {}
|
||
});
|
||
return uMt;
|
||
} catch(e) {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
// ===== 构造 mock O$ 向量(用于射线检测接口) =====
|
||
function MkVec(x, y, z) {
|
||
return {
|
||
f20_1: x || 0, g20_1: y || 0, h20_1: z || 0,
|
||
k26: function(t) { this.f20_1 = t.f20_1; this.g20_1 = t.g20_1; this.h20_1 = t.h20_1; },
|
||
n23: function(x, y, z) { this.f20_1 = x; this.g20_1 = y; this.h20_1 = z; return this; }
|
||
};
|
||
}
|
||
|
||
// ===== 射线检测:返回命中距离(-1=未命中/异常) =====
|
||
// 从 fromPos 沿 (dirX,dirY,dirZ) 方向,最大距离 maxDist
|
||
function RaycastDistance(gm, fromPos, dirX, dirY, dirZ, maxDist) {
|
||
var uMt = GetUMtComponent(gm);
|
||
if (!uMt) return -1;
|
||
try {
|
||
var startVec = MkVec(fromPos.x, fromPos.y, fromPos.z);
|
||
var dirVec = MkVec(dirX, dirY, dirZ);
|
||
var result = {
|
||
y178_1: MkVec(0, 0, 0),
|
||
z178_1: MkVec(0, 0, 0),
|
||
a179_1: maxDist,
|
||
q17o_1: null,
|
||
b179_1: null,
|
||
r17o: function(t, n, i, r, e) {
|
||
this.y178_1.k26(t);
|
||
this.z178_1.k26(n);
|
||
this.a179_1 = i;
|
||
this.q17o_1 = r;
|
||
this.b179_1 = e;
|
||
},
|
||
d3: function() { this.b179_1 = null; }
|
||
};
|
||
var filter = {
|
||
l1da_1: { h1: function() { return false; }, d3: function() {} },
|
||
w179: function() { return true; },
|
||
d3: function() { this.l1da_1.d3(); },
|
||
m1da: function() {}
|
||
};
|
||
var hit = uMt.a17p(startVec, dirVec, maxDist, result, filter);
|
||
return (hit && result.a179_1 < maxDist) ? result.a179_1 : -1;
|
||
} catch(e) {
|
||
return -1;
|
||
}
|
||
}
|
||
|
||
// ===== 3D 到 2D 投影(矩阵版,最可靠) =====
|
||
// 直接用相机自带的 world→clip 矩阵(scene.iyx_1.g37_1),不依赖任何欧拉角约定。
|
||
// 矩阵按列主序存放、按"行·点"使用(与引擎 z37/a38 一致)。
|
||
function Project3DMatrix(camInfo, worldPos, screenW, screenH) {
|
||
var M = camInfo && camInfo.matrix;
|
||
if (!M || typeof M.length !== 'number' || M.length < 16) return null;
|
||
var px = worldPos.x, py = worldPos.y, pz = worldPos.z;
|
||
var cx = M[0] * px + M[4] * py + M[8] * pz + M[12];
|
||
var cy = M[1] * px + M[5] * py + M[9] * pz + M[13];
|
||
var cw = M[3] * px + M[7] * py + M[11] * pz + M[15];
|
||
if (!(cw > 0.0001)) return null;
|
||
return {
|
||
x: (cx / cw * 0.5 + 0.5) * screenW,
|
||
y: (1 - (cy / cw * 0.5 + 0.5)) * screenH,
|
||
depth: cw
|
||
};
|
||
}
|
||
|
||
// ===== 3D 到 2D 投影(用于 UI 标记) =====
|
||
// 标准透视投影:forward/right/up 三轴正交基
|
||
function Project3D(worldPos, camPos, yaw, pitch, fov, screenW, screenH) {
|
||
var cp = Math.cos(pitch);
|
||
var sp = Math.sin(pitch);
|
||
var cy = Math.cos(yaw);
|
||
var sy = Math.sin(yaw);
|
||
var forward = { x: cp * (-sy), y: cp * cy, z: sp };
|
||
var right = { x: cy, y: sy, z: 0 };
|
||
var up = {
|
||
x: right.y * forward.z - right.z * forward.y,
|
||
y: right.z * forward.x - right.x * forward.z,
|
||
z: right.x * forward.y - right.y * forward.x
|
||
};
|
||
var relX = worldPos.x - camPos.x;
|
||
var relY = worldPos.y - camPos.y;
|
||
var relZ = worldPos.z - camPos.z;
|
||
var camX = relX * right.x + relY * right.y + relZ * right.z;
|
||
var camY = relX * up.x + relY * up.y + relZ * up.z;
|
||
var camZ = relX * forward.x + relY * forward.y + relZ * forward.z;
|
||
if (camZ <= 0.1) return null;
|
||
var focalLength = screenH / (2 * Math.tan(fov / 2));
|
||
var screenX = screenW / 2 + (camX / camZ) * focalLength;
|
||
var screenY = screenH / 2 - (camY / camZ) * focalLength;
|
||
return { x: screenX, y: screenY, depth: camZ };
|
||
}
|
||
|
||
// ===== 用世界变换矩阵变换顶点(列主序4x4矩阵) =====
|
||
function TransformVertex(matrix, x, y, z) {
|
||
return {
|
||
x: matrix[0] * x + matrix[4] * y + matrix[8] * z + matrix[12],
|
||
y: matrix[1] * x + matrix[5] * y + matrix[9] * z + matrix[13],
|
||
z: matrix[2] * x + matrix[6] * y + matrix[10] * z + matrix[14]
|
||
};
|
||
}
|
||
|
||
// ===== 让元素可拖动(左键拖动,改变 left/top) =====
|
||
// 适用于固定定位的小窗口(雷达、后视镜等)
|
||
// key: 可选,传入后会把窗口位置存到 localStorage(hornet_layout_<key>)并在创建时还原
|
||
// 实现要点:Pointer Events + setPointerCapture,保证 pointerup/pointercancel 必定回到元素,
|
||
// 并在 window 捕获阶段兜底,避免游戏画布抢事件后"松手仍粘着鼠标"。
|
||
function MakeDraggable(elem, key) {
|
||
if (!elem) return null;
|
||
elem.style.cursor = 'move';
|
||
|
||
var StorageKey = key ? ('hornet_layout_' + key) : null;
|
||
|
||
function ClampX(v) { return Math.max(0, Math.min(window.innerWidth - 50, v)); }
|
||
function ClampY(v) { return Math.max(0, Math.min(window.innerHeight - 30, v)); }
|
||
|
||
// 还原上次保存的位置
|
||
if (StorageKey) {
|
||
try {
|
||
var __saved = localStorage.getItem(StorageKey);
|
||
if (__saved) {
|
||
var __p = JSON.parse(__saved);
|
||
if (__p && typeof __p.left === 'number' && typeof __p.top === 'number') {
|
||
elem.style.right = 'auto';
|
||
elem.style.transform = 'none';
|
||
elem.style.left = ClampX(__p.left) + 'px';
|
||
elem.style.top = ClampY(__p.top) + 'px';
|
||
}
|
||
}
|
||
} catch(e) {}
|
||
}
|
||
|
||
function SavePos() {
|
||
if (!StorageKey) return;
|
||
try {
|
||
var r = elem.getBoundingClientRect();
|
||
localStorage.setItem(StorageKey, JSON.stringify({ left: Math.round(r.left), top: Math.round(r.top) }));
|
||
} catch(e) {}
|
||
}
|
||
|
||
var Active = false;
|
||
var PointerId = null;
|
||
var GrabX = 0;
|
||
var GrabY = 0;
|
||
|
||
function MoveTo(ClientX, ClientY) {
|
||
elem.style.left = ClampX(ClientX - GrabX) + 'px';
|
||
elem.style.top = ClampY(ClientY - GrabY) + 'px';
|
||
}
|
||
|
||
function Begin(ClientX, ClientY) {
|
||
var Rect = elem.getBoundingClientRect();
|
||
// 移除 right 定位,改用 left
|
||
elem.style.right = 'auto';
|
||
elem.style.transform = 'none';
|
||
elem.style.left = Rect.left + 'px';
|
||
elem.style.top = Rect.top + 'px';
|
||
GrabX = ClientX - Rect.left;
|
||
GrabY = ClientY - Rect.top;
|
||
Active = true;
|
||
elem.style.cursor = 'grabbing';
|
||
}
|
||
|
||
function End() {
|
||
if (!Active) return;
|
||
Active = false;
|
||
if (PointerId !== null && elem.releasePointerCapture) {
|
||
try { elem.releasePointerCapture(PointerId); } catch (Err) {}
|
||
}
|
||
PointerId = null;
|
||
elem.style.cursor = 'move';
|
||
SavePos();
|
||
}
|
||
|
||
if (window.PointerEvent) {
|
||
elem.addEventListener('pointerdown', function (e) {
|
||
if (e.button !== 0) return;
|
||
Begin(e.clientX, e.clientY);
|
||
PointerId = e.pointerId;
|
||
try { elem.setPointerCapture(e.pointerId); } catch (Err) {}
|
||
e.preventDefault();
|
||
});
|
||
elem.addEventListener('pointermove', function (e) {
|
||
if (!Active || (PointerId !== null && e.pointerId !== PointerId)) return;
|
||
if (e.buttons === 0) { End(); return; }
|
||
MoveTo(e.clientX, e.clientY);
|
||
});
|
||
elem.addEventListener('pointerup', function () { End(); });
|
||
elem.addEventListener('pointercancel', function () { End(); });
|
||
elem.addEventListener('lostpointercapture', function () { End(); });
|
||
window.addEventListener('pointerup', function () { End(); }, true);
|
||
} else {
|
||
elem.addEventListener('mousedown', function (e) {
|
||
if (e.button !== 0) return;
|
||
Begin(e.clientX, e.clientY);
|
||
e.preventDefault();
|
||
});
|
||
document.addEventListener('mousemove', function (e) {
|
||
if (!Active) return;
|
||
if (e.buttons === 0) { End(); return; }
|
||
MoveTo(e.clientX, e.clientY);
|
||
}, true);
|
||
document.addEventListener('mouseup', function () { End(); }, true);
|
||
}
|
||
|
||
window.addEventListener('blur', End);
|
||
window.addEventListener('mouseleave', End);
|
||
|
||
return { End: End, IsActive: function () { return Active; } };
|
||
}
|
||
|
||
// ===== 导出公共函数到 window.__hornetLib__ =====
|
||
window.__hornetLib__ = {
|
||
LoadTeamFunctions: LoadTeamFunctions,
|
||
FindComponentByField: FindComponentByField,
|
||
GetTankPosition: GetTankPosition,
|
||
GetTankState: GetTankState,
|
||
IsTankAlive: IsTankAlive,
|
||
GetTankName: GetTankName,
|
||
GetTankHealth: GetTankHealth,
|
||
SelfDiagnose: SelfDiagnose,
|
||
GetGearScore: GetGearScore,
|
||
ScanBots: ScanBots,
|
||
GetBotSet: GetBotSet,
|
||
IsBot: IsBot,
|
||
GetPlayerTank: GetPlayerTank,
|
||
IsFriendly: IsFriendly,
|
||
GetCameraInfo: GetCameraInfo,
|
||
GetUMtComponent: GetUMtComponent,
|
||
MkVec: MkVec,
|
||
RaycastDistance: RaycastDistance,
|
||
Project3D: Project3D,
|
||
Project3DMatrix: Project3DMatrix,
|
||
TransformVertex: TransformVertex,
|
||
MakeDraggable: MakeDraggable
|
||
};
|
||
|
||
console.log('[公共库] 已加载,可用函数:', Object.keys(window.__hornetLib__).join(', '));
|
||
})();
|
||
|
||
|
||
// ===== hornet_radar.js =====
|
||
// ===== 3DTank雷达 - 2D俯视图雷达 =====
|
||
// 在浏览器控制台运行(需先运行 hook_gamemode.js)
|
||
// 功能:在屏幕右上角显示2D雷达,显示所有坦克的相对位置
|
||
|
||
(function() {
|
||
function _start() {
|
||
console.log('===== 3DTank雷达启动 =====');
|
||
|
||
// 允许在未进入战局 / ght 尚未捕获时启动:先出雷达框,内容等进入战局再出
|
||
if (window.__hornetRadar__) {
|
||
window.__hornetRadar__.stop();
|
||
console.log('已停止旧雷达');
|
||
}
|
||
|
||
var gm = window.__gameMode__ || null;
|
||
|
||
// ===== 引用公共库(hornet_lib.js) =====
|
||
var lib = window.__hornetLib__;
|
||
if (!lib) {
|
||
console.error('[雷达] 请先运行 hornet_lib.js');
|
||
return;
|
||
}
|
||
var teamFns = lib.LoadTeamFunctions();
|
||
var ZCt = teamFns.ZCt, m2Fn = teamFns.m2Fn;
|
||
// 人机标记:ScanBots 定期扫描(同队内装备分数相同且 < 8000)→ IsBot 查询
|
||
var ScanBots = lib.ScanBots, IsBot = lib.IsBot;
|
||
|
||
// 组件查找:使用公共库(已修复 falsy 值漏判 bug)
|
||
var FindComponentByField = lib.FindComponentByField;
|
||
|
||
// 坦克位置:使用公共库
|
||
var GetTankPosition = lib.GetTankPosition;
|
||
|
||
// 坦克状态:使用公共库(0=DEAD,1=DEAD_PHANTOM,2=SEMI_ACTIVE,3=ACTIVE,4=DISABLED)
|
||
var GetTankState = lib.GetTankState;
|
||
var GetTankHealth = lib.GetTankHealth;
|
||
|
||
// ===== 获取坦克方向(Kkt 组件的 dzc_1) =====
|
||
function getTankDirection(tank) {
|
||
var hullComp = FindComponentByField(tank, 'qzv_1');
|
||
if (hullComp && hullComp.wzl_1) {
|
||
return {
|
||
x: hullComp.wzl_1.f20_1,
|
||
y: hullComp.wzl_1.g20_1,
|
||
z: hullComp.wzl_1.h20_1
|
||
};
|
||
}
|
||
return null;
|
||
}
|
||
|
||
// ===== 获取相机朝向(新版:直接用场景主相机 scene.iyx_1 的视图矩阵算水平朝向) =====
|
||
// 雷达约定:forward = (sin(angle), cos(angle)),x/y 为水平面
|
||
function getCameraHeading() {
|
||
var ci = null;
|
||
try { ci = lib.GetCameraInfo(gm); } catch (e) { ci = null; }
|
||
if (!ci) return null;
|
||
return { yaw: ci.heading, pitch: ci.pitch, pos: ci.pos };
|
||
}
|
||
|
||
// 玩家坦克:使用公共库
|
||
function GetPlayerTank() { return lib.GetPlayerTank(gm); }
|
||
|
||
// 友方判断:使用公共库
|
||
function IsFriendly(tank, playerTank) { return lib.IsFriendly(tank, playerTank, gm); }
|
||
|
||
// 画死亡标记(物理体仍存在但已阵亡):红色 ×
|
||
function DrawDeadCross(x, y) {
|
||
var s = 5;
|
||
ctx.strokeStyle = '#ff4444';
|
||
ctx.lineWidth = 2;
|
||
ctx.beginPath();
|
||
ctx.moveTo(x - s, y - s);
|
||
ctx.lineTo(x + s, y + s);
|
||
ctx.moveTo(x + s, y - s);
|
||
ctx.lineTo(x - s, y + s);
|
||
ctx.stroke();
|
||
}
|
||
|
||
// ===== 获取全局碰撞检测器(uMt 组件,挂在 GameMode entity 上) =====
|
||
var UMtComponent = null;
|
||
function getUMtComponent() {
|
||
if (UMtComponent) return UMtComponent;
|
||
try {
|
||
var gmEntity = gm.lz4();
|
||
if (!gmEntity || !gmEntity.qz1_1 || !gmEntity.qz1_1.s2a_1) return null;
|
||
gmEntity.qz1_1.s2a_1.forEach(function(valueList, key) {
|
||
if (UMtComponent) return;
|
||
try {
|
||
var comp = (valueList && typeof valueList.g1 === 'function') ? valueList.g1(0) : valueList;
|
||
if (comp && typeof comp.a17p === 'function') UMtComponent = comp;
|
||
} catch(e) {}
|
||
});
|
||
if (UMtComponent) console.log('[雷达] uMt 碰撞检测器已获取');
|
||
else UMtComponent = false;
|
||
} catch(e) {
|
||
console.warn('[雷达] 获取 uMt 失败:', e.message);
|
||
}
|
||
return UMtComponent || null;
|
||
}
|
||
|
||
// mock O$ 向量:使用公共库
|
||
var MkVec = lib.MkVec;
|
||
|
||
// ===== 射线检测:从 from 点沿 dir 方向,最大距离 maxDist,返回命中距离(-1=未命中) =====
|
||
function raycastDistance(fromPos, dirX, dirY, dirZ, maxDist) {
|
||
var uMt = getUMtComponent();
|
||
if (!uMt) return -1;
|
||
try {
|
||
var startVec = MkVec(fromPos.x, fromPos.y, fromPos.z);
|
||
var dirVec = MkVec(dirX, dirY, dirZ);
|
||
var result = {
|
||
y178_1: MkVec(0,0,0), z178_1: MkVec(0,0,0),
|
||
a179_1: maxDist, q17o_1: null, b179_1: null,
|
||
r17o: function(t, n, i, r, e) {
|
||
this.y178_1.k26(t); this.z178_1.k26(n);
|
||
this.a179_1 = i; this.q17o_1 = r; this.b179_1 = e;
|
||
},
|
||
d3: function() { this.b179_1 = null; }
|
||
};
|
||
var filter = {
|
||
l1da_1: { h1: function() { return false; }, d3: function() {} },
|
||
w179: function() { return true; },
|
||
d3: function() {}, m1da: function() {}
|
||
};
|
||
var hit = uMt.a17p(startVec, dirVec, maxDist, result, filter);
|
||
return (hit && result.a179_1 < maxDist) ? result.a179_1 : -1;
|
||
} catch(e) {
|
||
return -1;
|
||
}
|
||
}
|
||
|
||
// ===== 地形高度采样(分帧,双缓冲) =====
|
||
// 双缓冲:重采样时写 newHeights,旧 heights 继续显示,完成后原子切换
|
||
// 解决闪烁:重采样期间不清空 heights,ready 保持 true
|
||
var Terrain = {
|
||
gridSize: 72, // 72x72 网格
|
||
range: 30000, // 采样范围(与雷达范围一致)
|
||
// 当前显示的缓冲区
|
||
heights: null,
|
||
originX: 0, originY: 0,
|
||
minHeight: 99999, maxHeight: -99999,
|
||
// 重采样时的新缓冲区(null=未在重采样)
|
||
newHeights: null,
|
||
newOriginX: 0, newOriginY: 0,
|
||
newMinHeight: 99999, newMaxHeight: -99999,
|
||
sampling: false,
|
||
sampleIndex: 0,
|
||
lastSampleX: 99999, lastSampleY: 99999,
|
||
samplesPerFrame: 60,
|
||
rayStartZ: 5000,
|
||
rayMaxDist: 10000,
|
||
ready: false
|
||
};
|
||
|
||
// 首次初始化(heights 为 null 时调用)
|
||
function initTerrain(playerPos) {
|
||
Terrain.heights = new Float32Array(Terrain.gridSize * Terrain.gridSize);
|
||
for (var i = 0; i < Terrain.heights.length; i++) Terrain.heights[i] = NaN;
|
||
Terrain.originX = playerPos.x;
|
||
Terrain.originY = playerPos.y;
|
||
Terrain.minHeight = 99999;
|
||
Terrain.maxHeight = -99999;
|
||
Terrain.sampleIndex = 0;
|
||
Terrain.sampling = true;
|
||
Terrain.ready = false;
|
||
}
|
||
|
||
// 开始重采样(双缓冲:不清空 heights,保持 ready 状态)
|
||
function startResample(playerPos) {
|
||
Terrain.newHeights = new Float32Array(Terrain.gridSize * Terrain.gridSize);
|
||
for (var i = 0; i < Terrain.newHeights.length; i++) Terrain.newHeights[i] = NaN;
|
||
Terrain.newOriginX = playerPos.x;
|
||
Terrain.newOriginY = playerPos.y;
|
||
Terrain.newMinHeight = 99999;
|
||
Terrain.newMaxHeight = -99999;
|
||
Terrain.sampleIndex = 0;
|
||
Terrain.sampling = true;
|
||
// 不动 heights/originX/originY/min/max/ready —— 旧数据继续显示,避免闪烁
|
||
}
|
||
|
||
function updateTerrainSampling(playerPos) {
|
||
// 首次初始化
|
||
if (!Terrain.heights) {
|
||
initTerrain(playerPos);
|
||
Terrain.lastSampleX = playerPos.x;
|
||
Terrain.lastSampleY = playerPos.y;
|
||
} else if (!Terrain.sampling) {
|
||
// 玩家移动超过 range/3 重新采样(双缓冲,不阻塞显示)
|
||
var dx = playerPos.x - Terrain.lastSampleX;
|
||
var dy = playerPos.y - Terrain.lastSampleY;
|
||
if (dx*dx + dy*dy > (Terrain.range/3)*(Terrain.range/3)) {
|
||
Terrain.lastSampleX = playerPos.x;
|
||
Terrain.lastSampleY = playerPos.y;
|
||
startResample(playerPos);
|
||
}
|
||
}
|
||
|
||
if (!Terrain.sampling) return;
|
||
var uMt = getUMtComponent();
|
||
if (!uMt) { Terrain.sampling = false; return; }
|
||
|
||
var halfRange = Terrain.range / 2;
|
||
var cellSize = Terrain.range / (Terrain.gridSize - 1);
|
||
var sampled = 0;
|
||
|
||
// 目标缓冲区:重采样时写 newHeights,首次时写 heights
|
||
var target = Terrain.newHeights || Terrain.heights;
|
||
var originX = Terrain.newHeights ? Terrain.newOriginX : Terrain.originX;
|
||
var originY = Terrain.newHeights ? Terrain.newOriginY : Terrain.originY;
|
||
var minH = Terrain.newHeights ? Terrain.newMinHeight : Terrain.minHeight;
|
||
var maxH = Terrain.newHeights ? Terrain.newMaxHeight : Terrain.maxHeight;
|
||
|
||
while (Terrain.sampleIndex < Terrain.gridSize * Terrain.gridSize && sampled < Terrain.samplesPerFrame) {
|
||
var gx = Terrain.sampleIndex % Terrain.gridSize;
|
||
var gy = Math.floor(Terrain.sampleIndex / Terrain.gridSize);
|
||
var wx = originX - halfRange + gx * cellSize;
|
||
var wy = originY - halfRange + gy * cellSize;
|
||
|
||
// 向下射线检测地面高度
|
||
var dist = raycastDistance(
|
||
{ x: wx, y: wy, z: Terrain.rayStartZ },
|
||
0, 0, -1, Terrain.rayMaxDist
|
||
);
|
||
var height = dist > 0 ? (Terrain.rayStartZ - dist) : NaN;
|
||
target[Terrain.sampleIndex] = height;
|
||
if (!isNaN(height)) {
|
||
if (height < minH) minH = height;
|
||
if (height > maxH) maxH = height;
|
||
}
|
||
|
||
Terrain.sampleIndex++;
|
||
sampled++;
|
||
}
|
||
|
||
// 回写 min/max(基本类型,需手动赋值)
|
||
if (Terrain.newHeights) {
|
||
Terrain.newMinHeight = minH;
|
||
Terrain.newMaxHeight = maxH;
|
||
} else {
|
||
Terrain.minHeight = minH;
|
||
Terrain.maxHeight = maxH;
|
||
}
|
||
|
||
if (Terrain.sampleIndex >= Terrain.gridSize * Terrain.gridSize) {
|
||
// 采样完成:原子切换
|
||
if (Terrain.newHeights) {
|
||
Terrain.heights = Terrain.newHeights;
|
||
Terrain.originX = Terrain.newOriginX;
|
||
Terrain.originY = Terrain.newOriginY;
|
||
Terrain.minHeight = Terrain.newMinHeight;
|
||
Terrain.maxHeight = Terrain.newMaxHeight;
|
||
Terrain.newHeights = null;
|
||
console.log('[雷达] 地形重采样完成, 高度范围:', Terrain.minHeight.toFixed(0), '~', Terrain.maxHeight.toFixed(0));
|
||
} else {
|
||
console.log('[雷达] 地形采样完成, 高度范围:', Terrain.minHeight.toFixed(0), '~', Terrain.maxHeight.toFixed(0));
|
||
}
|
||
Terrain.sampling = false;
|
||
Terrain.ready = true;
|
||
}
|
||
}
|
||
|
||
var _terrainCanvas = null;
|
||
function drawTerrain(ctx, playerPos, playerAngle) {
|
||
if (!Terrain.ready || !Terrain.heights) return;
|
||
if (!_terrainCanvas) {
|
||
_terrainCanvas = document.createElement('canvas');
|
||
_terrainCanvas.width = radius * 2;
|
||
_terrainCanvas.height = radius * 2;
|
||
}
|
||
var tCtx = _terrainCanvas.getContext('2d');
|
||
var w = radius * 2;
|
||
var imgData = tCtx.createImageData(w, w);
|
||
var data = imgData.data;
|
||
|
||
var cosA = Math.cos(playerAngle);
|
||
var sinA = Math.sin(playerAngle);
|
||
var halfRange = Terrain.range / 2;
|
||
var cellSize = Terrain.range / (Terrain.gridSize - 1);
|
||
var heightRange = Terrain.maxHeight - Terrain.minHeight;
|
||
if (heightRange < 1) heightRange = 1;
|
||
var originX = Terrain.originX - halfRange;
|
||
var originY = Terrain.originY - halfRange;
|
||
var scale = Terrain.range / (radius * 2); // 雷达像素→世界距离
|
||
|
||
// 超出网格范围/未采样点的默认暗色(避免空洞)
|
||
var DEF_R = 15, DEF_G = 20, DEF_B = 35, DEF_A = 180;
|
||
|
||
// 遍历每个雷达像素,反算世界坐标,双线性插值查询高度图
|
||
// 海拔配色:蓝(低)→绿(中)→紫(高)
|
||
for (var py = 0; py < w; py++) {
|
||
for (var px = 0; px < w; px++) {
|
||
var ox = px - radius; // 相对中心x
|
||
var oy = radius - py; // 相对中心y(反转)
|
||
// 逆旋转(雷达→世界)
|
||
var rdx = ox * cosA + oy * sinA;
|
||
var rdy = -ox * sinA + oy * cosA;
|
||
var wx = playerPos.x + rdx * scale;
|
||
var wy = playerPos.y + rdy * scale;
|
||
var idx = (py * w + px) * 4;
|
||
// 双线性插值查询高度图
|
||
var gxf = (wx - originX) / cellSize;
|
||
var gyf = (wy - originY) / cellSize;
|
||
var gx0 = Math.floor(gxf);
|
||
var gy0 = Math.floor(gyf);
|
||
// 超出网格范围:填充默认暗色(不跳过,解决高度图不全)
|
||
if (gx0 < 0 || gx0 >= Terrain.gridSize || gy0 < 0 || gy0 >= Terrain.gridSize) {
|
||
data[idx] = DEF_R; data[idx + 1] = DEF_G; data[idx + 2] = DEF_B; data[idx + 3] = DEF_A;
|
||
continue;
|
||
}
|
||
var gx1 = Math.min(gx0 + 1, Terrain.gridSize - 1);
|
||
var gy1 = Math.min(gy0 + 1, Terrain.gridSize - 1);
|
||
var fx = gxf - gx0;
|
||
var fy = gyf - gy0;
|
||
// 四个角的高度
|
||
var h00 = Terrain.heights[gy0 * Terrain.gridSize + gx0];
|
||
var h10 = Terrain.heights[gy0 * Terrain.gridSize + gx1];
|
||
var h01 = Terrain.heights[gy1 * Terrain.gridSize + gx0];
|
||
var h11 = Terrain.heights[gy1 * Terrain.gridSize + gx1];
|
||
// 未采样到的点:填充默认暗色
|
||
if (isNaN(h00) || isNaN(h10) || isNaN(h01) || isNaN(h11)) {
|
||
data[idx] = DEF_R; data[idx + 1] = DEF_G; data[idx + 2] = DEF_B; data[idx + 3] = DEF_A;
|
||
continue;
|
||
}
|
||
// 双线性插值
|
||
var h0 = h00 * (1 - fx) + h10 * fx; // y=gy0 行 x 方向插值
|
||
var h1 = h01 * (1 - fx) + h11 * fx; // y=gy1 行 x 方向插值
|
||
var h = h0 * (1 - fy) + h1 * fy; // y 方向插值
|
||
var norm = (h - Terrain.minHeight) / heightRange;
|
||
// 蓝(50,100,230) → 绿(80,210,90) → 紫(190,80,220)
|
||
var r, g, b;
|
||
if (norm < 0.5) {
|
||
var t = norm * 2; // 0~1
|
||
r = Math.floor(50 + (80 - 50) * t);
|
||
g = Math.floor(100 + (210 - 100) * t);
|
||
b = Math.floor(230 + (90 - 230) * t);
|
||
} else {
|
||
var t = (norm - 0.5) * 2; // 0~1
|
||
r = Math.floor(80 + (190 - 80) * t);
|
||
g = Math.floor(210 + (80 - 210) * t);
|
||
b = Math.floor(90 + (220 - 90) * t);
|
||
}
|
||
data[idx] = r;
|
||
data[idx + 1] = g;
|
||
data[idx + 2] = b;
|
||
data[idx + 3] = 200;
|
||
}
|
||
}
|
||
|
||
tCtx.putImageData(imgData, 0, 0);
|
||
ctx.save();
|
||
ctx.globalAlpha = 0.6;
|
||
ctx.drawImage(_terrainCanvas, cx - radius, cy - radius);
|
||
ctx.restore();
|
||
}
|
||
|
||
// ===== 创建Canvas =====
|
||
var canvas = document.createElement('canvas');
|
||
canvas.id = 'hornetRadarCanvas';
|
||
canvas.width = 320;
|
||
canvas.height = 320;
|
||
canvas.style.cssText = 'position:fixed;top:10px;right:10px;z-index:99999;pointer-events:auto;cursor:move;border:2px solid rgba(255,255,255,0.3);border-radius:5px;background:rgba(0,0,0,0.3);box-shadow:0 0 10px rgba(0,0,0,0.3);';
|
||
document.body.appendChild(canvas);
|
||
if (lib.MakeDraggable) lib.MakeDraggable(canvas, 'radar');
|
||
|
||
var ctx = canvas.getContext('2d');
|
||
var cx = canvas.width / 2;
|
||
var cy = canvas.height / 2;
|
||
var radius = 150;
|
||
var radarRange = 30000; // 雷达范围(世界单位),可通过 Alt+/Alt- 调整
|
||
var RADAR_MIN = 3000, RADAR_MAX = 60000, RADAR_STEP = 1.5;
|
||
// 从 localStorage 恢复范围
|
||
try {
|
||
var __savedRR = localStorage.getItem('hornet_radar_range');
|
||
if (__savedRR) {
|
||
var __rr = parseFloat(__savedRR);
|
||
if (!isNaN(__rr) && __rr >= RADAR_MIN && __rr <= RADAR_MAX) radarRange = __rr;
|
||
}
|
||
} catch(e) {}
|
||
// 透明度
|
||
var RadarOpacity = 1.0;
|
||
try {
|
||
var __savedRO = localStorage.getItem('hornet_radar_opacity');
|
||
if (__savedRO) {
|
||
var __ro = parseFloat(__savedRO);
|
||
if (!isNaN(__ro) && __ro >= 0.2 && __ro <= 1) {
|
||
RadarOpacity = __ro;
|
||
canvas.style.opacity = String(__ro);
|
||
}
|
||
}
|
||
} catch(e) {}
|
||
|
||
// ===== Alt+/Alt- 调整雷达范围(Alt+ 放大看局部,Alt- 缩小看全场) =====
|
||
function adjustRange(zoomIn) {
|
||
var newRange = zoomIn
|
||
? Math.max(RADAR_MIN, radarRange / RADAR_STEP)
|
||
: Math.min(RADAR_MAX, radarRange * RADAR_STEP);
|
||
if (newRange === radarRange) return;
|
||
radarRange = newRange;
|
||
try { localStorage.setItem('hornet_radar_range', String(radarRange)); } catch(e) {}
|
||
Terrain.range = newRange;
|
||
// 强制下一帧 updateTerrainSampling 触发重采样(双缓冲:保持旧数据可见)
|
||
Terrain.lastSampleX = 99999;
|
||
Terrain.lastSampleY = 99999;
|
||
// 不设 ready=false —— 旧 heights 继续显示,避免闪烁
|
||
// 若正在采样则中止,用新 range 重新开始
|
||
Terrain.newHeights = null;
|
||
Terrain.sampling = false;
|
||
console.log('[雷达] 范围' + (zoomIn ? '缩小(放大局部)' : '扩大(看全场)') + ': ' + (radarRange / 100).toFixed(0) + 'm');
|
||
}
|
||
|
||
var _keyHandler = function(e) {
|
||
if (!e.altKey) return;
|
||
var key = e.key.toLowerCase();
|
||
if (key === '+' || key === '=' || e.code === 'NumpadAdd' || e.code === 'Equal') {
|
||
e.preventDefault();
|
||
adjustRange(true);
|
||
} else if (key === '-' || key === '_' || e.code === 'NumpadSubtract' || e.code === 'Minus') {
|
||
e.preventDefault();
|
||
adjustRange(false);
|
||
}
|
||
};
|
||
window.addEventListener('keydown', _keyHandler);
|
||
|
||
// ===== 雷达状态 =====
|
||
var radar = {
|
||
canvas: canvas,
|
||
ctx: ctx,
|
||
running: true,
|
||
frameCount: 0,
|
||
tankCount: 0,
|
||
playerFound: false,
|
||
lastPlayerPos: null, // 上一帧玩家位置(用于计算朝向)
|
||
playerHeading: 0, // 玩家朝向角度(弧度)
|
||
stop: function() {
|
||
this.running = false;
|
||
if (canvas.parentNode) canvas.parentNode.removeChild(canvas);
|
||
window.removeEventListener('keydown', _keyHandler);
|
||
console.log('雷达已停止');
|
||
},
|
||
setRange: function(r) {
|
||
var n = parseFloat(r);
|
||
if (!isNaN(n) && n >= RADAR_MIN && n <= RADAR_MAX) {
|
||
radarRange = n;
|
||
try { localStorage.setItem('hornet_radar_range', String(n)); } catch(e) {}
|
||
Terrain.range = n;
|
||
Terrain.lastSampleX = 99999; Terrain.lastSampleY = 99999;
|
||
Terrain.newHeights = null; Terrain.sampling = false;
|
||
console.log('[雷达] 范围设为: ' + (n/100).toFixed(0) + 'm');
|
||
} else {
|
||
console.warn('[雷达] 范围应在 ' + (RADAR_MIN/100) + '~' + (RADAR_MAX/100) + 'm');
|
||
}
|
||
},
|
||
getRange: function() { return radarRange; },
|
||
setOpacity: function(o) {
|
||
var n = parseFloat(o);
|
||
if (!isNaN(n) && n >= 0.2 && n <= 1) {
|
||
RadarOpacity = n;
|
||
canvas.style.opacity = String(n);
|
||
try { localStorage.setItem('hornet_radar_opacity', String(n)); } catch(e) {}
|
||
} else {
|
||
console.warn('[雷达] 透明度应在 0.2~1');
|
||
}
|
||
},
|
||
getOpacity: function() { return RadarOpacity; }
|
||
};
|
||
window.__hornetRadar__ = radar;
|
||
|
||
// ===== 渲染循环 =====
|
||
function render() {
|
||
if (!radar.running) return;
|
||
|
||
radar.frameCount++;
|
||
|
||
// ===== 新战斗/新地图自刷新:gameMode 实例变化时重置缓存并重新采样地形 =====
|
||
var NewGm = window.__gameMode__;
|
||
if (NewGm && NewGm !== gm) {
|
||
gm = NewGm;
|
||
UMtComponent = null; // 碰撞检测器需从新场景重新获取
|
||
radar.lastPlayerPos = null;
|
||
radar.playerHeading = 0;
|
||
Terrain.heights = null; // 丢弃旧地图高度图,触发重新采样
|
||
Terrain.newHeights = null;
|
||
Terrain.sampling = false;
|
||
Terrain.ready = false;
|
||
Terrain.lastSampleX = 99999;
|
||
Terrain.lastSampleY = 99999;
|
||
radar.tankCount = 0;
|
||
console.log('[雷达] 检测到新战斗/新地图,已重置缓存并重新采样地形');
|
||
}
|
||
// ght 尚未捕获 / 不在战局:仍画静态雷达框,内容留空(等进入战局自动出)
|
||
var Ready = !!gm;
|
||
|
||
// 进战局后打印一次自诊断(位置/相机链路),便于新版字段平移时快速定位
|
||
if (Ready && !radar._diagPrinted) {
|
||
radar._diagPrinted = true;
|
||
try { console.log('[雷达] 自诊断:', lib.SelfDiagnose()); } catch (e) {}
|
||
}
|
||
|
||
try {
|
||
var tankList = null, count = 0;
|
||
if (Ready) {
|
||
tankList = gm.m10b_1.j11y();
|
||
count = tankList.e1();
|
||
}
|
||
radar.tankCount = count;
|
||
|
||
// 人机扫描:装备分数不会每帧变,节流到约每 0.3 秒一次
|
||
if (Ready && ScanBots && (Date.now() - (radar._lastBotScan || 0) > 300)) {
|
||
radar._lastBotScan = Date.now();
|
||
ScanBots();
|
||
}
|
||
|
||
// 获取玩家坦克(带备用方案)
|
||
var playerTank = Ready ? GetPlayerTank() : null;
|
||
radar.playerFound = (playerTank !== null && playerTank !== undefined);
|
||
|
||
// 自由视角激活时,用虚拟相机位置
|
||
var fl = window.__hornetFreeLook__;
|
||
var flActive = fl && typeof fl.IsActive === 'function' && fl.IsActive();
|
||
|
||
// 获取玩家位置和方向
|
||
var playerPos = flActive ? fl.GetPosition() : (playerTank ? GetTankPosition(playerTank) : null);
|
||
var playerDir = flActive ? fl.GetDirection() : (playerTank ? getTankDirection(playerTank) : null);
|
||
|
||
// 地形高度采样(分帧)
|
||
if (playerPos) updateTerrainSampling(playerPos);
|
||
|
||
// 清空Canvas
|
||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||
|
||
// 画雷达背景(方形)
|
||
ctx.fillStyle = 'rgba(0, 20, 0, 0.4)';
|
||
ctx.fillRect(cx - radius, cy - radius, radius * 2, radius * 2);
|
||
ctx.strokeStyle = 'rgba(100, 255, 100, 0.5)';
|
||
ctx.lineWidth = 2;
|
||
ctx.strokeRect(cx - radius, cy - radius, radius * 2, radius * 2);
|
||
|
||
// 画十字线
|
||
ctx.beginPath();
|
||
ctx.moveTo(cx - radius, cy);
|
||
ctx.lineTo(cx + radius, cy);
|
||
ctx.moveTo(cx, cy - radius);
|
||
ctx.lineTo(cx, cy + radius);
|
||
ctx.strokeStyle = 'rgba(100, 255, 100, 0.2)';
|
||
ctx.lineWidth = 1;
|
||
ctx.stroke();
|
||
|
||
// 画距离网格线(方形)
|
||
for (var r = radius / 3; r < radius; r += radius / 3) {
|
||
ctx.beginPath();
|
||
ctx.moveTo(cx - radius, cy - r);
|
||
ctx.lineTo(cx + radius, cy - r);
|
||
ctx.moveTo(cx - radius, cy + r);
|
||
ctx.lineTo(cx + radius, cy + r);
|
||
ctx.moveTo(cx - r, cy - radius);
|
||
ctx.lineTo(cx - r, cy + radius);
|
||
ctx.moveTo(cx + r, cy - radius);
|
||
ctx.lineTo(cx + r, cy + radius);
|
||
ctx.strokeStyle = 'rgba(100, 255, 100, 0.15)';
|
||
ctx.stroke();
|
||
}
|
||
|
||
// 获取相机朝向(偏航角)——仅在战局内
|
||
var camHeading = Ready ? getCameraHeading() : null;
|
||
var playerAngle = radar.playerHeading;
|
||
|
||
// 自由视角激活时,直接用虚拟相机 yaw(取反:雷达旋转方向与相机yaw相反)
|
||
if (flActive && playerDir) {
|
||
playerAngle = -playerDir.yaw;
|
||
radar.playerHeading = playerAngle;
|
||
} else if (camHeading && camHeading.yaw !== undefined) {
|
||
// 使用相机偏航角作为雷达朝向
|
||
playerAngle = camHeading.yaw;
|
||
radar.playerHeading = playerAngle;
|
||
} else if (playerPos && radar.lastPlayerPos) {
|
||
// 备用方案:通过玩家位置变化计算朝向
|
||
var ddx = playerPos.x - radar.lastPlayerPos.x;
|
||
var ddy = playerPos.y - radar.lastPlayerPos.y;
|
||
var moveDist = Math.sqrt(ddx * ddx + ddy * ddy);
|
||
if (moveDist > 0.5) {
|
||
playerAngle = Math.atan2(ddx, ddy);
|
||
radar.playerHeading = playerAngle;
|
||
}
|
||
}
|
||
if (playerPos) radar.lastPlayerPos = playerPos;
|
||
radar._camHeading = camHeading; // 保存用于调试
|
||
|
||
// 绘制地形高度图
|
||
if (playerPos) drawTerrain(ctx, playerPos, playerAngle);
|
||
|
||
// 绘制所有坦克
|
||
var friendlyCount = 0, enemyCount = 0, deadCount = 0, botCount = 0;
|
||
for (var i = 0; i < count; i++) {
|
||
var tank = tankList.g1(i);
|
||
if (!tank) continue;
|
||
|
||
// 跳过玩家自己
|
||
if (playerTank && tank === playerTank) continue;
|
||
|
||
var pos = GetTankPosition(tank);
|
||
if (!pos || !playerPos) continue;
|
||
|
||
// 计算相对位置(x和y是水平面)
|
||
var dx = pos.x - playerPos.x;
|
||
var dy = pos.y - playerPos.y;
|
||
|
||
// 旋转到相机朝向(相机朝上=北)
|
||
var cosA = Math.cos(playerAngle);
|
||
var sinA = Math.sin(playerAngle);
|
||
var rx = dx * cosA - dy * sinA;
|
||
var ry = dx * sinA + dy * cosA;
|
||
|
||
// 缩放到雷达范围
|
||
var radarX = cx + (rx / radarRange) * radius;
|
||
var radarY = cy - (ry / radarRange) * radius; // 注意y轴反转(屏幕y向下)
|
||
|
||
// 限制在雷达方形内
|
||
if (radarX < cx - radius) radarX = cx - radius;
|
||
if (radarX > cx + radius) radarX = cx + radius;
|
||
if (radarY < cy - radius) radarY = cy - radius;
|
||
if (radarY > cy + radius) radarY = cy + radius;
|
||
|
||
// 死亡判断(同 3D透视):可见坦克 = ACTIVE(3) 或 SEMI_ACTIVE(2);其余直接跳过(不再画红叉)
|
||
// 辅助:血量归零也视为死亡(状态字段偶有滞后)
|
||
var tankState = GetTankState(tank);
|
||
var hp = GetTankHealth(tank);
|
||
if ((tankState !== 2 && tankState !== 3) || (hp && hp.current <= 0)) {
|
||
deadCount++;
|
||
continue;
|
||
}
|
||
|
||
// 判断友方/敌方
|
||
var friendly = IsFriendly(tank, playerTank);
|
||
var color = friendly ? '#4488ff' : '#ff4444';
|
||
if (friendly) friendlyCount++; else enemyCount++;
|
||
|
||
// 画坦克点:人机用「灰方块 + 黄框」,真人用「实心圆 + 白边」
|
||
var bot = (typeof IsBot === 'function') && IsBot(tank);
|
||
if (bot) botCount++;
|
||
if (bot) {
|
||
ctx.beginPath();
|
||
ctx.rect(radarX - 5, radarY - 5, 10, 10);
|
||
ctx.fillStyle = 'rgba(170,170,170,0.55)';
|
||
ctx.fill();
|
||
ctx.strokeStyle = '#ffd24a';
|
||
ctx.lineWidth = 1.5;
|
||
ctx.stroke();
|
||
} else {
|
||
ctx.beginPath();
|
||
ctx.arc(radarX, radarY, 5, 0, 2 * Math.PI);
|
||
ctx.fillStyle = color;
|
||
ctx.fill();
|
||
ctx.strokeStyle = '#ffffff';
|
||
ctx.lineWidth = 1;
|
||
ctx.stroke();
|
||
}
|
||
}
|
||
|
||
// 画玩家(中心,三角形朝上)
|
||
ctx.beginPath();
|
||
ctx.moveTo(cx, cy - 7);
|
||
ctx.lineTo(cx - 5, cy + 5);
|
||
ctx.lineTo(cx + 5, cy + 5);
|
||
ctx.closePath();
|
||
ctx.fillStyle = '#00ff00';
|
||
ctx.fill();
|
||
ctx.strokeStyle = '#ffffff';
|
||
ctx.lineWidth = 1;
|
||
ctx.stroke();
|
||
|
||
// 画北向指示
|
||
ctx.fillStyle = 'rgba(100, 255, 100, 0.8)';
|
||
ctx.font = '10px Arial';
|
||
ctx.textAlign = 'center';
|
||
ctx.fillText('N', cx, cy - radius + 12);
|
||
|
||
// 等待战局提示(ght 尚未捕获 / 不在战局)
|
||
if (!Ready) {
|
||
ctx.fillStyle = 'rgba(255, 220, 120, 0.95)';
|
||
ctx.font = 'bold 11px Arial';
|
||
ctx.textAlign = 'center';
|
||
ctx.fillText('等待战局…', cx, cy + 4);
|
||
}
|
||
|
||
// 信息文本
|
||
ctx.fillStyle = 'rgba(255, 255, 255, 0.7)';
|
||
ctx.font = '9px Arial';
|
||
ctx.textAlign = 'left';
|
||
ctx.fillText('Tanks: ' + count + ' (B:' + friendlyCount + ' R:' + enemyCount + ' X:' + deadCount + ' Bot:' + botCount + ')', 5, 12);
|
||
if (radar.playerFound) {
|
||
ctx.fillText('Player: ' + (playerTank ? playerTank.iz1_1 : '?'), 5, 24);
|
||
} else {
|
||
ctx.fillText('Player: NOT FOUND', 5, 24);
|
||
}
|
||
ctx.fillText('Cam: ' + (radar._camHeading ? 'OK' : 'NULL'), 5, 36);
|
||
ctx.fillText('Angle: ' + (radar.playerHeading * 180 / Math.PI).toFixed(0), 5, 48);
|
||
ctx.fillText('Range: ' + (radarRange / 100).toFixed(0) + 'm', 5, 60);
|
||
ctx.fillText('Frame: ' + radar.frameCount, 5, canvas.height - 16);
|
||
ctx.fillText('Alt+/-: zoom', 5, canvas.height - 5);
|
||
|
||
} catch(e) {
|
||
ctx.fillStyle = 'rgba(255, 0, 0, 0.8)';
|
||
ctx.font = '10px Arial';
|
||
ctx.textAlign = 'left';
|
||
ctx.fillText('Error: ' + e.message, 5, 15);
|
||
}
|
||
|
||
requestAnimationFrame(render);
|
||
}
|
||
|
||
// 启动渲染
|
||
requestAnimationFrame(render);
|
||
|
||
console.log('雷达已启动');
|
||
console.log('提示: window.__hornetRadar__.stop() 停止雷达');
|
||
console.log('提示: window.__hornetRadar__.canvas 获取Canvas元素');
|
||
}
|
||
|
||
var Tool = {
|
||
Start: function() {
|
||
if (window.__hornetRadar__ && window.__hornetRadar__.running) return;
|
||
_start();
|
||
},
|
||
Stop: function() {
|
||
if (window.__hornetRadar__) window.__hornetRadar__.stop();
|
||
},
|
||
Status: function() {
|
||
return (window.__hornetRadar__ && window.__hornetRadar__.running) ? '运行中' : '已停止';
|
||
},
|
||
GetRunning: function() {
|
||
return !!(window.__hornetRadar__ && window.__hornetRadar__.running);
|
||
},
|
||
SetRange: function(r) { if (window.__hornetRadar__) window.__hornetRadar__.setRange(r); },
|
||
GetRange: function() { return window.__hornetRadar__ ? window.__hornetRadar__.getRange() : 30000; },
|
||
SetOpacity: function(o) { if (window.__hornetRadar__) window.__hornetRadar__.setOpacity(o); },
|
||
GetOpacity: function() { return window.__hornetRadar__ ? window.__hornetRadar__.getOpacity() : 1.0; }
|
||
};
|
||
|
||
window.__hornetTools__ = window.__hornetTools__ || {};
|
||
window.__hornetTools__.Radar = Tool;
|
||
console.log('[3DTank] 雷达模块已加载');
|
||
})();
|
||
|
||
|
||
// ===== hornet_3d_wireframe.js =====
|
||
// ===== 3DTank3D透视 - 线框/描边双模式 =====
|
||
// 在浏览器控制台运行(需先运行 hook_gamemode.js)
|
||
// 功能:在全屏叠加层上绘制敌方坦克的3D模型
|
||
// 模式:
|
||
// wireframe - 完整线框(绘制所有边)
|
||
// outline - 屏幕空间轮廓描边(只画外轮廓,合并所有mesh节点)
|
||
// 切换:window.__hornet3DWireframe__.setMode('outline')
|
||
|
||
(function() {
|
||
function _start() {
|
||
console.log('===== 3DTank3D透视启动 =====');
|
||
|
||
// 允许在未进入战局 / ght 尚未捕获时启动:先出叠加层与状态行,模型等进入战局再出
|
||
if (window.__hornet3DWireframe__) {
|
||
window.__hornet3DWireframe__.stop();
|
||
console.log('已停止旧透视');
|
||
}
|
||
if (window.__hornet3DOutline__) {
|
||
window.__hornet3DOutline__.stop();
|
||
console.log('已停止旧描边透视');
|
||
}
|
||
|
||
var gm = window.__gameMode__ || null;
|
||
|
||
// ===== 当前模式 =====
|
||
var Mode = 'outline'; // 'wireframe' | 'outline'
|
||
try {
|
||
var __savedMode = localStorage.getItem('hornet_wireframe_mode');
|
||
if (__savedMode === 'wireframe' || __savedMode === 'outline') Mode = __savedMode;
|
||
} catch(e) {}
|
||
|
||
// ===== 线宽设置 =====
|
||
var LineWidth = 2;
|
||
try {
|
||
var __savedLW = localStorage.getItem('hornet_wireframe_linewidth');
|
||
if (__savedLW) {
|
||
var __n = parseFloat(__savedLW);
|
||
if (!isNaN(__n) && __n >= 0.5 && __n <= 8) LineWidth = __n;
|
||
}
|
||
} catch(e) {}
|
||
|
||
// ===== 渲染模式 =====
|
||
// 'team' - 队伍模式:所有坦克按队伍渲染(蓝=友方,红=敌方)
|
||
// 'whitelist' - 白名单:只有名单中的坦克渲染(按队伍颜色),其他不渲染
|
||
// 'blacklist' - 黑名单:名单中的坦克不渲染,其他按队伍颜色渲染
|
||
var HighlightMode = 'team';
|
||
try {
|
||
var __savedHM = localStorage.getItem('hornet_wireframe_highlight_mode');
|
||
if (__savedHM && ['team','whitelist','blacklist'].indexOf(__savedHM) >= 0) {
|
||
HighlightMode = __savedHM;
|
||
}
|
||
} catch(e) {}
|
||
|
||
// ===== 名单 =====
|
||
var HighlightList = [];
|
||
try {
|
||
var __savedHL = localStorage.getItem('hornet_highlight_list');
|
||
if (__savedHL) HighlightList = JSON.parse(__savedHL);
|
||
} catch(e) {}
|
||
function SaveHighlightList() {
|
||
try { localStorage.setItem('hornet_highlight_list', JSON.stringify(HighlightList)); } catch(e) {}
|
||
}
|
||
function IsInList(name) {
|
||
if (!name) return false;
|
||
for (var i = 0; i < HighlightList.length; i++) {
|
||
if (HighlightList[i] === name) return true;
|
||
}
|
||
return false;
|
||
}
|
||
// 判断坦克是否应渲染(根据 HighlightMode)
|
||
function ShouldRender(name) {
|
||
switch (HighlightMode) {
|
||
case 'team': return true; // 全部渲染
|
||
case 'whitelist': return IsInList(name); // 名单内渲染
|
||
case 'blacklist': return !IsInList(name); // 名单外渲染
|
||
default: return true;
|
||
}
|
||
}
|
||
|
||
// ===== 引用公共库(hornet_lib.js) =====
|
||
var lib = window.__hornetLib__;
|
||
if (!lib) {
|
||
console.error('[透视] 请先运行 hornet_lib.js');
|
||
return;
|
||
}
|
||
var teamFns = lib.LoadTeamFunctions();
|
||
var ZCt = teamFns.ZCt, m2Fn = teamFns.m2Fn;
|
||
|
||
// 公共函数别名(调用处不变)
|
||
var FindComponentByField = lib.FindComponentByField;
|
||
var GetTankPosition = lib.GetTankPosition;
|
||
var GetTankState = lib.GetTankState;
|
||
var IsTankAlive = lib.IsTankAlive;
|
||
var GetTankName = lib.GetTankName;
|
||
var GetTankHealth = lib.GetTankHealth;
|
||
function GetPlayerTank() { return lib.GetPlayerTank(gm); }
|
||
function IsFriendly(tank, playerTank) { return lib.IsFriendly(tank, playerTank, gm); }
|
||
function GetCameraInfo() {
|
||
// 自由视角激活时,用虚拟相机参数(位置/方向跟随鼠标)
|
||
var fl = window.__hornetFreeLook__;
|
||
if (fl && typeof fl.IsActive === 'function' && fl.IsActive()) {
|
||
var pos = fl.GetPosition();
|
||
var dir = fl.GetDirection();
|
||
if (pos && dir) {
|
||
return { pos: pos, yaw: dir.yaw, pitch: dir.pitch, fov: 1.0471976 };
|
||
}
|
||
}
|
||
return lib.GetCameraInfo(gm);
|
||
}
|
||
var Project3D = lib.Project3D;
|
||
var Project3DMatrix = lib.Project3DMatrix;
|
||
// 优先用相机矩阵投影(新版唯一可靠路径);自由视角虚拟相机没有矩阵时退回欧拉角投影
|
||
function ProjectPoint(pos, camInfo, screenW, screenH) {
|
||
if (camInfo && camInfo.matrix) {
|
||
var r = Project3DMatrix(camInfo, pos, screenW, screenH);
|
||
if (r) return r;
|
||
}
|
||
return Project3D(pos, camInfo.pos, camInfo.yaw, camInfo.pitch, camInfo.fov, screenW, screenH);
|
||
}
|
||
// 人机标记:同队内装备分数相同且 < 8000(lib.ScanBots 定期扫描 → IsBot 查询)
|
||
var ScanBots = lib.ScanBots, IsBot = lib.IsBot;
|
||
var LastBotScanAt = 0; // 人机扫描节流(约每 0.3 秒一次)
|
||
|
||
// ===== 热点优化:相机基每帧只算一次(避免逐顶点重复三角函数 + 对象分配)=====
|
||
var Cam = {
|
||
fx: 0, fy: 0, fz: 0, rx: 0, ry: 0, rz: 0, ux: 0, uy: 0, uz: 0,
|
||
focal: 1, halfW: 0, halfH: 0, px: 0, py: 0, pz: 0
|
||
};
|
||
function UpdateCam(camInfo, screenW, screenH) {
|
||
// 新 build:直接用相机自带的 world→clip 矩阵(g37_1),不依赖欧拉角约定
|
||
var M = camInfo.matrix;
|
||
if (M && typeof M.length === 'number' && M.length >= 16) {
|
||
Cam.M = M; Cam.hasM = true;
|
||
} else {
|
||
Cam.M = null; Cam.hasM = false;
|
||
}
|
||
var cp = Math.cos(camInfo.pitch), sp = Math.sin(camInfo.pitch);
|
||
var cy = Math.cos(camInfo.yaw), sy = Math.sin(camInfo.yaw);
|
||
Cam.fx = cp * (-sy); Cam.fy = cp * cy; Cam.fz = sp; // forward
|
||
Cam.rx = cy; Cam.ry = sy; Cam.rz = 0; // right
|
||
Cam.ux = Cam.ry * Cam.fz - Cam.rz * Cam.fy; // up = right × forward
|
||
Cam.uy = Cam.rz * Cam.fx - Cam.rx * Cam.fz;
|
||
Cam.uz = Cam.rx * Cam.fy - Cam.ry * Cam.fx;
|
||
Cam.focal = screenH / (2 * Math.tan(camInfo.fov / 2));
|
||
Cam.halfW = screenW / 2; Cam.halfH = screenH / 2;
|
||
Cam.screenW = screenW; Cam.screenH = screenH;
|
||
Cam.px = camInfo.pos.x; Cam.py = camInfo.pos.y; Cam.pz = camInfo.pos.z;
|
||
}
|
||
|
||
// 顶点缓冲(全局复用,杜绝每帧每顶点对象分配)
|
||
var VB_Wx = null, VB_Wy = null, VB_Wz = null; // 世界坐标(法线用)
|
||
var VB_Sx = null, VB_Sy = null, VB_Ok = null; // 屏幕坐标 + 可投影标志
|
||
function EnsureVerts(n) {
|
||
if (!VB_Wx || VB_Wx.length < n) {
|
||
var cap = Math.max(n, 2048);
|
||
VB_Wx = new Float32Array(cap); VB_Wy = new Float32Array(cap); VB_Wz = new Float32Array(cap);
|
||
VB_Sx = new Float32Array(cap); VB_Sy = new Float32Array(cap);
|
||
VB_Ok = new Uint8Array(cap);
|
||
}
|
||
}
|
||
|
||
// 变换 + 投影一个 mesh 节点的全部顶点到复用缓冲,返回顶点数
|
||
function PrepareNodeVerts(meshData, worldMatrix, pos) {
|
||
var positions = meshData.positions;
|
||
var stride = meshData.stride || 3;
|
||
var vc = Math.floor(positions.length / stride);
|
||
EnsureVerts(vc);
|
||
var m0 = worldMatrix[0], m1 = worldMatrix[1], m2 = worldMatrix[2],
|
||
m4 = worldMatrix[4], m5 = worldMatrix[5], m6 = worldMatrix[6],
|
||
m8 = worldMatrix[8], m9 = worldMatrix[9], m10 = worldMatrix[10],
|
||
m12 = worldMatrix[12], m13 = worldMatrix[13], m14 = worldMatrix[14];
|
||
var px = pos.x, py = pos.y, pz = pos.z;
|
||
// 新 build:矩阵投影(world→clip),完全跟随引擎自己的相机矩阵
|
||
if (Cam.hasM) {
|
||
var M = Cam.M;
|
||
var mm0 = M[0], mm1 = M[1], mm3 = M[3],
|
||
mm4 = M[4], mm5 = M[5], mm7 = M[7],
|
||
mm8 = M[8], mm9 = M[9], mm11 = M[11],
|
||
mm12 = M[12], mm13 = M[13], mm15 = M[15];
|
||
var sw = Cam.screenW, sh = Cam.screenH;
|
||
for (var v2 = 0; v2 < vc; v2++) {
|
||
var b2x = positions[v2 * stride], b2y = positions[v2 * stride + 1], b2z = positions[v2 * stride + 2];
|
||
var w2x = m0 * b2x + m4 * b2y + m8 * b2z + m12;
|
||
var w2y = m1 * b2x + m5 * b2y + m9 * b2z + m13;
|
||
var w2z = m2 * b2x + m6 * b2y + m10 * b2z + m14;
|
||
VB_Wx[v2] = w2x; VB_Wy[v2] = w2y; VB_Wz[v2] = w2z;
|
||
var dd2x = w2x - px, dd2y = w2y - py, dd2z = w2z - pz;
|
||
if (dd2x * dd2x + dd2y * dd2y + dd2z * dd2z > 6250000) { VB_Ok[v2] = 0; continue; }
|
||
var ccx = mm0 * w2x + mm4 * w2y + mm8 * w2z + mm12;
|
||
var ccy = mm1 * w2x + mm5 * w2y + mm9 * w2z + mm13;
|
||
var ccw = mm3 * w2x + mm7 * w2y + mm11 * w2z + mm15;
|
||
if (!(ccw > 0.0001)) { VB_Ok[v2] = 0; continue; }
|
||
VB_Sx[v2] = (ccx / ccw * 0.5 + 0.5) * sw;
|
||
VB_Sy[v2] = (1 - (ccy / ccw * 0.5 + 0.5)) * sh;
|
||
VB_Ok[v2] = 1;
|
||
}
|
||
return vc;
|
||
}
|
||
var cfx = Cam.fx, cfy = Cam.fy, cfz = Cam.fz;
|
||
var crx = Cam.rx, cry = Cam.ry, crz = Cam.rz;
|
||
var cux = Cam.ux, cuy = Cam.uy, cuz = Cam.uz;
|
||
var focal = Cam.focal, halfW = Cam.halfW, halfH = Cam.halfH;
|
||
var cpx = Cam.px, cpy = Cam.py, cpz = Cam.pz;
|
||
for (var v = 0; v < vc; v++) {
|
||
var bx = positions[v * stride], by = positions[v * stride + 1], bz = positions[v * stride + 2];
|
||
var wx = m0 * bx + m4 * by + m8 * bz + m12;
|
||
var wy = m1 * bx + m5 * by + m9 * bz + m13;
|
||
var wz = m2 * bx + m6 * by + m10 * bz + m14;
|
||
VB_Wx[v] = wx; VB_Wy[v] = wy; VB_Wz[v] = wz;
|
||
var ddx = wx - px, ddy = wy - py, ddz = wz - pz;
|
||
if (ddx * ddx + ddy * ddy + ddz * ddz > 6250000) { VB_Ok[v] = 0; continue; }
|
||
var relX = wx - cpx, relY = wy - cpy, relZ = wz - cpz;
|
||
var camZ = relX * cfx + relY * cfy + relZ * cfz;
|
||
if (camZ <= 0.1) { VB_Ok[v] = 0; continue; }
|
||
var camX = relX * crx + relY * cry + relZ * crz;
|
||
var camY = relX * cux + relY * cuy + relZ * cuz;
|
||
VB_Sx[v] = halfW + (camX / camZ) * focal;
|
||
VB_Sy[v] = halfH - (camY / camZ) * focal;
|
||
VB_Ok[v] = 1;
|
||
}
|
||
return vc;
|
||
}
|
||
|
||
// ===== 皮肤组件(新旧差异修正) =====
|
||
// 新版(main.7cfa4058):
|
||
// skin 组件(类 eUt)构造后由 j16r 把皮肤对象存到 **组件自己的 g159_1**,
|
||
// 皮肤对象.g17r_1 = 车身部件表;每个部件 part.j17r_1 是**单个 BP 节点**(旧版是节点列表)。
|
||
// 炮塔根节点 = skinComp.a17q_1(weaponSkin 组件 LUt.u11p() = e13a().a17q_1,e13a 取的就是 skin 组件)。
|
||
// 旧写法(w14w_1 / n11c_1 / m17a_1)在新版已被无关类占用(w14w_1=SetReverseBackTurn、m17a_1=数值参数),不再可用。
|
||
var SkinCompCache = new WeakMap();
|
||
function GetSkinComponent(entity) {
|
||
if (!entity) return null;
|
||
var Cached = SkinCompCache.get(entity);
|
||
if (Cached) return Cached;
|
||
var comp = FindComponentByField(entity, 'g159_1');
|
||
if (!comp) comp = FindComponentByField(entity, 'a17q_1');
|
||
// 兜底:按结构找 —— 组件字段值是"皮肤对象"(含 g17r_1 部件表)
|
||
if (!comp) {
|
||
try {
|
||
entity.qz1_1.s2a_1.forEach(function (VL) {
|
||
if (comp) return;
|
||
var C = (VL && typeof VL.g1 === 'function') ? VL.g1(0) : VL;
|
||
if (!C) return;
|
||
for (var K in C) {
|
||
var V = C[K];
|
||
if (V && typeof V === 'object' && V.g17r_1 && typeof V.g17r_1.e1 === 'function') { comp = C; return; }
|
||
}
|
||
});
|
||
} catch (e) {}
|
||
}
|
||
if (comp) SkinCompCache.set(entity, comp);
|
||
return comp;
|
||
}
|
||
|
||
// 取车身皮肤对象(含 g17r_1 部件表);stats 用于诊断来源
|
||
function GetHullSkin(tank, stats) {
|
||
var comp = GetSkinComponent(tank);
|
||
if (!comp) { if (stats) stats.hullSource = '无皮肤组件'; return null; }
|
||
if (comp.g159_1 && comp.g159_1.g17r_1) { if (stats) stats.hullSource = 'g159_1'; return comp.g159_1; }
|
||
if (stats) stats.hullSource = '有组件但无皮肤';
|
||
return null;
|
||
}
|
||
|
||
// 炮塔 BP 根节点
|
||
// 新版:weaponSkin 组件(类 LUt,构造首字段 a11p_1)里 e11p_1 = CUt 包装,其 a17q_1 = 炮塔节点
|
||
function GetTurretNode(entity) {
|
||
var wp = FindComponentByField(entity, 'a11p_1');
|
||
if (wp) {
|
||
if (wp.e11p_1 && wp.e11p_1.a17q_1) return wp.e11p_1.a17q_1;
|
||
if (typeof wp.u11p === 'function') { try { return wp.u11p(); } catch (e) {} }
|
||
}
|
||
var comp = GetSkinComponent(entity);
|
||
if (comp && comp.a17q_1) return comp.a17q_1;
|
||
return null;
|
||
}
|
||
|
||
// 兼容两种集合:引擎列表类(e1/g1)与迭代器(t/u/v)
|
||
function ForEachInList(list, fn) {
|
||
if (!list) return;
|
||
try {
|
||
if (typeof list.e1 === 'function' && typeof list.g1 === 'function') {
|
||
var n = list.e1();
|
||
for (var i = 0; i < n; i++) fn(list.g1(i));
|
||
return;
|
||
}
|
||
if (typeof list.t === 'function') {
|
||
for (var it = list.t(); it.u();) fn(it.v());
|
||
}
|
||
} catch (e) {}
|
||
}
|
||
|
||
// ===== 从GPU读取顶点位置数据(新版结构) =====
|
||
// 新旧差异(main.7cfa4058):
|
||
// Tp(类 sm): x4w_1 = 顶点组列表,每组 { h3x_1: 顶点缓冲资源 J$, i3x_1: 属性布局 }
|
||
// J$: v3x_1 = GPU 缓冲包装(kC,d75_1 = 原生 WebGLBuffer),w3x_1 = 顶点数
|
||
// 设备:V$.s3h_1 = GL 包装器 $C,$C.f75_1 = 原生 WebGL2RenderingContext
|
||
// ⚠ 旧字段 d3x_1 / s3h_1.b74_1 / v3x_1.z73_1 在新版分别是"渲染参数 / 雾色 uniform / uniform 槽位",全错
|
||
function GetGlFromResource(Res) {
|
||
if (!Res) return null;
|
||
var Dev = Res.s3h_1;
|
||
return (Dev && Dev.f75_1) ? Dev.f75_1 : null;
|
||
}
|
||
|
||
// 把引擎的 CPU 端缓冲区包装转成 TypedArray
|
||
// J$.x3x_1 / Y$.p3x_1 = 未上传时的原始数据(上传后置 null)
|
||
// 包装接口:v2u()=底层视图,g2t()=元素偏移,t2t()=元素个数
|
||
function ToTypedArray(Src, Kind) {
|
||
if (!Src) return null;
|
||
try {
|
||
if (typeof Src.v2u === 'function') {
|
||
var V = Src.v2u();
|
||
var Start = typeof Src.g2t === 'function' ? Src.g2t() : 0;
|
||
var Count = typeof Src.t2t === 'function' ? Src.t2t() : (V && V.length);
|
||
if (ArrayBuffer.isView(V) && Count > 0) {
|
||
var ByteOff = V.byteOffset + Start * V.BYTES_PER_ELEMENT;
|
||
if (Kind === 'u32') return new Uint32Array(V.buffer, ByteOff, Count);
|
||
if (Kind === 'u16') return new Uint16Array(V.buffer, ByteOff, Count);
|
||
return new Float32Array(V.buffer, ByteOff, Count);
|
||
}
|
||
}
|
||
if (ArrayBuffer.isView(Src)) {
|
||
if (Kind === 'u16') return (Src instanceof Uint16Array) ? Src : new Uint16Array(Src.buffer, Src.byteOffset, Src.byteLength >> 1);
|
||
if (Kind === 'u32') return (Src instanceof Uint32Array) ? Src : new Uint32Array(Src.buffer, Src.byteOffset, Src.byteLength >> 2);
|
||
return (Src instanceof Float32Array) ? Src : new Float32Array(Src.buffer, Src.byteOffset, Src.byteLength >> 2);
|
||
}
|
||
if (typeof Src.length === 'number' && Src.length > 0) {
|
||
return (Kind === 'u16') ? Uint16Array.from(Src) : (Kind === 'u32' ? Uint32Array.from(Src) : Float32Array.from(Src));
|
||
}
|
||
} catch (e) {}
|
||
return null;
|
||
}
|
||
|
||
function ReadVertexPositionsFromTp(tp) {
|
||
if (!tp || !tp.x4w_1 || typeof tp.x4w_1.e1 !== 'function' || tp.x4w_1.e1() === 0) return null;
|
||
var group = tp.x4w_1.g1(0);
|
||
if (!group) return null;
|
||
var Buf = group.h3x_1; // J$
|
||
if (!Buf) return null;
|
||
// ① 优先 CPU 端原始数据:未上传 GPU 的 mesh 也能读到(v3x_1.d75_1 此时为 null)
|
||
var Cpu = ToTypedArray(Buf.x3x_1, 'f32');
|
||
if (Cpu && Cpu.length) return Cpu;
|
||
// ② GPU 回读(引擎已上传并释放 CPU 数据时)
|
||
var gl = GetGlFromResource(Buf);
|
||
var webglBuf = Buf.v3x_1 && Buf.v3x_1.d75_1;
|
||
if (!gl || !webglBuf) return null;
|
||
gl.bindBuffer(gl.ARRAY_BUFFER, webglBuf);
|
||
var bufSize = gl.getBufferParameter(gl.ARRAY_BUFFER, gl.BUFFER_SIZE);
|
||
if (bufSize <= 0) return null;
|
||
var f32 = new Float32Array(bufSize / 4);
|
||
gl.getBufferSubData(gl.ARRAY_BUFFER, 0, f32);
|
||
return f32;
|
||
}
|
||
|
||
// ===== 从GPU读取索引数据 =====
|
||
// y4w_1 = 索引列表,每项 { q4w_1: 索引缓冲资源 Y$ }(旧版是 il.u3p_1)
|
||
function ReadIndicesFromTp(tp) {
|
||
if (!tp.y4w_1 || typeof tp.y4w_1.e1 !== 'function') return null;
|
||
var meshCount = tp.y4w_1.e1();
|
||
if (meshCount === 0) return null;
|
||
var allIndices = [];
|
||
for (var m = 0; m < meshCount; m++) {
|
||
var il = tp.y4w_1.g1(m);
|
||
if (!il) continue;
|
||
var IdxBuf = il.q4w_1 || il.u3p_1;
|
||
if (!IdxBuf) continue;
|
||
// ① CPU 端索引数据(Y$.p3x_1),未上传时也能用
|
||
var Cpu = ToTypedArray(IdxBuf.p3x_1, 'u16');
|
||
if (Cpu && Cpu.length) { allIndices.push(Cpu); continue; }
|
||
// ② GPU 回读
|
||
var gl = GetGlFromResource(IdxBuf);
|
||
var webglBuf = IdxBuf.n3x_1 && IdxBuf.n3x_1.d75_1;
|
||
if (!gl || !webglBuf) continue;
|
||
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, webglBuf);
|
||
var bufSize = gl.getBufferParameter(gl.ELEMENT_ARRAY_BUFFER, gl.BUFFER_SIZE);
|
||
if (bufSize <= 0) continue;
|
||
var indices = new Uint16Array(bufSize / 2);
|
||
gl.getBufferSubData(gl.ELEMENT_ARRAY_BUFFER, 0, indices);
|
||
allIndices.push(indices);
|
||
}
|
||
return allIndices;
|
||
}
|
||
|
||
// ===== GPU 数据缓存(按 Tp 资源引用,避免重复 GPU 读取) =====
|
||
var GpuDataCache = new Map();
|
||
var GpuCacheFrameCount = 0;
|
||
var GpuCacheClearInterval = 1800; // 定期清 GPU 数据缓存(getBufferSubData 会停顿 GPU,太频繁会周期性卡顿)
|
||
|
||
var StrideDiagPrinted = new WeakSet();
|
||
var GpuFailLogged = new WeakSet();
|
||
function GetGpuData(tp) {
|
||
if (!tp) return null;
|
||
if (GpuDataCache.has(tp)) return GpuDataCache.get(tp);
|
||
var positions = ReadVertexPositionsFromTp(tp);
|
||
var indices = ReadIndicesFromTp(tp);
|
||
if (positions && indices && indices.length > 0) {
|
||
// 计算最大索引值,推断顶点 stride
|
||
var maxIdx = 0;
|
||
for (var i = 0; i < indices.length; i++) {
|
||
var idxArr = indices[i];
|
||
for (var j = 0; j < idxArr.length; j++) {
|
||
if (idxArr[j] > maxIdx) maxIdx = idxArr[j];
|
||
}
|
||
}
|
||
var stride = 0;
|
||
// 优先用引擎记录的顶点数反推 stride(J$.w3x_1 = 顶点数)
|
||
try {
|
||
var G0 = tp.x4w_1.g1(0);
|
||
var Vc = G0 && G0.h3x_1 && G0.h3x_1.w3x_1;
|
||
if (Vc > 0 && positions.length % Vc === 0) stride = Math.round(positions.length / Vc);
|
||
} catch (e) {}
|
||
if (!stride || stride < 3 || stride > 20) {
|
||
stride = 3;
|
||
var vc3 = Math.floor(positions.length / 3);
|
||
if (maxIdx >= vc3) {
|
||
// stride=3 顶点数不够,尝试常见 interleaved 布局
|
||
var candidates = [8, 11, 5, 7, 14];
|
||
for (var c = 0; c < candidates.length; c++) {
|
||
var vc = Math.floor(positions.length / candidates[c]);
|
||
if (maxIdx < vc) { stride = candidates[c]; break; }
|
||
}
|
||
}
|
||
}
|
||
var data = { positions: positions, indices: indices, stride: stride };
|
||
GpuDataCache.set(tp, data);
|
||
if (!StrideDiagPrinted.has(tp)) {
|
||
StrideDiagPrinted.add(tp);
|
||
console.log('[透视][GPU数据] stride=' + stride + ', 缓冲区=' + positions.length + ' float, 最大索引=' + maxIdx + ', 顶点数=' + Math.floor(positions.length / stride));
|
||
}
|
||
return data;
|
||
}
|
||
// 采集失败诊断(每个资源只打一次):直接指出卡在顶点、索引还是 GL 上下文
|
||
if (!GpuFailLogged.has(tp)) {
|
||
GpuFailLogged.add(tp);
|
||
var D = {};
|
||
try {
|
||
var G = tp.x4w_1 && typeof tp.x4w_1.g1 === 'function' ? tp.x4w_1.g1(0) : null;
|
||
var B = G && G.h3x_1;
|
||
var Dev = B && B.s3h_1;
|
||
var I = tp.y4w_1 && typeof tp.y4w_1.e1 === 'function' && tp.y4w_1.e1() > 0 ? tp.y4w_1.g1(0) : null;
|
||
var IB = I && (I.q4w_1 || I.u3p_1);
|
||
D = {
|
||
顶点组: tp.x4w_1 && tp.x4w_1.e1 ? tp.x4w_1.e1() : -1,
|
||
顶点缓冲资源: !!B,
|
||
缓冲包装: !!(B && B.v3x_1),
|
||
原生Buffer: !!(B && B.v3x_1 && B.v3x_1.d75_1),
|
||
设备: !!Dev,
|
||
原生GL: !!(Dev && Dev.f75_1),
|
||
顶点数: B ? B.w3x_1 : -1,
|
||
位置长度: positions ? positions.length : -1,
|
||
索引组: tp.y4w_1 && tp.y4w_1.e1 ? tp.y4w_1.e1() : -1,
|
||
索引资源: !!IB,
|
||
索引Buffer: !!(IB && IB.n3x_1 && IB.n3x_1.d75_1),
|
||
索引个数: indices ? indices.length : -1
|
||
};
|
||
} catch (e) {}
|
||
console.warn('[透视][GPU] 采集失败', D);
|
||
}
|
||
return null;
|
||
}
|
||
|
||
// ===== 递归收集mesh节点(每帧重新获取 Bp 节点引用) =====
|
||
// Seen:跨「车身根 / 部件表 / 炮塔 / 扫描」多路径去重,避免同一节点被画多次
|
||
function CollectMeshNodes(bpNode, nodes, depth, group, stats, Seen) {
|
||
if (!bpNode || depth > 12) return;
|
||
if (Seen) {
|
||
if (Seen.has(bpNode)) return;
|
||
Seen.add(bpNode);
|
||
}
|
||
if (stats) stats.seen++;
|
||
var name = bpNode.g35_1 || '';
|
||
if (bpNode.v3m_1) {
|
||
if (stats) stats.hasMesh++;
|
||
var gpuData = GetGpuData(bpNode.v3m_1);
|
||
if (gpuData) {
|
||
if (stats) stats.gpuOk++;
|
||
nodes.push({
|
||
name: name, node: bpNode, tp: bpNode.v3m_1,
|
||
positions: gpuData.positions, indices: gpuData.indices, stride: gpuData.stride, group: group
|
||
});
|
||
} else if (stats) stats.gpuFail++;
|
||
}
|
||
if (bpNode.u4x_1 && typeof bpNode.u4x_1.e1 === 'function') {
|
||
try {
|
||
var lodCount = bpNode.u4x_1.e1();
|
||
for (var lod = 0; lod < lodCount && lod < 1; lod++) {
|
||
var lodLevel = bpNode.u4x_1.g1(lod);
|
||
if (!lodLevel) continue;
|
||
var lodTp = lodLevel.v3m_1 || lodLevel.v3m_1;
|
||
if (!lodTp) continue;
|
||
var lodGpuData = GetGpuData(lodTp);
|
||
if (lodGpuData) {
|
||
if (stats) stats.gpuOk++;
|
||
nodes.push({
|
||
name: name + '_lod' + lod, node: bpNode, tp: lodTp,
|
||
positions: lodGpuData.positions, indices: lodGpuData.indices, stride: lodGpuData.stride, group: group
|
||
});
|
||
}
|
||
}
|
||
} catch(e) {}
|
||
}
|
||
// 子节点:新版是 r35_1(zl 基类:this.r35_1 = new lo(),r3k 里递归子节点)
|
||
// 旧版是 p35_1;新版 p35_1 已变成"世界矩阵",绝不能当子节点列表
|
||
var Children = (bpNode.r35_1 && typeof bpNode.r35_1.e1 === 'function') ? bpNode.r35_1 :
|
||
((bpNode.p35_1 && typeof bpNode.p35_1.e1 === 'function') ? bpNode.p35_1 : null);
|
||
if (Children) {
|
||
ForEachInList(Children, function (child) {
|
||
if (child) CollectMeshNodes(child, nodes, depth + 1, group, stats, Seen);
|
||
});
|
||
}
|
||
}
|
||
|
||
// 兜底采集:完全不依赖皮肤组件结构,直接扫实体组件的字段值,找 3D 节点
|
||
// 新版节点(zl 基类)特征:g35_1 = 名字字符串,p35_1 = 世界矩阵(长度≥16),r35_1 = 子节点列表
|
||
function IsSceneNode(V) {
|
||
return !!(V && typeof V === 'object' &&
|
||
typeof V.g35_1 === 'string' &&
|
||
V.p35_1 && typeof V.p35_1.length === 'number' && V.p35_1.length >= 16 &&
|
||
V.r35_1 && typeof V.r35_1 === 'object');
|
||
}
|
||
|
||
// 只遍历普通对象:TypedArray / 数组 / Map / Set 的 for-in 会产出成千上万个键,必须跳过
|
||
function IsPlainObj(V) {
|
||
if (!V || typeof V !== 'object') return false;
|
||
if (Array.isArray(V) || ArrayBuffer.isView(V)) return false;
|
||
if (typeof Map !== 'undefined' && V instanceof Map) return false;
|
||
if (typeof Set !== 'undefined' && V instanceof Set) return false;
|
||
return true;
|
||
}
|
||
|
||
// 扫描结果按「组件字段路径」缓存(不缓存节点对象:引擎会重建节点树,
|
||
// 但字段名与组件实例稳定,路径每次都能解析到当前节点)
|
||
var NodeSrcCache = new WeakMap(); // entity -> [{ Comp, Path: [K1, K2, K3] }]
|
||
var ScanTick = 0;
|
||
|
||
function ResolveSrc(Src) {
|
||
var V = Src.Comp;
|
||
for (var i = 0; i < Src.Path.length; i++) {
|
||
if (!V || typeof V !== 'object') return null;
|
||
V = V[Src.Path[i]];
|
||
}
|
||
return V;
|
||
}
|
||
|
||
function ScanMeshNodes(tank, nodes, stats, Seen) {
|
||
Seen = Seen || new WeakSet();
|
||
// ① 有缓存路径:直接解析(开销极小);每 300 帧强制重扫一次,捡起后加载的部件
|
||
ScanTick++;
|
||
var NeedRescan = (ScanTick % 300) === 0;
|
||
var Cached = NodeSrcCache.get(tank);
|
||
if (!NeedRescan && Cached && Cached.length) {
|
||
var AllOk = true;
|
||
for (var c = 0; c < Cached.length; c++) {
|
||
var V0 = ResolveSrc(Cached[c]);
|
||
if (!IsSceneNode(V0)) { AllOk = false; break; }
|
||
if (!Seen.has(V0)) CollectMeshNodes(V0, nodes, 0, 'scan', stats, Seen);
|
||
}
|
||
if (AllOk) return;
|
||
}
|
||
// ② 缓存失效或首次:全量扫描并重建路径表
|
||
var Srcs = [];
|
||
function Try(V, Comp, Path) {
|
||
if (!IsSceneNode(V) || Seen.has(V)) return;
|
||
CollectMeshNodes(V, nodes, 0, 'scan', stats, Seen);
|
||
Srcs.push({ Comp: Comp, Path: Path });
|
||
}
|
||
try {
|
||
tank.qz1_1.s2a_1.forEach(function (VL) {
|
||
var comp = (VL && typeof VL.g1 === 'function') ? VL.g1(0) : VL;
|
||
if (!IsPlainObj(comp)) return;
|
||
for (var K in comp) {
|
||
var V = comp[K];
|
||
if (!IsPlainObj(V)) continue;
|
||
if (IsSceneNode(V)) { Try(V, comp, [K]); continue; }
|
||
// 深度 2/3:节点常包在皮肤对象/包装对象里(如 skin.h159_1、weaponSkin.e11p_1.a17q_1)
|
||
try {
|
||
for (var K2 in V) {
|
||
var V2 = V[K2];
|
||
if (!IsPlainObj(V2)) continue;
|
||
if (IsSceneNode(V2)) { Try(V2, comp, [K, K2]); continue; }
|
||
for (var K3 in V2) {
|
||
var V3 = V2[K3];
|
||
if (IsPlainObj(V3)) Try(V3, comp, [K, K2, K3]);
|
||
}
|
||
}
|
||
} catch (e) {}
|
||
}
|
||
});
|
||
} catch (e) {}
|
||
NodeSrcCache.set(tank, Srcs);
|
||
}
|
||
|
||
// 结果级去重:同一 mesh 资源 + 同一节点名,只保留一份
|
||
// 注意:**不要把世界坐标写进键**——坦克移动时两套实例的平移会差一帧,
|
||
// 键随位置变化会导致「这帧去重、下帧不去重」→ 模型闪烁。
|
||
var TpIds = new WeakMap();
|
||
var TpNextId = 1;
|
||
function DedupNodes(nodes) {
|
||
if (nodes.length < 2) return nodes;
|
||
var Keys = new Set();
|
||
var Out = [];
|
||
for (var i = 0; i < nodes.length; i++) {
|
||
var N = nodes[i];
|
||
var Tp = N.tp;
|
||
var Id = TpIds.get(Tp);
|
||
if (!Id) { Id = TpNextId++; TpIds.set(Tp, Id); }
|
||
var Key = Id + ':' + N.name;
|
||
if (Keys.has(Key)) continue;
|
||
Keys.add(Key);
|
||
Out.push(N);
|
||
}
|
||
return Out;
|
||
}
|
||
|
||
// ===== 提取坦克的mesh数据 =====
|
||
// 采集策略:**单一路径优先**,绝不把两条路径的结果混在一起(混在一起就是"复制身体")
|
||
// ① 皮肤组件 h159_1(实际渲染树根)→ ② 部件表 → ③ 组件字段扫描(兜底)
|
||
function GetTankMeshData(tank, stats) {
|
||
var nodes = [];
|
||
var Seen = new WeakSet();
|
||
var skinComp = GetSkinComponent(tank);
|
||
var skin = (skinComp && skinComp.g159_1 && skinComp.g159_1.g17r_1) ? skinComp.g159_1 : null;
|
||
if (stats) {
|
||
stats.hullSource = skin ? 'g159_1' : (skinComp ? '有组件但无皮肤' : '无皮肤组件');
|
||
stats.hullParts = (skin && typeof skin.g17r_1.e1 === 'function') ? skin.g17r_1.e1() : -1;
|
||
}
|
||
// ① 主路径:车身根节点(包含全部部件节点)
|
||
if (skinComp && skinComp.h159_1) {
|
||
try { CollectMeshNodes(skinComp.h159_1, nodes, 0, 'hull', stats ? stats.hull : null, Seen); } catch (e) {}
|
||
}
|
||
// ② 根节点采不到才退回部件表
|
||
if (nodes.length === 0 && skin) {
|
||
try {
|
||
ForEachInList(skin.g17r_1, function (part) {
|
||
if (!part) return;
|
||
var j = part.j17r_1; // 新版:单个 BP 节点;旧版:节点列表
|
||
if (!j) return;
|
||
if (typeof j === 'object' && typeof j.e1 === 'function' && typeof j.g1 === 'function') {
|
||
ForEachInList(j, function (bp) {
|
||
if (bp) CollectMeshNodes(bp, nodes, 0, 'hull', stats ? stats.hull : null, Seen);
|
||
});
|
||
} else {
|
||
CollectMeshNodes(j, nodes, 0, 'hull', stats ? stats.hull : null, Seen);
|
||
}
|
||
});
|
||
} catch(e) {}
|
||
}
|
||
// ③ 结构扫描:无条件补充漏采(车身根/部件表往往只覆盖一部分)
|
||
// 重复实例(皮肤树/场景树同 tp 同名)由 DedupNodes 收掉,不会再出现"两个身体"
|
||
try { ScanMeshNodes(tank, nodes, stats ? stats.scan : null, Seen); } catch (e) {}
|
||
// ④ 炮塔(独立于车身根;重复实例由 DedupNodes 收掉)
|
||
var turretNode = GetTurretNode(tank);
|
||
if (stats) stats.turretComp = !!turretNode;
|
||
if (turretNode) {
|
||
if (stats) stats.turretRoot = true;
|
||
try { CollectMeshNodes(turretNode, nodes, 0, 'turret', stats ? stats.turret : null, Seen); } catch(e) {}
|
||
}
|
||
return DedupNodes(nodes);
|
||
}
|
||
|
||
// ===== 残留节点检测(世界矩阵"冻结")→ 拖拽跟随 =====
|
||
// 现象:实体已移动,但原处仍留着线框。
|
||
// 原因:引擎只为"本帧实际参与渲染"的节点更新世界矩阵,被裁剪/脱离的节点矩阵停在最后位置。
|
||
// 处理:检测到「节点矩阵平移长时间不动、而坦克在动」时**不再丢弃**(丢弃会让整辆模型只剩一个点),
|
||
// 而是用「冻结时刻的相对偏移 + 坦克当前位置」平移这份冻结矩阵,让它跟着坦克走:
|
||
// 既不在旧位置留残留,也不会因为引擎不更新矩阵而丢掉模型。
|
||
// 权衡:拖拽期间朝向沿用最后一次更新(位置正确、朝向可能滞后),对透视足够用。
|
||
var NodeMatrixHistory = new WeakMap(); // WeakMap:不持有节点强引用(引擎会重建节点树)
|
||
var TankPrevPos = new Map(); // tankId → 上一帧位置(含位移滑动平均)
|
||
var FrozenFrames = 20; // 连续不动帧数阈值(仅在坦克本身移动时才累计)
|
||
var NodeMatrixScratch = new Float32Array(16);
|
||
|
||
function IsNodeValid(bpNode, pos, tankIsMoving, diagStats) {
|
||
// 世界矩阵:新版是 p35_1(zl 基类构造里 = hl(),s3k/t3k/u3k 读 p35_1[12..14])
|
||
// 旧版是 n35_1;新版 n35_1 已变成布尔标志
|
||
var M = bpNode.p35_1;
|
||
if (!M || typeof M.length !== 'number' || M.length < 16) {
|
||
var OldM = bpNode.n35_1;
|
||
M = (OldM && typeof OldM.length === 'number' && OldM.length >= 16) ? OldM : null;
|
||
}
|
||
if (!M) return null;
|
||
if (diagStats) diagStats.totalNodes++;
|
||
|
||
var Hist = NodeMatrixHistory.get(bpNode);
|
||
if (!Hist) {
|
||
NodeMatrixHistory.set(bpNode, { x: M[12], y: M[13], z: M[14], still: 0, off: null });
|
||
} else {
|
||
var dx = M[12] - Hist.x, dy = M[13] - Hist.y, dz = M[14] - Hist.z;
|
||
if (dx * dx + dy * dy + dz * dz > 0.01) {
|
||
Hist.x = M[12]; Hist.y = M[13]; Hist.z = M[14];
|
||
Hist.still = 0;
|
||
Hist.off = null; // 矩阵恢复更新 → 恢复正常绘制
|
||
} else if (tankIsMoving) {
|
||
Hist.still++;
|
||
if (Hist.still >= FrozenFrames) {
|
||
if (!Hist.off) Hist.off = { x: M[12] - pos.x, y: M[13] - pos.y, z: M[14] - pos.z };
|
||
for (var k = 0; k < 16; k++) NodeMatrixScratch[k] = M[k];
|
||
NodeMatrixScratch[12] = pos.x + Hist.off.x;
|
||
NodeMatrixScratch[13] = pos.y + Hist.off.y;
|
||
NodeMatrixScratch[14] = pos.z + Hist.off.z;
|
||
if (diagStats) diagStats.draggedNodes++;
|
||
return NodeMatrixScratch; // 冻结但跟随坦克
|
||
}
|
||
} else {
|
||
Hist.still = 0;
|
||
}
|
||
}
|
||
|
||
// 位置检查(节点世界矩阵平移与坦克位置相差过大则丢弃)—— 只对"未冻结"的节点做
|
||
var nodeDx = M[12] - pos.x, nodeDy = M[13] - pos.y, nodeDz = M[14] - pos.z;
|
||
if (nodeDx * nodeDx + nodeDy * nodeDy + nodeDz * nodeDz > 4000000) {
|
||
if (diagStats) diagStats.filteredNodes++;
|
||
return null;
|
||
}
|
||
|
||
return M;
|
||
}
|
||
|
||
// ===== 线框模式:绘制单个节点的所有边 =====
|
||
// 顶点数据来自 PrepareNodeVerts 的复用缓冲;边去重用数值键 Set(避免字符串分配)
|
||
var _WfEdgeSet = new Set();
|
||
|
||
function WfAddEdge(ctx, a, b) {
|
||
var lo = a < b ? a : b, hi = a < b ? b : a;
|
||
var key = lo * 65536 + hi;
|
||
if (_WfEdgeSet.has(key)) return;
|
||
_WfEdgeSet.add(key);
|
||
ctx.moveTo(VB_Sx[lo], VB_Sy[lo]);
|
||
ctx.lineTo(VB_Sx[hi], VB_Sy[hi]);
|
||
}
|
||
|
||
function DrawWireframeNode(ctx, meshData, worldMatrix, pos) {
|
||
var indicesArr = meshData.indices;
|
||
var vertexCount = PrepareNodeVerts(meshData, worldMatrix, pos);
|
||
|
||
_WfEdgeSet.clear();
|
||
ctx.beginPath();
|
||
for (var mi = 0; mi < indicesArr.length; mi++) {
|
||
var indices = indicesArr[mi];
|
||
for (var idx = 0; idx < indices.length; idx += 3) {
|
||
var i0 = indices[idx], i1 = indices[idx+1], i2 = indices[idx+2];
|
||
if (!VB_Ok[i0] || !VB_Ok[i1] || !VB_Ok[i2]) continue;
|
||
WfAddEdge(ctx, i0, i1);
|
||
WfAddEdge(ctx, i1, i2);
|
||
WfAddEdge(ctx, i2, i0);
|
||
}
|
||
}
|
||
ctx.stroke();
|
||
}
|
||
|
||
// ===== 兜底标记点:模型采集不到/被过滤时,至少画一个点,避免坦克完全不可见 =====
|
||
function DrawTankMarker(ctx, pos, camInfo, screenW, screenH, color) {
|
||
var p = ProjectPoint(pos, camInfo, screenW, screenH);
|
||
if (!p) return;
|
||
ctx.fillStyle = color;
|
||
ctx.beginPath();
|
||
ctx.arc(p.x, p.y, 3, 0, 2 * Math.PI);
|
||
ctx.fill();
|
||
}
|
||
|
||
// ===== 描边模式:屏幕空间光栅化轮廓提取 =====
|
||
// 算法(真正的"屏幕几何"方法):
|
||
// 1. 把所有面向相机的三角形光栅化(扫描线填充)到像素掩码
|
||
// 2. 车身和炮塔在屏幕上重叠时,填充区域自动合并
|
||
// 3. 找出填充区域的边界像素(填充像素的4邻居中有未填充像素)
|
||
// 4. 只画边界像素 = 屏幕空间外轮廓
|
||
var PixelMask = null;
|
||
var PixelMaskW = 0;
|
||
var PixelMaskH = 0;
|
||
var MaskGen = 0; // 掩码用"代数"标记:本辆坦克填充的像素 = gen,无需在坦克之间清空整块缓冲
|
||
|
||
function GetPixelMask(w, h) {
|
||
if (!PixelMask || PixelMaskW !== w || PixelMaskH !== h) {
|
||
PixelMask = new Uint32Array(w * h);
|
||
PixelMaskW = w;
|
||
PixelMaskH = h;
|
||
MaskGen = 0;
|
||
}
|
||
return PixelMask;
|
||
}
|
||
|
||
// 扫描线光栅化三角形(pixels 中写入 gen 表示"本辆坦克已填充")
|
||
function RasterizeTriangle(pixels, w, h, gen, x0, y0, x1, y1, x2, y2) {
|
||
// 按 y 排序三个顶点
|
||
if (y0 > y1) { var t = y0; y0 = y1; y1 = t; t = x0; x0 = x1; x1 = t; }
|
||
if (y1 > y2) { var t = y1; y1 = y2; y2 = t; t = x1; x1 = x2; x2 = t; }
|
||
if (y0 > y1) { var t = y0; y0 = y1; y1 = t; t = x0; x0 = x1; x1 = t; }
|
||
|
||
var minY = Math.max(0, Math.ceil(y0));
|
||
var maxY = Math.min(h - 1, Math.floor(y2));
|
||
if (minY > maxY) return;
|
||
|
||
for (var y = minY; y <= maxY; y++) {
|
||
var xa, xb;
|
||
if (y < y1) {
|
||
// 上半部分:y0 -> y1 和 y0 -> y2
|
||
xa = (y1 === y0) ? x0 : x0 + (x1 - x0) * (y - y0) / (y1 - y0);
|
||
xb = (y2 === y0) ? x0 : x0 + (x2 - x0) * (y - y0) / (y2 - y0);
|
||
} else {
|
||
// 下半部分:y1 -> y2 和 y0 -> y2
|
||
xa = (y2 === y1) ? x1 : x1 + (x2 - x1) * (y - y1) / (y2 - y1);
|
||
xb = (y2 === y0) ? x0 : x0 + (x2 - x0) * (y - y0) / (y2 - y0);
|
||
}
|
||
if (xa > xb) { var t = xa; xa = xb; xb = t; }
|
||
var minXA = Math.max(0, Math.ceil(xa - 0.5));
|
||
var maxXA = Math.min(w - 1, Math.floor(xb + 0.5));
|
||
var rowOffset = y * w;
|
||
for (var x = minXA; x <= maxXA; x++) {
|
||
pixels[rowOffset + x] = gen;
|
||
}
|
||
}
|
||
}
|
||
|
||
function DrawTankOutline(ctx, meshNodes, pos, tankIsMoving, screenW, screenH, diagStats, color) {
|
||
var pixels = GetPixelMask(screenW, screenH);
|
||
var gen = ++MaskGen;
|
||
if (gen === 0) { pixels.fill(0); gen = ++MaskGen; } // 代数回绕(理论值):整体清一次
|
||
|
||
// 记录坦克的屏幕边界框(用于后续边界检测优化)
|
||
var minX = screenW, minY = screenH, maxX = 0, maxY = 0;
|
||
|
||
for (var ni = 0; ni < meshNodes.length; ni++) {
|
||
var meshData = meshNodes[ni];
|
||
var bpNode = meshData.node;
|
||
var worldMatrix = IsNodeValid(bpNode, pos, tankIsMoving, diagStats);
|
||
if (!worldMatrix) continue;
|
||
|
||
var indicesArr = meshData.indices;
|
||
// 顶点变换+投影到复用缓冲(无对象分配)
|
||
var vertexCount = PrepareNodeVerts(meshData, worldMatrix, pos);
|
||
|
||
for (var v = 0; v < vertexCount; v++) {
|
||
if (!VB_Ok[v]) continue;
|
||
var sx = VB_Sx[v], sy = VB_Sy[v];
|
||
if (sx < minX) minX = sx;
|
||
if (sy < minY) minY = sy;
|
||
if (sx > maxX) maxX = sx;
|
||
if (sy > maxY) maxY = sy;
|
||
}
|
||
|
||
// 遍历三角形,光栅化面向相机的
|
||
for (var mi = 0; mi < indicesArr.length; mi++) {
|
||
var indices = indicesArr[mi];
|
||
for (var i = 0; i < indices.length; i += 3) {
|
||
var a = indices[i], b = indices[i+1], c = indices[i+2];
|
||
if (!VB_Ok[a] || !VB_Ok[b] || !VB_Ok[c]) continue;
|
||
|
||
// 计算法线判断是否面向相机(世界坐标,来自缓冲)
|
||
var ax = VB_Wx[a], ay = VB_Wy[a], az = VB_Wz[a];
|
||
var bx = VB_Wx[b], by = VB_Wy[b], bz = VB_Wz[b];
|
||
var gx = VB_Wx[c], gy = VB_Wy[c], gz = VB_Wz[c];
|
||
var e1x = bx - ax, e1y = by - ay, e1z = bz - az;
|
||
var e2x = gx - ax, e2y = gy - ay, e2z = gz - az;
|
||
var nx = e1y * e2z - e1z * e2y;
|
||
var ny = e1z * e2x - e1x * e2z;
|
||
var nz = e1x * e2y - e1y * e2x;
|
||
var cx = (ax + bx + gx) / 3;
|
||
var cy = (ay + by + gy) / 3;
|
||
var cz = (az + bz + gz) / 3;
|
||
var dot = nx * (Cam.px - cx) + ny * (Cam.py - cy) + nz * (Cam.pz - cz);
|
||
if (dot <= 0) continue; // 背向相机,跳过
|
||
|
||
// 光栅化三角形到像素掩码
|
||
RasterizeTriangle(pixels, screenW, screenH, gen, VB_Sx[a], VB_Sy[a], VB_Sx[b], VB_Sy[b], VB_Sx[c], VB_Sy[c]);
|
||
}
|
||
}
|
||
}
|
||
|
||
// 找出边界像素并绘制(只在坦克边界框内遍历;用单条路径 + 横向连续合并,替代逐像素 fillRect)
|
||
var bx0 = Math.max(0, Math.floor(minX) - 1);
|
||
var by0 = Math.max(0, Math.floor(minY) - 1);
|
||
var bx1 = Math.min(screenW - 1, Math.ceil(maxX) + 1);
|
||
var by1 = Math.min(screenH - 1, Math.ceil(maxY) + 1);
|
||
|
||
ctx.fillStyle = color;
|
||
ctx.beginPath();
|
||
var outlineCount = 0;
|
||
for (var y = by0; y <= by1; y++) {
|
||
var rowOffset = y * screenW;
|
||
var runStart = -1;
|
||
for (var x = bx0; x <= bx1 + 1; x++) {
|
||
var isBoundary = false;
|
||
if (x <= bx1 && pixels[rowOffset + x] === gen) {
|
||
// 检查4个邻居是否有未填充像素(== gen 即本辆坦克填充)
|
||
if (x === 0 || pixels[rowOffset + x - 1] !== gen) isBoundary = true;
|
||
else if (x === screenW - 1 || pixels[rowOffset + x + 1] !== gen) isBoundary = true;
|
||
else if (y === 0 || pixels[rowOffset - screenW + x] !== gen) isBoundary = true;
|
||
else if (y === screenH - 1 || pixels[rowOffset + screenW + x] !== gen) isBoundary = true;
|
||
}
|
||
if (isBoundary) {
|
||
if (runStart < 0) runStart = x;
|
||
outlineCount++;
|
||
} else if (runStart >= 0) {
|
||
ctx.rect(runStart, y, x - runStart + 2, 3); // 横向连续的边界像素合并为一条
|
||
runStart = -1;
|
||
}
|
||
}
|
||
}
|
||
ctx.fill();
|
||
|
||
if (diagStats) diagStats.outlineEdges += outlineCount;
|
||
return outlineCount;
|
||
}
|
||
|
||
// ===== 创建全屏Canvas =====
|
||
var canvas = document.createElement('canvas');
|
||
canvas.id = 'hornet3DWireframeCanvas';
|
||
canvas.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;z-index:99998;pointer-events:none;';
|
||
document.body.appendChild(canvas);
|
||
|
||
var ctx = canvas.getContext('2d');
|
||
|
||
function ResizeCanvas() {
|
||
canvas.width = window.innerWidth;
|
||
canvas.height = window.innerHeight;
|
||
}
|
||
ResizeCanvas();
|
||
window.addEventListener('resize', ResizeCanvas);
|
||
|
||
// ===== 诊断统计 =====
|
||
var DiagStats = { totalNodes: 0, filteredNodes: 0, renderedNodes: 0, outlineEdges: 0 };
|
||
var LastDegradeLogFrame = -9999; // 退化诊断日志节流
|
||
|
||
// 退化诊断(节流输出):定位"存活坦克模型没画出来"的根因(皮肤/部件/GPU/炮塔逐级)
|
||
function LogTankDiag(tankName, tankState, S, hullCount, turretCount, rendered) {
|
||
if (esp.frameCount - LastDegradeLogFrame <= 120) return;
|
||
LastDegradeLogFrame = esp.frameCount;
|
||
console.warn('[透视][退化诊断] ' + tankName + ' state=' + tankState +
|
||
' | 皮肤=' + S.hullSource + ' 部件=' + S.hullParts +
|
||
' 车身[见/网格/GPU成功/失败]=' + S.hull.seen + '/' + S.hull.hasMesh + '/' + S.hull.gpuOk + '/' + S.hull.gpuFail +
|
||
' | 炮塔 组件=' + S.turretComp + ' 根节点=' + S.turretRoot +
|
||
' 炮塔[见/网格/GPU成功/失败]=' + S.turret.seen + '/' + S.turret.hasMesh + '/' + S.turret.gpuOk + '/' + S.turret.gpuFail +
|
||
' | 收集 车身=' + hullCount + ' 炮塔=' + turretCount + ' 绘制=' + rendered +
|
||
' | 本帧 总=' + DiagStats.totalNodes + ' 过滤=' + DiagStats.filteredNodes);
|
||
}
|
||
|
||
// ===== ESP状态 =====
|
||
var esp = {
|
||
canvas: canvas,
|
||
ctx: ctx,
|
||
running: true,
|
||
frameCount: 0,
|
||
mode: Mode,
|
||
setMode: function(m) {
|
||
if (m === 'wireframe' || m === 'outline') {
|
||
Mode = m;
|
||
this.mode = m;
|
||
try { localStorage.setItem('hornet_wireframe_mode', m); } catch(e) {}
|
||
console.log('[透视] 模式切换为: ' + m);
|
||
} else {
|
||
console.warn('[透视] 未知模式: ' + m + ',支持: wireframe | outline');
|
||
}
|
||
},
|
||
getMode: function() { return Mode; },
|
||
setLineWidth: function(w) {
|
||
var n = parseFloat(w);
|
||
if (!isNaN(n) && n >= 0.5 && n <= 8) {
|
||
LineWidth = n;
|
||
try { localStorage.setItem('hornet_wireframe_linewidth', String(n)); } catch(e) {}
|
||
console.log('[透视] 线宽设置为: ' + n);
|
||
} else {
|
||
console.warn('[透视] 线宽应在 0.5 ~ 8 之间,当前: ' + w);
|
||
}
|
||
},
|
||
getLineWidth: function() { return LineWidth; },
|
||
setHighlightMode: function(mode) {
|
||
var valid = ['team','whitelist','blacklist'];
|
||
if (valid.indexOf(mode) >= 0) {
|
||
HighlightMode = mode;
|
||
try { localStorage.setItem('hornet_wireframe_highlight_mode', mode); } catch(e) {}
|
||
console.log('[透视] 渲染模式切换为: ' + mode);
|
||
} else {
|
||
console.warn('[透视] 未知渲染模式: ' + mode + ',支持: ' + valid.join(', '));
|
||
}
|
||
},
|
||
getHighlightMode: function() { return HighlightMode; },
|
||
setHighlightList: function(names) {
|
||
HighlightList = Array.isArray(names) ? names.slice() : [];
|
||
SaveHighlightList();
|
||
console.log('[透视] 高亮名单已更新:', HighlightList.length + '人');
|
||
},
|
||
addHighlight: function(name) {
|
||
if (name && HighlightList.indexOf(name) < 0) {
|
||
HighlightList.push(name);
|
||
SaveHighlightList();
|
||
console.log('[透视] 已添加高亮:', name);
|
||
}
|
||
},
|
||
removeHighlight: function(name) {
|
||
var idx = HighlightList.indexOf(name);
|
||
if (idx >= 0) {
|
||
HighlightList.splice(idx, 1);
|
||
SaveHighlightList();
|
||
console.log('[透视] 已移除高亮:', name);
|
||
}
|
||
},
|
||
getHighlightList: function() {
|
||
return HighlightList.slice();
|
||
},
|
||
clearHighlight: function() {
|
||
HighlightList = [];
|
||
SaveHighlightList();
|
||
console.log('[透视] 高亮名单已清空');
|
||
},
|
||
stop: function() {
|
||
this.running = false;
|
||
window.removeEventListener('resize', ResizeCanvas);
|
||
if (canvas.parentNode) canvas.parentNode.removeChild(canvas);
|
||
console.log('透视已停止');
|
||
}
|
||
};
|
||
window.__hornet3DWireframe__ = esp;
|
||
|
||
// ===== 金箱子标记 =====
|
||
// 金箱子是独立实体(不是坦克)。引擎侧标志:
|
||
// · 补给实体 = entity.j10q_1(bonusData 组件,见 module_87072 的 ctt())
|
||
// · 类型枚举 mE(module_10032):GOLD_BOX = 6
|
||
// 做法:实体基类的组件创建方法 nyz()(module_61378「BattleEntity.createComponent」)上挂观察钩子,
|
||
// 发现「带 j10q_1 且类型=6」的实体就登记,之后每帧按它自己的位置画金色标记。
|
||
// 钩子目标沿 tank 的原型链找 nyz 的宿主原型 —— 不需要任何导出键。
|
||
var GoldBoxes = [];
|
||
var GoldPending = [];
|
||
var EntityProtoHooked = false;
|
||
var GOLD_BOX_TYPE = 6;
|
||
|
||
function FindEnumOf(Obj) {
|
||
for (var K in Obj) {
|
||
try {
|
||
var V = Obj[K];
|
||
if (V && typeof V === 'object' && typeof V.k3_1 === 'number' && typeof V.equals === 'function') return V;
|
||
} catch (e) {}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
// 引擎口径:有 bonusData 且类型是 GOLD_BOX
|
||
function IsGoldBoxEntity(Entity) {
|
||
if (!Entity || !Entity.j10q_1) return false;
|
||
var Type = FindEnumOf(Entity.j10q_1);
|
||
return !!(Type && Type.k3_1 === GOLD_BOX_TYPE);
|
||
}
|
||
|
||
function RegisterGoldBox(Entity) {
|
||
for (var i = 0; i < GoldBoxes.length; i++) if (GoldBoxes[i] === Entity) return;
|
||
GoldBoxes.push(Entity);
|
||
console.log('[透视] 发现金箱子,已加入标记(当前 ' + GoldBoxes.length + ' 个)');
|
||
}
|
||
|
||
function InstallEntityHook(Tank) {
|
||
if (EntityProtoHooked || !Tank) return;
|
||
var Proto = Tank;
|
||
while (Proto && !Object.prototype.hasOwnProperty.call(Proto, 'nyz')) Proto = Object.getPrototypeOf(Proto);
|
||
if (!Proto || typeof Proto.nyz !== 'function') return;
|
||
var Orig = Proto.nyz;
|
||
Proto.nyz = function () {
|
||
var R = Orig.apply(this, arguments);
|
||
try {
|
||
if (IsGoldBoxEntity(this)) RegisterGoldBox(this);
|
||
else if (this && this.j10q_1 === undefined && GoldPending.length < 300) {
|
||
GoldPending.push({ e: this, f: esp.frameCount }); // 字段可能稍后才赋值,延后判定
|
||
}
|
||
} catch (E) {}
|
||
return R;
|
||
};
|
||
EntityProtoHooked = true;
|
||
console.log('[透视] 已挂补给实体观察钩子(金箱子)');
|
||
}
|
||
|
||
// 金箱位置:优先物理组件(与坦克同款 rzf_1),否则退到 bonusData 里的节点世界矩阵/向量
|
||
function GetGoldBoxPosition(Entity) {
|
||
var PhysPos = GetTankPosition(Entity); // 多级探测物理组件位置
|
||
if (PhysPos) return PhysPos;
|
||
var Bonus = Entity.j10q_1;
|
||
for (var K in Bonus) {
|
||
try {
|
||
var V = Bonus[K];
|
||
if (!V || typeof V !== 'object') continue;
|
||
if (V.n35_1 && V.n35_1.length >= 15) {
|
||
var M = V.n35_1;
|
||
return { x: M[12], y: M[13], z: M[14] };
|
||
}
|
||
if (typeof V.f20_1 === 'number' && typeof V.g20_1 === 'number' && typeof V.h20_1 === 'number') {
|
||
return { x: V.f20_1, y: V.g20_1, z: V.h20_1 };
|
||
}
|
||
} catch (e) {}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
// 金色菱形 + 十字 + 「金箱」标签(穿透显示,不遮挡判断)
|
||
function DrawGoldBoxMarker(Pos, ScreenW, ScreenH) {
|
||
var X = Pos.x - Cam.px, Y = Pos.y - Cam.py, Z = Pos.z - Cam.pz;
|
||
var CamZ = X * Cam.fx + Y * Cam.fy + Z * Cam.fz;
|
||
if (CamZ <= 0.1) return false;
|
||
var CamX = X * Cam.rx + Y * Cam.ry + Z * Cam.rz;
|
||
var CamY = X * Cam.ux + Y * Cam.uy + Z * Cam.uz;
|
||
var Sx = Cam.halfW + (CamX / CamZ) * Cam.focal;
|
||
var Sy = Cam.halfH - (CamY / CamZ) * Cam.focal;
|
||
if (Sx < -60 || Sy < -60 || Sx > ScreenW + 60 || Sy > ScreenH + 60) return false;
|
||
|
||
var R = 12;
|
||
ctx.save();
|
||
ctx.strokeStyle = '#ffd700';
|
||
ctx.fillStyle = 'rgba(255, 215, 0, 0.22)';
|
||
ctx.lineWidth = 2;
|
||
ctx.beginPath();
|
||
ctx.moveTo(Sx, Sy - R);
|
||
ctx.lineTo(Sx + R, Sy);
|
||
ctx.lineTo(Sx, Sy + R);
|
||
ctx.lineTo(Sx - R, Sy);
|
||
ctx.closePath();
|
||
ctx.fill();
|
||
ctx.stroke();
|
||
ctx.beginPath();
|
||
ctx.moveTo(Sx - R - 7, Sy); ctx.lineTo(Sx + R + 7, Sy);
|
||
ctx.moveTo(Sx, Sy - R - 7); ctx.lineTo(Sx, Sy + R + 7);
|
||
ctx.stroke();
|
||
ctx.fillStyle = '#ffd700';
|
||
ctx.font = 'bold 12px Consolas,monospace';
|
||
ctx.textAlign = 'center';
|
||
ctx.fillText('金箱', Sx, Sy - R - 9);
|
||
ctx.restore();
|
||
return true;
|
||
}
|
||
|
||
// ===== 冻结节点"拖拽跟随"诊断(节流 2s)=====
|
||
var LastFrozenLogAt = 0;
|
||
function LogDragDiag(Stats) {
|
||
if (!Stats.draggedNodes) return;
|
||
var Now = Date.now();
|
||
if (Now - LastFrozenLogAt < 2000) return;
|
||
LastFrozenLogAt = Now;
|
||
console.log('[透视][冻结] 本帧拖拽跟随节点 ' + Stats.draggedNodes + '/' + Stats.totalNodes +
|
||
'(引擎未更新这些节点矩阵,已按坦克位移平移,避免旧位置残留/模型丢失)');
|
||
}
|
||
|
||
// ===== 渲染循环 =====
|
||
function Render() {
|
||
if (!esp.running) return;
|
||
esp.frameCount++;
|
||
|
||
// 换图自刷新:gameMode 实例变化时重绑并清空该地图的缓存
|
||
var NewGm = window.__gameMode__;
|
||
if (NewGm && NewGm !== gm) {
|
||
gm = NewGm;
|
||
GpuDataCache.clear(); // 旧地图的 GPU 几何数据失效
|
||
TankPrevPos.clear(); // 位置历史(判断"在动")失效
|
||
GoldBoxes.length = 0; // 上一局的箱子实体全部作废
|
||
GoldPending.length = 0;
|
||
GpuCacheFrameCount = 0;
|
||
console.log('[透视] 检测到新战斗,已重置缓存');
|
||
}
|
||
// 旧实例已失效、新实例尚未捕获:清屏并显示等待提示,避免继续用悬空引用
|
||
if (!window.__gameMode__) {
|
||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||
ctx.fillStyle = 'rgba(255, 220, 120, 0.9)';
|
||
ctx.font = 'bold 12px Consolas,monospace';
|
||
ctx.textAlign = 'left';
|
||
ctx.fillText('[' + Mode.toUpperCase() + '] 等待战局…(ght 未捕获)', 5, canvas.height - 5);
|
||
requestAnimationFrame(Render);
|
||
return;
|
||
}
|
||
|
||
// 定期清空 GPU 数据缓存
|
||
GpuCacheFrameCount++;
|
||
if (GpuCacheFrameCount >= GpuCacheClearInterval) {
|
||
GpuDataCache.clear();
|
||
GpuCacheFrameCount = 0;
|
||
}
|
||
|
||
DiagStats = { totalNodes: 0, filteredNodes: 0, draggedNodes: 0, renderedNodes: 0, outlineEdges: 0, goldBoxes: 0 };
|
||
|
||
try {
|
||
var screenW = canvas.width;
|
||
var screenH = canvas.height;
|
||
ctx.clearRect(0, 0, screenW, screenH);
|
||
|
||
var camInfo = GetCameraInfo();
|
||
if (!camInfo) {
|
||
requestAnimationFrame(Render);
|
||
return;
|
||
}
|
||
|
||
var playerTank = GetPlayerTank();
|
||
if (!playerTank) {
|
||
requestAnimationFrame(Render);
|
||
return;
|
||
}
|
||
|
||
// 相机位置为0时使用玩家位置
|
||
if (Math.abs(camInfo.pos.x) < 0.1 && Math.abs(camInfo.pos.y) < 0.1 && Math.abs(camInfo.pos.z) < 0.1) {
|
||
var pPos = GetTankPosition(playerTank);
|
||
if (pPos) camInfo.pos = { x: pPos.x, y: pPos.y, z: pPos.z + 100 };
|
||
}
|
||
|
||
UpdateCam(camInfo, screenW, screenH); // 每帧只算一次相机基(需在相机位置兜底之后),供顶点变换/投影复用
|
||
|
||
var tankList = gm.m10b_1.j11y();
|
||
var count = tankList.e1();
|
||
var drawnCount = 0, botDrawn = 0;
|
||
|
||
// 人机扫描(装备分数不会每帧变,节流到约每 0.3 秒一次)
|
||
if (ScanBots && (Date.now() - LastBotScanAt > 300)) {
|
||
LastBotScanAt = Date.now();
|
||
ScanBots();
|
||
}
|
||
|
||
for (var i = 0; i < count; i++) {
|
||
var tank = tankList.g1(i);
|
||
if (!tank || tank === playerTank) continue;
|
||
if (!EntityProtoHooked) InstallEntityHook(tank); // 借坦克实例找到实体基类原型,挂补给观察钩子
|
||
|
||
var pos = GetTankPosition(tank); // 多级探测物理组件位置(字段名每个构建会平移)
|
||
if (!pos) continue;
|
||
var tankState = GetTankState(tank);
|
||
// 可见坦克 = ACTIVE(3) 或 SEMI_ACTIVE(2)(引擎 s14j() 判定),0/1/4 才跳过
|
||
if (tankState !== 2 && tankState !== 3) continue;
|
||
// 辅助判死:状态字段有时滞后一帧(爆毁动画期间仍是 ACTIVE),血量归零则不再画
|
||
var hp = GetTankHealth(tank);
|
||
if (hp && hp.current <= 0) continue;
|
||
|
||
// 本帧该坦克是否在移动(供「残留节点」检测:坦克在动而节点矩阵不动 = 旧位置遗留)
|
||
// 用位移的指数滑动平均,而不是「单帧位移 > 10 单位」——后者对慢速移动几乎永远为假,
|
||
// 会让残留检测形同虚设(残留漏掉的根因就在这里)
|
||
var tankId = tank.iz1_1;
|
||
var prevPos = TankPrevPos.get(tankId);
|
||
var tankMove = 0;
|
||
if (prevPos) {
|
||
var pdx = pos.x - prevPos.x, pdy = pos.y - prevPos.y, pdz = pos.z - prevPos.z;
|
||
tankMove = (prevPos.move || 0) * 0.85 + Math.sqrt(pdx * pdx + pdy * pdy + pdz * pdz);
|
||
}
|
||
var tankIsMoving = tankMove > 0.5; // 0.5 单位/帧 ≈ 0.3 m/s;静置坦克的抖动低于此值
|
||
TankPrevPos.set(tankId, { x: pos.x, y: pos.y, z: pos.z, move: tankMove });
|
||
|
||
// 距离检查(50000 = 500米)
|
||
var dx = pos.x - camInfo.pos.x;
|
||
var dy = pos.y - camInfo.pos.y;
|
||
var dz = pos.z - camInfo.pos.z;
|
||
var distance = Math.sqrt(dx*dx + dy*dy + dz*dz);
|
||
if (distance > 50000) continue;
|
||
|
||
var friendly = IsFriendly(tank, playerTank);
|
||
var tankName = GetTankName(tank) || ('ID:' + tank.iz1_1);
|
||
// 根据 HighlightMode 判断是否渲染该坦克
|
||
if (!ShouldRender(tankName)) continue;
|
||
// 颜色按队伍:友方=蓝,敌方=红
|
||
var color = friendly ? 'rgba(68, 136, 255, 0.6)' : 'rgba(255, 68, 68, 0.6)';
|
||
|
||
// 3D模型渲染(仅在mesh数据可用时)
|
||
var MeshStats = {
|
||
hullSource: '', hullParts: 0, turretComp: false, turretRoot: false,
|
||
hull: { seen: 0, hasMesh: 0, gpuOk: 0, gpuFail: 0 },
|
||
turret: { seen: 0, hasMesh: 0, gpuOk: 0, gpuFail: 0 }
|
||
};
|
||
var meshNodes = GetTankMeshData(tank, MeshStats);
|
||
if (!meshNodes || meshNodes.length === 0) {
|
||
LogTankDiag(tankName, tankState, MeshStats, 0, 0, 0);
|
||
DrawTankMarker(ctx, pos, camInfo, screenW, screenH, color); // 兜底:至少可见
|
||
drawnCount++;
|
||
continue;
|
||
}
|
||
|
||
// 统计各分组节点数(诊断用)
|
||
var hullCount = 0, turretCount = 0;
|
||
for (var gi = 0; gi < meshNodes.length; gi++) {
|
||
if (meshNodes[gi].group === 'turret') turretCount++; else hullCount++;
|
||
}
|
||
// 车身/炮塔任一有 mesh 就画模型;两者都为空才退回标记点
|
||
// (旧逻辑要求必须先拿到炮塔节点,炮塔字段漂移时会连带把车身模型也吞掉)
|
||
if (hullCount === 0 && turretCount === 0) {
|
||
LogTankDiag(tankName, tankState, MeshStats, hullCount, turretCount, 0);
|
||
DrawTankMarker(ctx, pos, camInfo, screenW, screenH, color);
|
||
drawnCount++;
|
||
continue;
|
||
}
|
||
|
||
ctx.strokeStyle = color;
|
||
ctx.lineWidth = LineWidth;
|
||
|
||
var renderedThisTank = 0;
|
||
if (Mode === 'wireframe') {
|
||
for (var ni = 0; ni < meshNodes.length; ni++) {
|
||
var meshData = meshNodes[ni];
|
||
var bpNode = meshData.node;
|
||
var worldMatrix = IsNodeValid(bpNode, pos, tankIsMoving, DiagStats);
|
||
if (!worldMatrix) continue;
|
||
DrawWireframeNode(ctx, meshData, worldMatrix, pos);
|
||
DiagStats.renderedNodes++;
|
||
renderedThisTank++;
|
||
}
|
||
} else {
|
||
renderedThisTank = DrawTankOutline(ctx, meshNodes, pos, tankIsMoving, screenW, screenH, DiagStats, color);
|
||
}
|
||
|
||
// 诊断(节流):存活坦克模型退化时输出根因数据,便于一次性定位
|
||
if ((renderedThisTank === 0 && meshNodes.length > 0) || hullCount === 0) {
|
||
LogTankDiag(tankName, tankState, MeshStats, hullCount, turretCount, renderedThisTank);
|
||
}
|
||
// 一个节点都没画出来:画兜底标记,避免坦克完全不可见
|
||
if (renderedThisTank === 0) {
|
||
DrawTankMarker(ctx, pos, camInfo, screenW, screenH, color);
|
||
}
|
||
|
||
// 人机标记:坦克上方写「人机」(同队装备分数相同且 < 8000)
|
||
if (typeof IsBot === 'function' && IsBot(tank)) {
|
||
var botPt = ProjectPoint({ x: pos.x, y: pos.y, z: pos.z + 1300 },
|
||
camInfo, screenW, screenH);
|
||
if (botPt) {
|
||
ctx.save();
|
||
ctx.font = 'bold 13px Consolas,monospace';
|
||
ctx.textAlign = 'center';
|
||
ctx.lineWidth = 3;
|
||
ctx.strokeStyle = 'rgba(0,0,0,0.85)';
|
||
ctx.strokeText('人机', botPt.x, botPt.y); // 先描黑边,保证任何背景下都看得清
|
||
ctx.fillStyle = '#ffd24a';
|
||
ctx.fillText('人机', botPt.x, botPt.y);
|
||
ctx.restore();
|
||
botDrawn++;
|
||
}
|
||
}
|
||
|
||
drawnCount++;
|
||
}
|
||
|
||
// ===== 金箱子:清理失效项 → 评估候选 → 画金色标记 =====
|
||
var goldDrawn = 0;
|
||
for (var gci = GoldBoxes.length - 1; gci >= 0; gci--) {
|
||
if (!IsGoldBoxEntity(GoldBoxes[gci])) GoldBoxes.splice(gci, 1); // 被拾取/销毁
|
||
}
|
||
for (var gpi = GoldPending.length - 1; gpi >= 0; gpi--) {
|
||
var PendingIt = GoldPending[gpi];
|
||
if (esp.frameCount - PendingIt.f > 90) { GoldPending.splice(gpi, 1); continue; }
|
||
if (IsGoldBoxEntity(PendingIt.e)) { RegisterGoldBox(PendingIt.e); GoldPending.splice(gpi, 1); }
|
||
}
|
||
for (var gbi = 0; gbi < GoldBoxes.length; gbi++) {
|
||
var GoldPos = GetGoldBoxPosition(GoldBoxes[gbi]);
|
||
if (GoldPos && DrawGoldBoxMarker(GoldPos, screenW, screenH)) goldDrawn++;
|
||
}
|
||
DiagStats.goldBoxes = goldDrawn;
|
||
|
||
// 屏幕信息
|
||
ctx.fillStyle = 'rgba(255, 255, 255, 0.5)';
|
||
ctx.font = '10px Arial';
|
||
ctx.textAlign = 'left';
|
||
var infoText = '[' + Mode.toUpperCase() + '] | ' + drawnCount + ' tanks';
|
||
if (Mode === 'outline') {
|
||
infoText += ' | Edges: ' + DiagStats.outlineEdges;
|
||
} else {
|
||
infoText += ' | Nodes: ' + DiagStats.renderedNodes + '/' + DiagStats.totalNodes;
|
||
}
|
||
infoText += ' | Frame: ' + esp.frameCount;
|
||
if (DiagStats.draggedNodes > 0) infoText += ' | 拖拽:' + DiagStats.draggedNodes;
|
||
if (DiagStats.goldBoxes > 0) infoText += ' | 金箱:' + DiagStats.goldBoxes;
|
||
ctx.fillText(infoText, 5, screenH - 5);
|
||
LogDragDiag(DiagStats);
|
||
|
||
} catch(e) {
|
||
console.error('[透视] 错误:', e.message, e.stack ? e.stack.split('\n')[1] : '');
|
||
}
|
||
|
||
requestAnimationFrame(Render);
|
||
}
|
||
|
||
// 启动渲染
|
||
requestAnimationFrame(Render);
|
||
|
||
console.log('3D透视已启动(双模式: wireframe | outline)');
|
||
console.log('当前模式: ' + Mode);
|
||
console.log('提示: window.__hornet3DWireframe__.setMode("outline") 切换到描边模式');
|
||
console.log('提示: window.__hornet3DWireframe__.setMode("wireframe") 切换到线框模式');
|
||
console.log('提示: window.__hornet3DWireframe__.addHighlight("玩家名") 添加高亮(金色标记)');
|
||
console.log('提示: window.__hornet3DWireframe__.stop() 停止透视');
|
||
}
|
||
|
||
var Tool = {
|
||
Start: function() {
|
||
if (window.__hornet3DWireframe__ && window.__hornet3DWireframe__.running) return;
|
||
_start();
|
||
},
|
||
Stop: function() {
|
||
if (window.__hornet3DWireframe__) window.__hornet3DWireframe__.stop();
|
||
},
|
||
Status: function() {
|
||
return (window.__hornet3DWireframe__ && window.__hornet3DWireframe__.running) ? ('运行中(' + window.__hornet3DWireframe__.mode + ')') : '已停止';
|
||
},
|
||
GetRunning: function() {
|
||
return !!(window.__hornet3DWireframe__ && window.__hornet3DWireframe__.running);
|
||
},
|
||
SetMode: function(m) {
|
||
if (window.__hornet3DWireframe__) window.__hornet3DWireframe__.setMode(m);
|
||
},
|
||
GetMode: function() {
|
||
return window.__hornet3DWireframe__ ? window.__hornet3DWireframe__.getMode() : null;
|
||
},
|
||
SetLineWidth: function(w) {
|
||
if (window.__hornet3DWireframe__) window.__hornet3DWireframe__.setLineWidth(w);
|
||
},
|
||
GetLineWidth: function() {
|
||
return window.__hornet3DWireframe__ ? window.__hornet3DWireframe__.getLineWidth() : 2;
|
||
},
|
||
SetHighlightMode: function(mode) {
|
||
if (window.__hornet3DWireframe__) window.__hornet3DWireframe__.setHighlightMode(mode);
|
||
},
|
||
GetHighlightMode: function() {
|
||
return window.__hornet3DWireframe__ ? window.__hornet3DWireframe__.getHighlightMode() : 'team';
|
||
},
|
||
SetHighlightList: function(names) {
|
||
if (window.__hornet3DWireframe__) window.__hornet3DWireframe__.setHighlightList(names);
|
||
},
|
||
AddHighlight: function(name) {
|
||
if (window.__hornet3DWireframe__) window.__hornet3DWireframe__.addHighlight(name);
|
||
},
|
||
RemoveHighlight: function(name) {
|
||
if (window.__hornet3DWireframe__) window.__hornet3DWireframe__.removeHighlight(name);
|
||
},
|
||
GetHighlightList: function() {
|
||
return window.__hornet3DWireframe__ ? window.__hornet3DWireframe__.getHighlightList() : [];
|
||
},
|
||
ClearHighlight: function() {
|
||
if (window.__hornet3DWireframe__) window.__hornet3DWireframe__.clearHighlight();
|
||
}
|
||
};
|
||
|
||
window.__hornetTools__ = window.__hornetTools__ || {};
|
||
window.__hornetTools__.Wireframe = Tool;
|
||
console.log('[3DTank] 3D透视模块已加载');
|
||
})();
|
||
|
||
|
||
// ===== hornet_rearview.js =====
|
||
// ===== 3DTank后视镜 - 原生渲染方案 =====
|
||
// 在浏览器控制台运行(需先运行 hook_gamemode.js)
|
||
// 功能:复用游戏原生渲染器(Ff.y3s)+ FBO 重定向,在小 canvas 中渲染玩家背后的 3D 场景
|
||
// 原理:Hook gl.bindFramebuffer 重定向到自定义 FBO,用后视相机参数调用游戏渲染器,readback 显示
|
||
|
||
(function() {
|
||
function _start() {
|
||
console.log('===== 3DTank后视镜(原生渲染版)启动 =====');
|
||
|
||
if (!window.__gameMode__) {
|
||
console.error('请先运行 hook_gamemode.js');
|
||
return;
|
||
}
|
||
|
||
if (window.__hornetRearview__) {
|
||
window.__hornetRearview__.Stop();
|
||
console.log('已停止旧后视镜');
|
||
}
|
||
|
||
var gm = window.__gameMode__;
|
||
|
||
// ===== 引用公共库(hornet_lib.js) =====
|
||
var lib = window.__hornetLib__;
|
||
if (!lib) {
|
||
console.error('[后视] 请先运行 hornet_lib.js');
|
||
return;
|
||
}
|
||
var teamFns = lib.LoadTeamFunctions();
|
||
var ZCt = teamFns.ZCt, m2Fn = teamFns.m2Fn;
|
||
|
||
// 公共函数别名(调用处不变)
|
||
var FindComponentByField = lib.FindComponentByField;
|
||
var GetTankPosition = lib.GetTankPosition;
|
||
var GetTankState = lib.GetTankState;
|
||
var IsTankAlive = lib.IsTankAlive;
|
||
var GetTankName = lib.GetTankName;
|
||
var GetTankHealth = lib.GetTankHealth;
|
||
function GetPlayerTank() { return lib.GetPlayerTank(gm); }
|
||
function IsFriendly(tank, playerTank) { return lib.IsFriendly(tank, playerTank, gm); }
|
||
function GetCameraInfo() { return lib.GetCameraInfo(gm); }
|
||
|
||
// ===== 配置 =====
|
||
var Config = {
|
||
width: 400, height: 240, fov: 1.2,
|
||
maxRange: 15000, pitchOffset: -0.05,
|
||
cameraHeight: 250, near: 50, far: 30000,
|
||
refreshInterval: 0 // 每帧渲染(0=每帧,用 PBO 异步回读避免 GPU 停顿)
|
||
};
|
||
// 从 localStorage 恢复 FOV 和透明度
|
||
try {
|
||
var __savedFov = localStorage.getItem('hornet_rearview_fov');
|
||
if (__savedFov) {
|
||
var __fv = parseFloat(__savedFov);
|
||
if (!isNaN(__fv) && __fv >= 0.5 && __fv <= 2.5) Config.fov = __fv;
|
||
}
|
||
} catch(e) {}
|
||
var RearviewOpacity = 1.0;
|
||
try {
|
||
var __savedROp = localStorage.getItem('hornet_rearview_opacity');
|
||
if (__savedROp) {
|
||
var __rop = parseFloat(__savedROp);
|
||
if (!isNaN(__rop) && __rop >= 0.2 && __rop <= 1) RearviewOpacity = __rop;
|
||
}
|
||
} catch(e) {}
|
||
|
||
// ===== UI 层禁用(防止技能条等 UI 混入后视 FBO)=====
|
||
// ff.x38_1 是渲染层队列数组,索引对应层:
|
||
// 20=Hud, 21=GuiBack, 22=GuiMain, 23=GuiFront, 24=DebugGui
|
||
// 替换为空假对象,使 z3k 填充和渲染都跳过这些层
|
||
var uiLayerIndices = [20, 21, 22, 23, 24];
|
||
var fakeQueue = {
|
||
e1: function() { return 0; },
|
||
g1: function(i) { return null; },
|
||
w2a: function(x) {},
|
||
d3: function() {},
|
||
ii: function(x) {}
|
||
};
|
||
|
||
// ===== 获取游戏渲染对象 =====
|
||
var scene = null, ff = null, rootNode = null, mainCamera = null, gl = null;
|
||
var initError = null;
|
||
|
||
try {
|
||
scene = gm.lz4().jz1_1;
|
||
console.log('[后视] 场景对象:', scene);
|
||
|
||
// hC 渲染器实例(jr.lye_1)
|
||
var renderer = scene.gyw_1;
|
||
console.log('[后视] 渲染器:', renderer);
|
||
|
||
// Ff 绘制器实例(hC.r74_1)
|
||
ff = renderer.d75_1;
|
||
console.log('[后视] Ff绘制器:', ff);
|
||
|
||
// 场景根节点(jr.kyf_1)
|
||
rootNode = scene.fyx_1;
|
||
console.log('[后视] 根节点:', rootNode);
|
||
|
||
// 主相机 Ha 实例(jr.nyf_1)
|
||
mainCamera = scene.iyx_1;
|
||
console.log('[后视] 主相机:', mainCamera);
|
||
|
||
// 游戏 gl context(Ff.p39_1.p73_1 = BS.p73_1)
|
||
gl = ff.w39_1.b74_1;
|
||
console.log('[后视] GL上下文:', gl);
|
||
|
||
if (!ff || !rootNode || !mainCamera || !gl) {
|
||
throw new Error('关键字段缺失');
|
||
}
|
||
|
||
// 探测相机字段
|
||
console.log('[后视] 相机u35_1:', mainCamera.u35_1);
|
||
console.log('[后视] 相机r35_1:', mainCamera.r35_1);
|
||
console.log('[后视] 相机b37_1:', mainCamera.b37_1);
|
||
|
||
if (!mainCamera.u35_1 || !mainCamera.r35_1) {
|
||
throw new Error('相机字段缺失');
|
||
}
|
||
|
||
} catch(e) {
|
||
initError = e;
|
||
console.error('[后视] 初始化失败:', e.message, e.stack);
|
||
}
|
||
|
||
// ===== 创建 UI canvas(显示后视画面 + UI 标记)=====
|
||
var uiCanvas = document.createElement('canvas');
|
||
uiCanvas.width = Config.width;
|
||
uiCanvas.height = Config.height;
|
||
uiCanvas.style.cssText = 'position:fixed;top:10px;right:10px;width:' + Config.width + 'px;height:' + Config.height + 'px;z-index:99999;border:2px solid rgba(120,200,255,0.6);border-radius:6px;pointer-events:auto;cursor:move;box-shadow:0 0 12px rgba(0,0,0,0.6);opacity:' + RearviewOpacity + ';';
|
||
document.body.appendChild(uiCanvas);
|
||
if (lib.MakeDraggable) lib.MakeDraggable(uiCanvas, 'rearview');
|
||
var uiCtx = uiCanvas.getContext('2d');
|
||
|
||
// ===== UI 绘制函数 =====
|
||
function DrawText(text, x, y, color, font, align, baseline) {
|
||
uiCtx.font = font || '12px monospace';
|
||
uiCtx.textAlign = align || 'left';
|
||
uiCtx.textBaseline = baseline || 'middle';
|
||
uiCtx.fillStyle = color || 'rgba(255,255,255,1)';
|
||
uiCtx.fillText(text, x, y);
|
||
}
|
||
|
||
function DrawTextOutlined(text, x, y, color, font, align, baseline) {
|
||
uiCtx.font = font || '12px monospace';
|
||
uiCtx.textAlign = align || 'left';
|
||
uiCtx.textBaseline = baseline || 'middle';
|
||
uiCtx.strokeStyle = 'rgba(0,0,0,0.85)';
|
||
uiCtx.lineWidth = 3;
|
||
uiCtx.strokeText(text, x, y);
|
||
uiCtx.fillStyle = color || 'rgba(255,255,255,1)';
|
||
uiCtx.fillText(text, x, y);
|
||
}
|
||
|
||
// ===== FBO 创建(后视镜渲染目标)=====
|
||
var rw = Config.width, rh = Config.height;
|
||
var rearFbo = null, rearTex = null, rearDepthRb = null;
|
||
var pixels = new Uint8Array(rw * rh * 4);
|
||
var imageData = uiCtx.createImageData(rw, rh);
|
||
|
||
// PBO 双缓冲异步回读(避免 readPixels 同步 GPU 停顿)
|
||
var pbos = [null, null];
|
||
var pboIndex = 0;
|
||
var pboReady = false; // 第一帧没有前帧数据,跳过回读
|
||
var pboSupported = false;
|
||
|
||
// Uint32 视图用于批量设置 alpha=255(修复树叶等半透明边缘)
|
||
var pixels32 = new Uint32Array(pixels.buffer);
|
||
|
||
if (gl && !initError) {
|
||
try {
|
||
rearFbo = gl.createFramebuffer();
|
||
rearTex = gl.createTexture();
|
||
rearDepthRb = gl.createRenderbuffer();
|
||
|
||
// 颜色纹理
|
||
gl.bindTexture(gl.TEXTURE_2D, rearTex);
|
||
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, rw, rh, 0, gl.RGBA, gl.UNSIGNED_BYTE, null);
|
||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
|
||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
|
||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
|
||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
|
||
|
||
// 深度缓冲
|
||
gl.bindRenderbuffer(gl.RENDERBUFFER, rearDepthRb);
|
||
gl.renderbufferStorage(gl.RENDERBUFFER, gl.DEPTH_COMPONENT16, rw, rh);
|
||
|
||
// 组装 FBO
|
||
gl.bindFramebuffer(gl.FRAMEBUFFER, rearFbo);
|
||
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, rearTex, 0);
|
||
gl.framebufferRenderbuffer(gl.FRAMEBUFFER, gl.DEPTH_ATTACHMENT, gl.RENDERBUFFER, rearDepthRb);
|
||
|
||
var fboStatus = gl.checkFramebufferStatus(gl.FRAMEBUFFER);
|
||
console.log('[后视] FBO状态:', fboStatus, '(完整=' + gl.FRAMEBUFFER_COMPLETE + ')');
|
||
|
||
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
|
||
gl.bindTexture(gl.TEXTURE_2D, null);
|
||
gl.bindRenderbuffer(gl.RENDERBUFFER, null);
|
||
|
||
if (fboStatus !== gl.FRAMEBUFFER_COMPLETE) {
|
||
throw new Error('FBO不完整: ' + fboStatus);
|
||
}
|
||
|
||
// 创建 PBO 双缓冲(WebGL2 PIXEL_PACK_BUFFER)
|
||
try {
|
||
var byteSize = rw * rh * 4;
|
||
for (var pi = 0; pi < 2; pi++) {
|
||
pbos[pi] = gl.createBuffer();
|
||
gl.bindBuffer(gl.PIXEL_PACK_BUFFER, pbos[pi]);
|
||
gl.bufferData(gl.PIXEL_PACK_BUFFER, byteSize, gl.STREAM_READ);
|
||
}
|
||
gl.bindBuffer(gl.PIXEL_PACK_BUFFER, null);
|
||
pboSupported = true;
|
||
console.log('[后视] PBO 双缓冲已创建(异步回读)');
|
||
} catch(pboErr) {
|
||
console.warn('[后视] PBO 不支持,回退到同步 readPixels:', pboErr.message);
|
||
pboSupported = false;
|
||
}
|
||
} catch(e) {
|
||
initError = e;
|
||
console.error('[后视] FBO创建失败:', e.message);
|
||
}
|
||
}
|
||
|
||
// ===== Hook gl.bindFramebuffer(后视渲染时重定向到我们的 FBO)=====
|
||
var rearviewRendering = false;
|
||
var mainFbo = null;
|
||
var origBindFramebuffer = null;
|
||
var origH3c = null, origG3c = null;
|
||
|
||
if (gl && !initError) {
|
||
origBindFramebuffer = gl.bindFramebuffer.bind(gl);
|
||
gl.bindFramebuffer = function(target, fbo) {
|
||
// 记录主 FBO(非后视渲染时的绑定)
|
||
if (!rearviewRendering && target === gl.FRAMEBUFFER) {
|
||
mainFbo = fbo;
|
||
}
|
||
// 后视渲染时重定向到我们的 FBO
|
||
if (rearviewRendering && target === gl.FRAMEBUFFER && fbo !== rearFbo) {
|
||
return origBindFramebuffer(target, rearFbo);
|
||
}
|
||
return origBindFramebuffer(target, fbo);
|
||
};
|
||
console.log('[后视] gl.bindFramebuffer 已 Hook');
|
||
|
||
// Hook BS.h3c/g3c:后视渲染时返回 FBO 尺寸(修复 screenSize/aspect ratio)
|
||
// y3s 会调用 BS.h3c()/g3c() 获取 drawingBufferWidth/Height 设置相机 l36_1/m36_1
|
||
// 不 Hook 的话 aspect ratio 会用游戏 canvas 尺寸(如1920x1080),导致画面比例错误
|
||
origH3c = ff.w39_1.o3c.bind(ff.w39_1);
|
||
origG3c = ff.w39_1.n3c.bind(ff.w39_1);
|
||
ff.w39_1.o3c = function() { return rearviewRendering ? rw : origH3c(); };
|
||
ff.w39_1.n3c = function() { return rearviewRendering ? rh : origG3c(); };
|
||
console.log('[后视] BS.o3c/n3c 已 Hook');
|
||
}
|
||
|
||
// ===== 渲染后视镜 =====
|
||
var frameCount = 0;
|
||
var running = true;
|
||
var debugInfo = {
|
||
frame: 0, tankCount: 0, renderedTanks: 0, lastError: null
|
||
};
|
||
|
||
function RenderRearview() {
|
||
if (!running || initError || !ff || !mainCamera || !rootNode || !gl || !rearFbo) return;
|
||
|
||
frameCount++;
|
||
debugInfo.frame = frameCount;
|
||
|
||
// 频率控制
|
||
if (frameCount % (Config.refreshInterval + 1) !== 0) return;
|
||
|
||
var playerTank = GetPlayerTank();
|
||
if (!playerTank) {
|
||
uiCtx.clearRect(0, 0, rw, rh);
|
||
DrawText('未找到玩家坦克', rw/2, rh/2, 'rgba(255,200,80,1)', '13px monospace', 'center');
|
||
return;
|
||
}
|
||
|
||
var playerPos = GetTankPosition(playerTank);
|
||
if (!playerPos) {
|
||
uiCtx.clearRect(0, 0, rw, rh);
|
||
DrawText('无位置数据', rw/2, rh/2, 'rgba(255,200,80,1)', '13px monospace', 'center');
|
||
return;
|
||
}
|
||
|
||
var camInfo = GetCameraInfo();
|
||
if (!camInfo) {
|
||
uiCtx.clearRect(0, 0, rw, rh);
|
||
DrawText('无相机数据', rw/2, rh/2, 'rgba(255,200,80,1)', '13px monospace', 'center');
|
||
return;
|
||
}
|
||
|
||
// 后视方向修复:camInfo.yaw = $r.lym_1.h20_1 是 roll(不是 yaw!)
|
||
// o37: forward.y = d[5] = cos(pitch)*cos(roll) + sin(pitch)*sin(yaw)*sin(roll)
|
||
// yaw=π 时 sin(yaw)=0,故后视 forward.y = cos(rearPitch)*cos(roll)
|
||
// roll = camInfo.yaw+π → cos(camInfo.yaw+π) = -cos(camInfo.yaw)
|
||
// 故后视 forward.y = -cos(rearPitch)*cos(camInfo.yaw),主视为 +cos(pitch)*cos(camInfo.yaw)
|
||
// → 后视水平方向 = -主视水平方向(朝后看 ✓,与 rearPitch 符号无关)
|
||
// o37: forward.z = d[9] = sin(pitch)*cos(yaw)
|
||
// 后视 yaw=π: forward.z = -sin(rearPitch)
|
||
// 要后视与主视同垂直方向(低头时都朝下看地形):rearPitch = -camInfo.pitch
|
||
// → 后视 forward.z = -sin(-camInfo.pitch) = sin(camInfo.pitch) = 主视 forward.z ✓
|
||
// up 向量:rearPitch=-camInfo.pitch 时 up.z<0(朝下),FBO 画面颠倒
|
||
// GL readPixels(自底向上) + putImageData(自顶向下) = Y 翻转,使显示画面正立 ✓
|
||
var rearPitch = -camInfo.pitch;
|
||
|
||
// 后视相机位置:玩家位置 + 高度偏移
|
||
var camPosX = playerPos.x;
|
||
var camPosY = playerPos.y;
|
||
var camPosZ = playerPos.z + Config.cameraHeight;
|
||
|
||
// ===== 保存主相机参数 =====
|
||
var savedPitch = mainCamera.u35_1.f20_1;
|
||
var savedYaw = mainCamera.u35_1.g20_1;
|
||
var savedRoll = mainCamera.u35_1.h20_1;
|
||
var savedX = mainCamera.r35_1.f20_1;
|
||
var savedY = mainCamera.r35_1.g20_1;
|
||
var savedZ = mainCamera.r35_1.h20_1;
|
||
var savedL36 = mainCamera.u36_1;
|
||
var savedM36 = mainCamera.v36_1;
|
||
var savedS36 = mainCamera.z36_1;
|
||
var savedQ36 = mainCamera.x36_1;
|
||
var savedR36 = mainCamera.y36_1;
|
||
|
||
// 临时暂停自由视角的 h37 hook,避免覆盖后视镜相机参数
|
||
var fl = window.__hornetFreeLook__;
|
||
if (fl && typeof fl.SetSuspended === 'function') fl.SetSuspended(true);
|
||
|
||
try {
|
||
// ===== 设置后视相机参数 =====
|
||
// g=π 使水平方向反向(朝后看),h=yaw+π 保持水平视角正确
|
||
// f=-pitch 使垂直方向与主视一致(低头看后方地形)
|
||
// up 朝下 → FBO 画面颠倒 → GL readback Y 翻转修正
|
||
mainCamera.u35_1.f20_1 = rearPitch;
|
||
mainCamera.u35_1.g20_1 = Math.PI;
|
||
mainCamera.u35_1.h20_1 = camInfo.yaw + Math.PI;
|
||
mainCamera.r35_1.f20_1 = camPosX;
|
||
mainCamera.r35_1.g20_1 = camPosY;
|
||
mainCamera.r35_1.h20_1 = camPosZ;
|
||
mainCamera.u36_1 = rw;
|
||
mainCamera.v36_1 = rh;
|
||
mainCamera.z36_1 = Config.fov;
|
||
mainCamera.x36_1 = Config.near;
|
||
mainCamera.y36_1 = Config.far;
|
||
|
||
// 更新相机矩阵(viewMatrix, projectionMatrix, viewProjectionMatrix)
|
||
mainCamera.o37();
|
||
|
||
// 保存后视视图矩阵副本(用于 UI 标记投影,finally 会恢复相机所以需提前保存)
|
||
var rearViewMatrix = new Float32Array(mainCamera.b37_1);
|
||
|
||
// ===== 保存并禁用阴影/后处理(避免它们切换 FBO 覆盖 rearFbo)=====
|
||
// p38_1.b3o_1 控制 z39_1(阴影),c3o_1 控制 c3a_1/d3a_1(后处理),z3n_1 控制 f3a_1
|
||
// 禁用后 y3s 只执行主场景渲染 z3k,跳过所有 FBO 切换的 pass
|
||
var savedX3n = ff.y38_1.e3o_1;
|
||
var savedY3n = ff.y38_1.f3o_1;
|
||
var savedZ3n = ff.y38_1.k3o_1;
|
||
ff.y38_1.e3o_1 = false;
|
||
ff.y38_1.f3o_1 = false;
|
||
ff.y38_1.k3o_1 = false;
|
||
|
||
// ===== 禁用 UI 渲染层(防止技能条等 UI 混入后视 FBO)=====
|
||
// 保存 UI 层队列,替换为空假对象,y3s 后恢复
|
||
var savedUiQueues = {};
|
||
for (var ui = 0; ui < uiLayerIndices.length; ui++) {
|
||
var li = uiLayerIndices[ui];
|
||
savedUiQueues[li] = ff.e39_1[li];
|
||
ff.e39_1[li] = fakeQueue;
|
||
}
|
||
|
||
// ===== 渲染到 FBO =====
|
||
rearviewRendering = true;
|
||
|
||
// 清除 FBO
|
||
origBindFramebuffer(gl.FRAMEBUFFER, rearFbo);
|
||
gl.viewport(0, 0, rw, rh);
|
||
gl.clearColor(0.15, 0.18, 0.22, 1.0);
|
||
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
|
||
|
||
// 调用游戏原生渲染器(已禁用阴影/后处理/UI层,只渲染 3D 场景)
|
||
ff.f3t(rootNode, mainCamera);
|
||
|
||
rearviewRendering = false;
|
||
|
||
// ===== Readback FBO 到 canvas(PBO 异步回读,避免 GPU 管线停顿)=====
|
||
origBindFramebuffer(gl.FRAMEBUFFER, rearFbo);
|
||
|
||
if (pboSupported) {
|
||
// PBO 异步回读:当前帧发起异步 readPixels 到 PBO[current],读取上一帧的 PBO[previous] 数据
|
||
var curPbo = pboIndex;
|
||
var prevPbo = 1 - pboIndex;
|
||
|
||
// 发起当前帧的异步 readPixels(写入 PBO,不阻塞 GPU)
|
||
gl.bindBuffer(gl.PIXEL_PACK_BUFFER, pbos[curPbo]);
|
||
gl.readPixels(0, 0, rw, rh, gl.RGBA, gl.UNSIGNED_BYTE, 0);
|
||
|
||
// 读取上一帧的 PBO 数据(此时 GPU 应已完成)
|
||
if (pboReady) {
|
||
gl.bindBuffer(gl.PIXEL_PACK_BUFFER, pbos[prevPbo]);
|
||
gl.getBufferSubData(gl.PIXEL_PACK_BUFFER, 0, pixels);
|
||
}
|
||
|
||
gl.bindBuffer(gl.PIXEL_PACK_BUFFER, null);
|
||
pboIndex = prevPbo;
|
||
pboReady = true;
|
||
} else {
|
||
// 回退:同步 readPixels
|
||
gl.readPixels(0, 0, rw, rh, gl.RGBA, gl.UNSIGNED_BYTE, pixels);
|
||
}
|
||
|
||
// 显示画面(g=π 渲染已自带 Y 翻转,直接复制)
|
||
// 修复半透明边缘:强制 alpha=255(树叶等 alpha 混合会导致 canvas 透明)
|
||
for (var ai = 0; ai < pixels32.length; ai++) {
|
||
pixels32[ai] = pixels32[ai] | 0xFF000000; // little-endian: 高字节=alpha
|
||
}
|
||
imageData.data.set(pixels);
|
||
uiCtx.putImageData(imageData, 0, 0);
|
||
|
||
// 恢复主 FBO
|
||
origBindFramebuffer(gl.FRAMEBUFFER, mainFbo);
|
||
|
||
} catch(e) {
|
||
debugInfo.lastError = e.message;
|
||
console.error('[后视] 渲染失败:', e.message, e.stack);
|
||
uiCtx.clearRect(0, 0, rw, rh);
|
||
DrawText('渲染错误: ' + e.message, rw/2, rh/2, 'rgba(255,100,100,1)', '11px monospace', 'center');
|
||
} finally {
|
||
// ===== 确保 rearviewRendering 已重置 =====
|
||
rearviewRendering = false;
|
||
|
||
// ===== 恢复阴影/后处理(异常时也要恢复)=====
|
||
try {
|
||
if (typeof savedX3n !== 'undefined') {
|
||
ff.y38_1.e3o_1 = savedX3n;
|
||
ff.y38_1.f3o_1 = savedY3n;
|
||
ff.y38_1.k3o_1 = savedZ3n;
|
||
}
|
||
} catch(e) {
|
||
console.error('[后视] 恢复阴影/后处理失败:', e.message);
|
||
}
|
||
|
||
// ===== 恢复 UI 渲染层(异常时也要恢复,否则游戏 UI 永久失效)=====
|
||
try {
|
||
if (savedUiQueues) {
|
||
for (var ui2 = 0; ui2 < uiLayerIndices.length; ui2++) {
|
||
var li2 = uiLayerIndices[ui2];
|
||
ff.e39_1[li2] = savedUiQueues[li2];
|
||
}
|
||
}
|
||
} catch(e) {
|
||
console.error('[后视] 恢复 UI 层失败:', e.message);
|
||
}
|
||
|
||
// ===== 恢复主相机参数 =====
|
||
try {
|
||
mainCamera.u35_1.f20_1 = savedPitch;
|
||
mainCamera.u35_1.g20_1 = savedYaw;
|
||
mainCamera.u35_1.h20_1 = savedRoll;
|
||
mainCamera.r35_1.f20_1 = savedX;
|
||
mainCamera.r35_1.g20_1 = savedY;
|
||
mainCamera.r35_1.h20_1 = savedZ;
|
||
mainCamera.u36_1 = savedL36;
|
||
mainCamera.v36_1 = savedM36;
|
||
mainCamera.z36_1 = savedS36;
|
||
mainCamera.x36_1 = savedQ36;
|
||
mainCamera.y36_1 = savedR36;
|
||
mainCamera.o37();
|
||
} catch(e) {
|
||
console.error('[后视] 恢复相机失败:', e.message);
|
||
}
|
||
// 恢复自由视角的 h37 hook
|
||
if (fl && typeof fl.SetSuspended === 'function') fl.SetSuspended(false);
|
||
}
|
||
|
||
// ===== 绘制 UI 标记(坦克位置标记)=====
|
||
if (rearViewMatrix) {
|
||
DrawUIMarkers(playerTank, playerPos, camInfo, rearViewMatrix);
|
||
}
|
||
}
|
||
|
||
// ===== UI 标记绘制 =====
|
||
function DrawUIMarkers(playerTank, playerPos, camInfo, viewMatrix) {
|
||
var tankList = gm.m10b_1.j11y();
|
||
var count = tankList.e1();
|
||
debugInfo.tankCount = count;
|
||
var visibleTanks = 0;
|
||
|
||
for (var i = 0; i < count; i++) {
|
||
var tank = tankList.g1(i);
|
||
if (!tank || tank === playerTank) continue;
|
||
if (!IsTankAlive(tank)) continue;
|
||
|
||
var pos = GetTankPosition(tank);
|
||
if (!pos) continue;
|
||
|
||
// 使用视图矩阵投影(与 3D 渲染一致)
|
||
// o37 变换前矩阵行:right(d[0,4,8]), forward(d[1,5,9]), up(d[2,6,10])
|
||
// Ua 变换后行:right, up, -forward
|
||
// 故变换后相机空间:camX=右,camY=上(up·rel),camZ=深度(-forward·rel,负=前方)
|
||
// 后视相机 up 朝下:世界"上"rel.z>0 → camY<0;渲染+FBO readback 使世界"上"显示在 canvas 顶部
|
||
// marker 用 + 号匹配:camY<0(上方) → +负 → screen.y<rh/2 → canvas 顶部
|
||
var wx = pos.x, wy = pos.y, wz = pos.z;
|
||
var m = viewMatrix;
|
||
var camX = m[0]*wx + m[4]*wy + m[8]*wz + m[12];
|
||
var camY = m[1]*wx + m[5]*wy + m[9]*wz + m[13]; // 上:up·rel
|
||
var camZ = m[2]*wx + m[6]*wy + m[10]*wz + m[14]; // 深度:-forward·rel,负=前方
|
||
if (camZ >= -0.1) continue; // 剔除相机后方(深度非负)
|
||
var depth = -camZ; // 正值=距离
|
||
var focalLength = rh / (2 * Math.tan(Config.fov / 2));
|
||
var screen = {
|
||
x: rw / 2 + (camX / depth) * focalLength,
|
||
y: rh / 2 + (camY / depth) * focalLength, // +号匹配 GL readback Y 翻转后的画面
|
||
depth: depth
|
||
};
|
||
|
||
if (screen.x < 0 || screen.x > rw || screen.y < 0 || screen.y > rh) continue;
|
||
if (screen.depth > Config.maxRange) continue;
|
||
|
||
visibleTanks++;
|
||
var friendly = IsFriendly(tank, playerTank);
|
||
var color = friendly ? 'rgba(80,180,255,1)' : 'rgba(255,80,60,1)';
|
||
var bgColor = friendly ? 'rgba(40,90,140,0.7)' : 'rgba(140,40,30,0.7)';
|
||
|
||
// 方框标记
|
||
uiCtx.strokeStyle = color;
|
||
uiCtx.lineWidth = 1.5;
|
||
uiCtx.strokeRect(screen.x - 6, screen.y - 6, 12, 12);
|
||
|
||
// 名称
|
||
var name = GetTankName(tank);
|
||
if (name) {
|
||
DrawTextOutlined(name, screen.x, screen.y - 12, color, '10px monospace', 'center');
|
||
}
|
||
|
||
// 血量条
|
||
var health = GetTankHealth(tank);
|
||
if (health) {
|
||
var barW = 24, barH = 3;
|
||
var barX = screen.x - barW / 2, barY = screen.y + 8;
|
||
uiCtx.fillStyle = 'rgba(0,0,0,0.6)';
|
||
uiCtx.fillRect(barX, barY, barW, barH);
|
||
var hpColor = health.percent > 0.6 ? 'rgba(80,255,80,1)' :
|
||
health.percent > 0.3 ? 'rgba(255,200,60,1)' : 'rgba(255,80,60,1)';
|
||
uiCtx.fillStyle = hpColor;
|
||
uiCtx.fillRect(barX, barY, barW * health.percent, barH);
|
||
}
|
||
}
|
||
|
||
debugInfo.renderedTanks = visibleTanks;
|
||
|
||
// 边框 + 标题
|
||
uiCtx.strokeStyle = 'rgba(120,200,255,0.4)';
|
||
uiCtx.lineWidth = 1;
|
||
uiCtx.strokeRect(0, 0, rw, rh);
|
||
|
||
DrawTextOutlined('后视镜', 8, 12, 'rgba(120,200,255,0.9)', '11px monospace', 'left');
|
||
DrawTextOutlined(visibleTanks + '/' + count, rw - 8, 12, 'rgba(180,180,180,0.9)', '11px monospace', 'right');
|
||
|
||
if (debugInfo.lastError) {
|
||
DrawTextOutlined('ERR: ' + debugInfo.lastError.substring(0, 30), 8, rh - 8, 'rgba(255,100,100,0.9)', '10px monospace', 'left');
|
||
}
|
||
}
|
||
|
||
// ===== 主循环 =====
|
||
function Loop() {
|
||
if (!running) return;
|
||
|
||
// 换图自刷新:gameMode 实例变化时整体重启,重新获取场景/相机/GL hook
|
||
var NewGm = window.__gameMode__;
|
||
if (NewGm && NewGm !== gm) {
|
||
console.log('[后视] 检测到新战斗,正在重启后视镜...');
|
||
Stop();
|
||
_start();
|
||
return;
|
||
}
|
||
|
||
requestAnimationFrame(Loop);
|
||
// 旧实例已失效、新实例尚未捕获:跳过本帧
|
||
if (!window.__gameMode__) return;
|
||
RenderRearview();
|
||
}
|
||
|
||
// ===== 停止函数 =====
|
||
function Stop() {
|
||
running = false;
|
||
console.log('[后视] 停止后视镜');
|
||
|
||
// 恢复 gl.bindFramebuffer
|
||
if (origBindFramebuffer && gl) {
|
||
gl.bindFramebuffer = origBindFramebuffer;
|
||
console.log('[后视] gl.bindFramebuffer 已恢复');
|
||
}
|
||
|
||
// 恢复 BS.h3c/g3c
|
||
if (origH3c && ff && ff.w39_1) {
|
||
ff.w39_1.o3c = origH3c;
|
||
ff.w39_1.n3c = origG3c;
|
||
console.log('[后视] BS.o3c/n3c 已恢复');
|
||
}
|
||
|
||
// 清理 FBO
|
||
if (gl && rearFbo) gl.deleteFramebuffer(rearFbo);
|
||
if (gl && rearTex) gl.deleteTexture(rearTex);
|
||
if (gl && rearDepthRb) gl.deleteRenderbuffer(rearDepthRb);
|
||
|
||
// 清理 PBO
|
||
if (gl && pbos[0]) gl.deleteBuffer(pbos[0]);
|
||
if (gl && pbos[1]) gl.deleteBuffer(pbos[1]);
|
||
|
||
// 移除 canvas
|
||
if (uiCanvas && uiCanvas.parentNode) {
|
||
uiCanvas.parentNode.removeChild(uiCanvas);
|
||
}
|
||
|
||
window.__hornetRearview__ = null;
|
||
}
|
||
|
||
// ===== 启动 =====
|
||
window.__hornetRearview__ = {
|
||
Stop: Stop, Config: Config,
|
||
setFov: function(f) {
|
||
var n = parseFloat(f);
|
||
if (!isNaN(n) && n >= 0.5 && n <= 2.5) {
|
||
Config.fov = n;
|
||
try { localStorage.setItem('hornet_rearview_fov', String(n)); } catch(e) {}
|
||
console.log('[后视] FOV 设为: ' + n);
|
||
} else {
|
||
console.warn('[后视] FOV 应在 0.5~2.5');
|
||
}
|
||
},
|
||
getFov: function() { return Config.fov; },
|
||
setOpacity: function(o) {
|
||
var n = parseFloat(o);
|
||
if (!isNaN(n) && n >= 0.2 && n <= 1) {
|
||
RearviewOpacity = n;
|
||
uiCanvas.style.opacity = String(n);
|
||
try { localStorage.setItem('hornet_rearview_opacity', String(n)); } catch(e) {}
|
||
} else {
|
||
console.warn('[后视] 透明度应在 0.2~1');
|
||
}
|
||
},
|
||
getOpacity: function() { return RearviewOpacity; }
|
||
};
|
||
|
||
if (initError) {
|
||
uiCtx.clearRect(0, 0, rw, rh);
|
||
DrawText('初始化失败:', rw/2, rh/2 - 10, 'rgba(255,100,100,1)', '13px monospace', 'center');
|
||
DrawText(initError.message, rw/2, rh/2 + 10, 'rgba(255,200,80,1)', '11px monospace', 'center');
|
||
console.error('[后视] 初始化失败,请检查字段访问');
|
||
return;
|
||
}
|
||
|
||
console.log('[后视] 原生渲染后视镜已启动');
|
||
console.log('[后视] 配置: ' + rw + 'x' + rh + ', FOV=' + Config.fov + ', 间隔=' + Config.refreshInterval);
|
||
Loop();
|
||
}
|
||
|
||
var Tool = {
|
||
Start: function() {
|
||
if (window.__hornetRearview__) return;
|
||
_start();
|
||
},
|
||
Stop: function() {
|
||
if (window.__hornetRearview__) window.__hornetRearview__.Stop();
|
||
},
|
||
Status: function() {
|
||
return window.__hornetRearview__ ? '运行中' : '已停止';
|
||
},
|
||
GetRunning: function() {
|
||
return !!window.__hornetRearview__;
|
||
},
|
||
SetFov: function(f) { if (window.__hornetRearview__) window.__hornetRearview__.setFov(f); },
|
||
GetFov: function() { return window.__hornetRearview__ ? window.__hornetRearview__.getFov() : 1.2; },
|
||
SetOpacity: function(o) { if (window.__hornetRearview__) window.__hornetRearview__.setOpacity(o); },
|
||
GetOpacity: function() { return window.__hornetRearview__ ? window.__hornetRearview__.getOpacity() : 1.0; }
|
||
};
|
||
|
||
window.__hornetTools__ = window.__hornetTools__ || {};
|
||
window.__hornetTools__.Rearview = Tool;
|
||
console.log('[3DTank] 后视镜模块已加载');
|
||
})();
|
||
|
||
|
||
// ===== hornet_aim_warning.js =====
|
||
// ===== 3DTank瞄准警告 - 敌方坦克瞄准线预判 =====
|
||
// 在浏览器控制台运行(需先运行 hook_gamemode.js)
|
||
// 功能:当敌方坦克炮塔朝向玩家时,立刻显示HUD警告并标记方向
|
||
// 独立轻量级脚本,不依赖3D透视渲染
|
||
|
||
(function() {
|
||
function _start() {
|
||
console.log('===== 3DTank瞄准警告启动 =====');
|
||
|
||
// 允许在未进入战局 / ght 尚未捕获时启动:先出状态行,威胁判定等进入战局再算
|
||
if (window.__hornetAimWarning__) {
|
||
window.__hornetAimWarning__.Stop();
|
||
console.log('已停止旧瞄准警告');
|
||
}
|
||
|
||
var gm = window.__gameMode__ || null;
|
||
|
||
// ===== 设置参数(localStorage 持久化)=====
|
||
// Sensitivity: 灵敏度系数,>1 更灵敏(阈值更大,更易触发),<1 更迟钝
|
||
var Sensitivity = 1.0;
|
||
try {
|
||
var __savedSens = localStorage.getItem('hornet_aim_sensitivity');
|
||
if (__savedSens) {
|
||
var __s = parseFloat(__savedSens);
|
||
if (!isNaN(__s) && __s >= 0.3 && __s <= 3.0) Sensitivity = __s;
|
||
}
|
||
} catch(e) {}
|
||
// SoundEnabled: 警告声音开关
|
||
var SoundEnabled = false;
|
||
try {
|
||
var __savedSE = localStorage.getItem('hornet_aim_sound');
|
||
if (__savedSE === 'true') SoundEnabled = true;
|
||
} catch(e) {}
|
||
var _audioCtx = null;
|
||
var _lastBeepTime = 0;
|
||
function PlayBeep() {
|
||
if (!SoundEnabled) return;
|
||
var now = Date.now();
|
||
if (now - _lastBeepTime < 300) return; // 最多每300ms响一次
|
||
_lastBeepTime = now;
|
||
try {
|
||
if (!_audioCtx) _audioCtx = new (window.AudioContext || window.webkitAudioContext)();
|
||
var osc = _audioCtx.createOscillator();
|
||
var gain = _audioCtx.createGain();
|
||
osc.frequency.value = 880;
|
||
osc.type = 'square';
|
||
gain.gain.value = 0.08;
|
||
osc.connect(gain);
|
||
gain.connect(_audioCtx.destination);
|
||
osc.start();
|
||
gain.gain.exponentialRampToValueAtTime(0.001, _audioCtx.currentTime + 0.15);
|
||
osc.stop(_audioCtx.currentTime + 0.15);
|
||
} catch(e) {}
|
||
}
|
||
|
||
// ===== 引用公共库(hornet_lib.js) =====
|
||
var lib = window.__hornetLib__;
|
||
if (!lib) {
|
||
console.error('[瞄准] 请先运行 hornet_lib.js');
|
||
return;
|
||
}
|
||
var teamFns = lib.LoadTeamFunctions();
|
||
var ZCt = teamFns.ZCt, m2Fn = teamFns.m2Fn;
|
||
|
||
// 公共函数别名(调用处不变)
|
||
var FindComponentByField = lib.FindComponentByField;
|
||
var GetTankPosition = lib.GetTankPosition;
|
||
var GetTankState = lib.GetTankState;
|
||
var IsTankAlive = lib.IsTankAlive;
|
||
|
||
// ===== 获取 TDt 武器皮肤组件(炮塔) =====
|
||
function GetTDtComponent(entity) {
|
||
var comp = FindComponentByField(entity, 'n11c_1');
|
||
if (comp) return comp;
|
||
comp = FindComponentByField(entity, 'q112_1');
|
||
return comp;
|
||
}
|
||
|
||
// 坦克名称/血量:使用公共库
|
||
var GetTankName = lib.GetTankName;
|
||
var GetTankHealth = lib.GetTankHealth;
|
||
|
||
// ===== 获取炮塔瞄准角度 =====
|
||
// 从炮塔 Bp 节点世界矩阵(n35_1)提取水平面前向方向
|
||
// 返回 { angle: 炮塔朝向与"敌方→玩家"方向的夹角, distance: 距离, turretAngle: 炮塔朝向角度, turretDir: {x,y} }
|
||
function GetTurretAimAngle(tank, enemyPos, playerPos) {
|
||
var tdt = GetTDtComponent(tank);
|
||
if (!tdt || !tdt.n11c_1 || !tdt.n11c_1.m17a_1) return null;
|
||
var bpNode = tdt.n11c_1.m17a_1;
|
||
var m = bpNode.n35_1;
|
||
if (!m) return null;
|
||
|
||
// 敌方→玩家方向(x,y 水平面)
|
||
var dx = playerPos.x - enemyPos.x;
|
||
var dy = playerPos.y - enemyPos.y;
|
||
var dist = Math.sqrt(dx * dx + dy * dy);
|
||
if (dist < 100) return null; // 太近不判断
|
||
var tx = dx / dist, ty = dy / dist;
|
||
|
||
// 尝试 X 轴(m0,m1) 和 Y 轴(m4,m5),取最小夹角
|
||
var minAngle = Math.PI;
|
||
var turretAngle = 0;
|
||
var turretDir = { x: 1, y: 0 };
|
||
var axes = [
|
||
{ x: m[0], y: m[1] },
|
||
{ x: m[4], y: m[5] }
|
||
];
|
||
for (var ai = 0; ai < axes.length; ai++) {
|
||
var ax = axes[ai].x, ay = axes[ai].y;
|
||
var len = Math.sqrt(ax * ax + ay * ay);
|
||
if (len < 0.001) continue;
|
||
var nx = ax / len, ny = ay / len;
|
||
var dot = nx * tx + ny * ty;
|
||
var ang = Math.acos(Math.max(-1, Math.min(1, dot)));
|
||
if (ang < minAngle) {
|
||
minAngle = ang;
|
||
turretAngle = Math.atan2(ny, nx);
|
||
turretDir = { x: nx, y: ny };
|
||
}
|
||
}
|
||
|
||
return { angle: minAngle, distance: dist, turretAngle: turretAngle, turretDir: turretDir };
|
||
}
|
||
|
||
// ===== 全局碰撞检测器(uMt 组件,挂在 GameMode entity 上) =====
|
||
var UMtComponent = null;
|
||
function GetUMtComponent() {
|
||
if (UMtComponent) return UMtComponent;
|
||
try {
|
||
var gmEntity = gm.lz4();
|
||
if (!gmEntity || !gmEntity.qz1_1 || !gmEntity.qz1_1.s2a_1) return null;
|
||
gmEntity.qz1_1.s2a_1.forEach(function(valueList, key) {
|
||
if (UMtComponent) return;
|
||
try {
|
||
var comp = (valueList && typeof valueList.g1 === 'function') ? valueList.g1(0) : valueList;
|
||
if (comp && typeof comp.a17p === 'function') UMtComponent = comp;
|
||
} catch(e) {}
|
||
});
|
||
if (UMtComponent) console.log('[瞄准] uMt 碰撞检测器已获取');
|
||
else UMtComponent = false;
|
||
} catch(e) {
|
||
console.warn('[瞄准] 获取 uMt 组件失败:', e.message);
|
||
}
|
||
return UMtComponent || null;
|
||
}
|
||
|
||
// mock O$ 向量:使用公共库
|
||
var MkVec = lib.MkVec;
|
||
|
||
// ===== 射线检测视线遮挡(缓存:每10帧更新) =====
|
||
var RaycastCache = new Map();
|
||
var RaycastFailCount = 0;
|
||
|
||
function IsLineOfSightBlocked(enemyPos, playerPos, tankId, frame) {
|
||
var cached = RaycastCache.get(tankId);
|
||
if (cached && (frame - cached.frame) < 10) return cached.blocked;
|
||
|
||
var uMt = GetUMtComponent();
|
||
if (!uMt) return false;
|
||
|
||
var blocked = false;
|
||
try {
|
||
var dx = playerPos.x - enemyPos.x;
|
||
var dy = playerPos.y - enemyPos.y;
|
||
var dz = playerPos.z - enemyPos.z;
|
||
var dist = Math.sqrt(dx*dx + dy*dy + dz*dz);
|
||
if (dist < 200) {
|
||
RaycastCache.set(tankId, { frame: frame, blocked: false });
|
||
return false;
|
||
}
|
||
var ndx = dx / dist, ndy = dy / dist, ndz = dz / dist;
|
||
|
||
var startVec = MkVec(enemyPos.x, enemyPos.y, enemyPos.z + 200);
|
||
var dirVec = MkVec(ndx, ndy, ndz);
|
||
var maxDist = dist - 300;
|
||
|
||
var result = {
|
||
y178_1: MkVec(0,0,0),
|
||
z178_1: MkVec(0,0,0),
|
||
a179_1: maxDist,
|
||
q17o_1: null,
|
||
b179_1: null,
|
||
r17o: function(t, n, i, r, e) {
|
||
this.y178_1.k26(t);
|
||
this.z178_1.k26(n);
|
||
this.a179_1 = i;
|
||
this.q17o_1 = r;
|
||
this.b179_1 = e;
|
||
},
|
||
d3: function() { this.b179_1 = null; }
|
||
};
|
||
|
||
var filter = {
|
||
l1da_1: { h1: function() { return false; }, d3: function() {} },
|
||
w179: function() { return true; },
|
||
d3: function() { this.l1da_1.d3(); },
|
||
m1da: function() {}
|
||
};
|
||
|
||
var hit = uMt.a17p(startVec, dirVec, maxDist, result, filter);
|
||
blocked = !!hit && result.a179_1 < (maxDist - 50);
|
||
RaycastFailCount = 0;
|
||
} catch(e) {
|
||
RaycastFailCount++;
|
||
if (RaycastFailCount <= 3) console.warn('[瞄准] 射线检测异常:', e.message);
|
||
blocked = false;
|
||
}
|
||
|
||
RaycastCache.set(tankId, { frame: frame, blocked: blocked });
|
||
return blocked;
|
||
}
|
||
|
||
// 玩家坦克/友方/相机/投影:使用公共库
|
||
function GetPlayerTank() { return lib.GetPlayerTank(gm); }
|
||
function IsFriendly(tank, playerTank) { return lib.IsFriendly(tank, playerTank, gm); }
|
||
function GetCameraInfo() { return lib.GetCameraInfo(gm); }
|
||
var Project3D = lib.Project3D;
|
||
|
||
// ===== 创建 Canvas =====
|
||
var canvas = document.createElement('canvas');
|
||
canvas.id = 'hornetAimWarningCanvas';
|
||
canvas.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;z-index:99998;pointer-events:none;';
|
||
document.body.appendChild(canvas);
|
||
var ctx = canvas.getContext('2d');
|
||
|
||
function ResizeCanvas() {
|
||
canvas.width = window.innerWidth;
|
||
canvas.height = window.innerHeight;
|
||
}
|
||
ResizeCanvas();
|
||
window.addEventListener('resize', ResizeCanvas);
|
||
|
||
// ===== 警告闪烁状态 =====
|
||
var WarningPulse = 0; // 闪烁动画相位
|
||
|
||
// ===== 死亡检测状态 =====
|
||
var PlayerPrevHealth = -1; // 上一帧血量(-1=未初始化)
|
||
var DeathInfo = null; // 死亡信息 { killerName, distance, expected, time, threats }
|
||
var DeathDisplayTime = 360; // 死亡信息显示帧数(约6秒)
|
||
var DeathStats = { total: 0, expected: 0, unexpected: 0 }; // 死亡统计
|
||
|
||
// ===== 主循环 =====
|
||
var running = true;
|
||
var frameCount = 0;
|
||
|
||
function Loop() {
|
||
if (!running) return;
|
||
frameCount++;
|
||
requestAnimationFrame(Loop);
|
||
|
||
// 换图自刷新:gameMode 实例变化时重绑并清空该地图的缓存
|
||
var NewGm = window.__gameMode__;
|
||
if (NewGm && NewGm !== gm) {
|
||
gm = NewGm;
|
||
UMtComponent = null; // 碰撞检测器需从新场景重新获取
|
||
RaycastCache.clear(); // 旧地图的视线遮挡缓存失效
|
||
DeathInfo = null; // 清掉旧地图的死亡提示
|
||
console.log('[瞄准] 检测到新战斗,已重置缓存');
|
||
}
|
||
// ght 尚未捕获 / 不在战局:清屏并显示等待提示
|
||
if (!window.__gameMode__) {
|
||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||
ctx.fillStyle = 'rgba(255, 220, 120, 0.85)';
|
||
ctx.font = '11px Arial';
|
||
ctx.textAlign = 'left';
|
||
ctx.fillText('[瞄准警告] 等待战局…(ght 未捕获)', 5, 18);
|
||
return;
|
||
}
|
||
|
||
var screenW = canvas.width;
|
||
var screenH = canvas.height;
|
||
ctx.clearRect(0, 0, screenW, screenH);
|
||
|
||
var playerTank = GetPlayerTank();
|
||
if (!playerTank) return;
|
||
|
||
var playerPos = GetTankPosition(playerTank);
|
||
if (!playerPos) return;
|
||
|
||
var camInfo = GetCameraInfo();
|
||
if (!camInfo) return;
|
||
|
||
// 相机位置为0时使用玩家位置
|
||
if (Math.abs(camInfo.pos.x) < 0.1 && Math.abs(camInfo.pos.y) < 0.1 && Math.abs(camInfo.pos.z) < 0.1) {
|
||
camInfo.pos = { x: playerPos.x, y: playerPos.y, z: playerPos.z + 100 };
|
||
}
|
||
|
||
var tankList = gm.m10b_1.j11y();
|
||
var count = tankList.e1();
|
||
var threats = [];
|
||
|
||
// 遍历所有敌方坦克,检测炮塔朝向
|
||
for (var i = 0; i < count; i++) {
|
||
var tank = tankList.g1(i);
|
||
if (!tank || tank === playerTank) continue;
|
||
if (!IsTankAlive(tank)) continue;
|
||
|
||
var pos = GetTankPosition(tank);
|
||
if (!pos) continue;
|
||
|
||
// 只检测敌方
|
||
if (IsFriendly(tank, playerTank)) continue;
|
||
|
||
var aimInfo = GetTurretAimAngle(tank, pos, playerPos);
|
||
if (!aimInfo) continue;
|
||
|
||
// 瞄准阈值随距离变化:近距离~11度(0.20rad),远距离~3度
|
||
// 公式:0.20 / (1 + distance/8000),乘以灵敏度系数
|
||
var aimThreshold = 0.20 / (1 + aimInfo.distance / 8000) * Sensitivity;
|
||
if (aimInfo.angle >= aimThreshold) continue;
|
||
|
||
// 墙体阻隔判断
|
||
var blocked = IsLineOfSightBlocked(pos, playerPos, tank.iz1_1, frameCount);
|
||
if (blocked) continue;
|
||
|
||
// 记录威胁
|
||
var proj = Project3D(pos, camInfo.pos, camInfo.yaw, camInfo.pitch, camInfo.fov, screenW, screenH);
|
||
var tankName = GetTankName(tank) || ('ID:' + tank.iz1_1);
|
||
threats.push({
|
||
pos: pos,
|
||
distance: aimInfo.distance,
|
||
angle: aimInfo.angle,
|
||
turretDir: aimInfo.turretDir,
|
||
screenProj: proj,
|
||
tankId: tank.iz1_1,
|
||
tankName: tankName
|
||
});
|
||
}
|
||
|
||
// ===== 死亡检测 =====
|
||
var playerHealth = GetTankHealth(playerTank);
|
||
var playerCurHealth = playerHealth ? playerHealth.current : 0;
|
||
// 检测血量从 >0 变为 0 = 死亡
|
||
if (PlayerPrevHealth > 0 && playerCurHealth <= 0) {
|
||
// 死亡时,从 threats 列表找最近的瞄准者作为可能的击杀者
|
||
var killer = null;
|
||
var minDist = Infinity;
|
||
for (var ki = 0; ki < threats.length; ki++) {
|
||
if (threats[ki].distance < minDist) {
|
||
minDist = threats[ki].distance;
|
||
killer = threats[ki];
|
||
}
|
||
}
|
||
DeathInfo = {
|
||
killerName: killer ? killer.tankName : null,
|
||
killerDistance: killer ? killer.distance : 0,
|
||
expected: threats.length > 0, // 有瞄准者 = 意料之内
|
||
threatCount: threats.length,
|
||
time: frameCount
|
||
};
|
||
DeathStats.total++;
|
||
if (DeathInfo.expected) DeathStats.expected++;
|
||
else DeathStats.unexpected++;
|
||
console.log('[瞄准] 玩家死亡! ' + (killer ? '被 ' + killer.tankName + ' 击杀' : '被偷击杀') +
|
||
' | 意料之' + (threats.length > 0 ? '内' : '外') +
|
||
' | 瞄准者: ' + threats.length +
|
||
' | 统计: ' + DeathStats.total + '死 (内' + DeathStats.expected + '/外' + DeathStats.unexpected + ')');
|
||
}
|
||
// 复活检测:血量恢复 >0
|
||
if (PlayerPrevHealth <= 0 && playerCurHealth > 0) {
|
||
DeathInfo = null;
|
||
console.log('[瞄准] 玩家复活,重置死亡信息');
|
||
}
|
||
PlayerPrevHealth = playerCurHealth;
|
||
|
||
// ===== 绘制瞄准线(每个威胁的弹道轨迹) =====
|
||
if (threats.length > 0) {
|
||
PlayBeep();
|
||
ctx.strokeStyle = 'rgba(255, 40, 40, 0.5)';
|
||
ctx.lineWidth = 2;
|
||
for (var ti = 0; ti < threats.length; ti++) {
|
||
var tr = threats[ti];
|
||
var startPos = { x: tr.pos.x, y: tr.pos.y, z: tr.pos.z + 200 }; // 炮塔高度
|
||
var endPos = { x: playerPos.x, y: playerPos.y, z: playerPos.z };
|
||
var segs = 20;
|
||
var prevScreen = null;
|
||
ctx.beginPath();
|
||
for (var si = 0; si <= segs; si++) {
|
||
var st = si / segs;
|
||
var swp = {
|
||
x: startPos.x + (endPos.x - startPos.x) * st,
|
||
y: startPos.y + (endPos.y - startPos.y) * st,
|
||
z: startPos.z + (endPos.z - startPos.z) * st
|
||
};
|
||
var sp = Project3D(swp, camInfo.pos, camInfo.yaw, camInfo.pitch, camInfo.fov, screenW, screenH);
|
||
if (sp) {
|
||
if (prevScreen) ctx.lineTo(sp.x, sp.y);
|
||
else ctx.moveTo(sp.x, sp.y);
|
||
prevScreen = sp;
|
||
} else {
|
||
prevScreen = null;
|
||
}
|
||
}
|
||
ctx.stroke();
|
||
}
|
||
}
|
||
|
||
// ===== 每个威胁的方向标记 =====
|
||
for (var thi = 0; thi < threats.length; thi++) {
|
||
var threat = threats[thi];
|
||
var proj = threat.screenProj;
|
||
|
||
if (proj && proj.x >= 0 && proj.x <= screenW && proj.y >= 0 && proj.y <= screenH) {
|
||
// ===== 威胁在屏幕内:在威胁位置画红色警告三角形 =====
|
||
var mx = proj.x, my = proj.y - 35;
|
||
ctx.strokeStyle = 'rgba(255, 0, 0, 1)';
|
||
ctx.lineWidth = 2.5;
|
||
ctx.fillStyle = 'rgba(255, 0, 0, 0.4)';
|
||
ctx.beginPath();
|
||
ctx.moveTo(mx, my - 10);
|
||
ctx.lineTo(mx - 9, my + 6);
|
||
ctx.lineTo(mx + 9, my + 6);
|
||
ctx.closePath();
|
||
ctx.fill();
|
||
ctx.stroke();
|
||
// 感叹号
|
||
ctx.fillStyle = 'rgba(255, 255, 255, 1)';
|
||
ctx.font = 'bold 13px Arial';
|
||
ctx.textAlign = 'center';
|
||
ctx.fillText('!', mx, my + 4);
|
||
// 距离标签
|
||
ctx.fillStyle = 'rgba(255, 200, 200, 0.95)';
|
||
ctx.font = 'bold 11px Arial';
|
||
ctx.fillText(Math.round(threat.distance / 100) + 'm', mx, my + 22);
|
||
// 坦克名称
|
||
ctx.fillStyle = 'rgba(255, 240, 240, 0.95)';
|
||
ctx.font = 'bold 11px Arial';
|
||
ctx.fillText(threat.tankName, mx, my + 36);
|
||
} else {
|
||
// ===== 威胁不在屏幕内:屏幕边缘红色箭头指示方向 =====
|
||
var tdx = threat.pos.x - playerPos.x;
|
||
var tdy = threat.pos.y - playerPos.y;
|
||
var tlen = Math.sqrt(tdx * tdx + tdy * tdy);
|
||
if (tlen > 0.001) {
|
||
var ndx = tdx / tlen, ndy = tdy / tlen;
|
||
// 计算相对相机偏航角的方向
|
||
var fwdX = -Math.sin(camInfo.yaw), fwdY = Math.cos(camInfo.yaw);
|
||
var rgtX = Math.cos(camInfo.yaw), rgtY = Math.sin(camInfo.yaw);
|
||
var screenRight = ndx * rgtX + ndy * rgtY;
|
||
var screenFwd = ndx * fwdX + ndy * fwdY;
|
||
var sAng = Math.atan2(screenRight, screenFwd);
|
||
var edgeR = Math.min(screenW, screenH) * 0.42;
|
||
var ex = screenW / 2 + Math.sin(sAng) * edgeR;
|
||
var ey = screenH / 2 - Math.cos(sAng) * edgeR;
|
||
// 限制在屏幕边缘内
|
||
ex = Math.max(30, Math.min(screenW - 30, ex));
|
||
ey = Math.max(30, Math.min(screenH - 30, ey));
|
||
|
||
// 画箭头(指向威胁方向)
|
||
ctx.fillStyle = 'rgba(255, 0, 0, 0.95)';
|
||
ctx.strokeStyle = 'rgba(255, 255, 255, 0.8)';
|
||
ctx.lineWidth = 1.5;
|
||
ctx.beginPath();
|
||
ctx.moveTo(ex + Math.sin(sAng) * 14, ey - Math.cos(sAng) * 14);
|
||
ctx.lineTo(ex + Math.sin(sAng + 2.5) * 9, ey - Math.cos(sAng + 2.5) * 9);
|
||
ctx.lineTo(ex + Math.sin(sAng - 2.5) * 9, ey - Math.cos(sAng - 2.5) * 9);
|
||
ctx.closePath();
|
||
ctx.fill();
|
||
ctx.stroke();
|
||
|
||
// 距离标签
|
||
ctx.fillStyle = 'rgba(255, 255, 255, 0.95)';
|
||
ctx.font = 'bold 11px Arial';
|
||
ctx.textAlign = 'center';
|
||
ctx.fillText(Math.round(threat.distance / 100) + 'm', ex, ey + 22);
|
||
// 坦克名称
|
||
ctx.fillStyle = 'rgba(255, 240, 240, 0.95)';
|
||
ctx.font = 'bold 10px Arial';
|
||
ctx.fillText(threat.tankName, ex, ey + 36);
|
||
}
|
||
}
|
||
}
|
||
|
||
// ===== 左侧中央常显警告框(有威胁时) =====
|
||
if (threats.length > 0) {
|
||
WarningPulse += 0.15;
|
||
var pulseAlpha = 0.55 + Math.sin(WarningPulse) * 0.2; // 脉动透明度
|
||
|
||
// 动态高度:标题区 + 威胁列表
|
||
var maxListed = 4; // 最多列出4个威胁名称
|
||
var listedCount = Math.min(threats.length, maxListed);
|
||
var boxW = 240;
|
||
var boxH = 70 + listedCount * 18 + (threats.length > maxListed ? 16 : 0);
|
||
var boxX = 20;
|
||
var boxY = screenH / 2 - boxH / 2;
|
||
|
||
// 警告框背景(脉动)
|
||
ctx.fillStyle = 'rgba(80, 0, 0, ' + pulseAlpha + ')';
|
||
ctx.fillRect(boxX, boxY, boxW, boxH);
|
||
ctx.strokeStyle = 'rgba(255, 0, 0, 1)';
|
||
ctx.lineWidth = 2.5;
|
||
ctx.strokeRect(boxX, boxY, boxW, boxH);
|
||
|
||
// 警告标题
|
||
ctx.fillStyle = 'rgba(255, 60, 60, 1)';
|
||
ctx.font = 'bold 26px Arial';
|
||
ctx.textAlign = 'center';
|
||
ctx.fillText('⚠ 被瞄准', boxX + boxW / 2, boxY + 32);
|
||
ctx.font = 'bold 13px Arial';
|
||
ctx.fillStyle = 'rgba(255, 220, 220, 0.95)';
|
||
ctx.fillText(threats.length + ' 个威胁锁定你', boxX + boxW / 2, boxY + 54);
|
||
|
||
// 威胁坦克名称列表
|
||
ctx.textAlign = 'left';
|
||
ctx.font = 'bold 12px Arial';
|
||
for (var li = 0; li < listedCount; li++) {
|
||
var t = threats[li];
|
||
var lineY = boxY + 72 + li * 18;
|
||
// 名称 + 距离
|
||
ctx.fillStyle = 'rgba(255, 240, 240, 0.95)';
|
||
ctx.fillText('• ' + t.tankName, boxX + 12, lineY);
|
||
ctx.fillStyle = 'rgba(255, 180, 180, 0.85)';
|
||
ctx.textAlign = 'right';
|
||
ctx.fillText(Math.round(t.distance / 100) + 'm', boxX + boxW - 12, lineY);
|
||
ctx.textAlign = 'left';
|
||
}
|
||
// 超出部分
|
||
if (threats.length > maxListed) {
|
||
ctx.fillStyle = 'rgba(255, 200, 200, 0.7)';
|
||
ctx.font = 'italic 11px Arial';
|
||
ctx.textAlign = 'center';
|
||
ctx.fillText('等 ' + (threats.length - maxListed) + ' 个...', boxX + boxW / 2, boxY + 72 + listedCount * 18 + 12);
|
||
}
|
||
}
|
||
|
||
// ===== 死亡信息显示(屏幕中央,持续6秒或直到复活) =====
|
||
if (DeathInfo && (frameCount - DeathInfo.time) < DeathDisplayTime) {
|
||
var dBoxW = 400, dBoxH = 120;
|
||
var dBoxX = screenW / 2 - dBoxW / 2;
|
||
var dBoxY = screenH / 2 - dBoxH / 2 - 60; // 偏上一点
|
||
|
||
// 半透明背景
|
||
ctx.fillStyle = 'rgba(0, 0, 0, 0.75)';
|
||
ctx.fillRect(dBoxX, dBoxY, dBoxW, dBoxH);
|
||
// 边框(意料之内=红色,意料之外=紫色)
|
||
var borderColor = DeathInfo.expected ? 'rgba(255, 60, 60, 1)' : 'rgba(200, 80, 255, 1)';
|
||
ctx.strokeStyle = borderColor;
|
||
ctx.lineWidth = 3;
|
||
ctx.strokeRect(dBoxX, dBoxY, dBoxW, dBoxH);
|
||
|
||
// 标题
|
||
ctx.textAlign = 'center';
|
||
ctx.fillStyle = 'rgba(255, 80, 80, 1)';
|
||
ctx.font = 'bold 26px Arial';
|
||
ctx.fillText('你被击杀', dBoxX + dBoxW / 2, dBoxY + 36);
|
||
|
||
// 击杀者信息
|
||
ctx.font = 'bold 18px Arial';
|
||
if (DeathInfo.killerName) {
|
||
ctx.fillStyle = 'rgba(255, 220, 220, 1)';
|
||
ctx.fillText('击杀者: ' + DeathInfo.killerName, dBoxX + dBoxW / 2, dBoxY + 66);
|
||
ctx.font = '12px Arial';
|
||
ctx.fillStyle = 'rgba(255, 180, 180, 0.8)';
|
||
ctx.fillText('距离 ' + Math.round(DeathInfo.killerDistance / 100) + 'm', dBoxX + dBoxW / 2, dBoxY + 86);
|
||
} else {
|
||
ctx.fillStyle = 'rgba(220, 180, 255, 1)';
|
||
ctx.fillText('未知来源(偷袭)', dBoxX + dBoxW / 2, dBoxY + 66);
|
||
}
|
||
|
||
// 意料之内/外标签
|
||
ctx.font = 'bold 16px Arial';
|
||
if (DeathInfo.expected) {
|
||
ctx.fillStyle = 'rgba(255, 100, 100, 1)';
|
||
ctx.fillText('⚠ 意料之内(已在瞄准名单中)', dBoxX + dBoxW / 2, dBoxY + 108);
|
||
} else {
|
||
ctx.fillStyle = 'rgba(200, 100, 255, 1)';
|
||
ctx.fillText('✦ 意料之外(偷袭!)', dBoxX + dBoxW / 2, dBoxY + 108);
|
||
}
|
||
}
|
||
|
||
// ===== 顶部状态栏(始终显示) =====
|
||
ctx.fillStyle = 'rgba(255, 255, 255, 0.4)';
|
||
ctx.font = '11px Arial';
|
||
ctx.textAlign = 'left';
|
||
var statusText = '[瞄准警告] 监控中 | 威胁: ' + threats.length +
|
||
' | 死亡: ' + DeathStats.total + '(内' + DeathStats.expected + '/外' + DeathStats.unexpected + ')' +
|
||
' | Frame: ' + frameCount;
|
||
ctx.fillText(statusText, 5, 18);
|
||
}
|
||
|
||
// ===== 启动 =====
|
||
Loop();
|
||
console.log('[瞄准] 警告系统已启动,监控敌方坦克炮塔朝向');
|
||
|
||
// ===== 暴露控制接口 =====
|
||
window.__hornetAimWarning__ = {
|
||
running: true,
|
||
Stop: function() {
|
||
this.running = false;
|
||
running = false;
|
||
window.removeEventListener('resize', ResizeCanvas);
|
||
if (canvas && canvas.parentNode) canvas.parentNode.removeChild(canvas);
|
||
console.log('[瞄准] 警告系统已停止');
|
||
},
|
||
GetDeathStats: function() {
|
||
return DeathStats;
|
||
},
|
||
ResetDeathStats: function() {
|
||
DeathStats = { total: 0, expected: 0, unexpected: 0 };
|
||
console.log('[瞄准] 死亡统计已重置');
|
||
},
|
||
setSensitivity: function(s) {
|
||
var n = parseFloat(s);
|
||
if (!isNaN(n) && n >= 0.3 && n <= 3.0) {
|
||
Sensitivity = n;
|
||
try { localStorage.setItem('hornet_aim_sensitivity', String(n)); } catch(e) {}
|
||
console.log('[瞄准] 灵敏度设为: ' + n);
|
||
} else {
|
||
console.warn('[瞄准] 灵敏度应在 0.3~3.0');
|
||
}
|
||
},
|
||
getSensitivity: function() { return Sensitivity; },
|
||
setSound: function(on) {
|
||
SoundEnabled = !!on;
|
||
try { localStorage.setItem('hornet_aim_sound', SoundEnabled ? 'true' : 'false'); } catch(e) {}
|
||
console.log('[瞄准] 警告声音: ' + (SoundEnabled ? '开' : '关'));
|
||
},
|
||
getSound: function() { return SoundEnabled; }
|
||
};
|
||
}
|
||
|
||
var Tool = {
|
||
Start: function() {
|
||
if (window.__hornetAimWarning__ && window.__hornetAimWarning__.running) return;
|
||
_start();
|
||
},
|
||
Stop: function() {
|
||
if (window.__hornetAimWarning__) window.__hornetAimWarning__.Stop();
|
||
},
|
||
Status: function() {
|
||
return (window.__hornetAimWarning__ && window.__hornetAimWarning__.running) ? '运行中' : '已停止';
|
||
},
|
||
GetRunning: function() {
|
||
return !!(window.__hornetAimWarning__ && window.__hornetAimWarning__.running);
|
||
},
|
||
SetSensitivity: function(s) { if (window.__hornetAimWarning__) window.__hornetAimWarning__.setSensitivity(s); },
|
||
GetSensitivity: function() { return window.__hornetAimWarning__ ? window.__hornetAimWarning__.getSensitivity() : 1.0; },
|
||
SetSound: function(on) { if (window.__hornetAimWarning__) window.__hornetAimWarning__.setSound(on); },
|
||
GetSound: function() { return window.__hornetAimWarning__ ? window.__hornetAimWarning__.getSound() : false; }
|
||
};
|
||
|
||
window.__hornetTools__ = window.__hornetTools__ || {};
|
||
window.__hornetTools__.AimWarning = Tool;
|
||
console.log('[3DTank] 瞄准警告模块已加载');
|
||
})();
|
||
|
||
|
||
// ===== hornet_freelook.js =====
|
||
// ===== 3DTank自由视角 =====
|
||
// 在浏览器控制台运行(需先运行 hook_gamemode.js 和 hornet_lib.js)
|
||
// 功能:解锁主相机,WASD移动/鼠标右键旋转/QE升降/Shift加速/滚轮调速
|
||
// 原理:Hook mainCamera.h37,在相机矩阵更新前覆盖l35_1/k35_1参数
|
||
|
||
(function() {
|
||
function _start() {
|
||
console.log('===== 3DTank自由视角启动 =====');
|
||
|
||
if (!window.__gameMode__) {
|
||
console.error('请先运行 hook_gamemode.js');
|
||
return;
|
||
}
|
||
|
||
if (window.__hornetFreeLook__) {
|
||
window.__hornetFreeLook__.Stop();
|
||
console.log('已停止旧自由视角');
|
||
}
|
||
|
||
var gm = window.__gameMode__;
|
||
|
||
// ===== 引用公共库 =====
|
||
var lib = window.__hornetLib__;
|
||
if (!lib) {
|
||
console.error('[自由视角] 请先运行 hornet_lib.js');
|
||
return;
|
||
}
|
||
function GetPlayerTank() { return lib.GetPlayerTank(gm); }
|
||
function GetTankPosition(tank) { return lib.GetTankPosition(tank); }
|
||
|
||
// ===== 获取游戏渲染对象 =====
|
||
var scene = null, mainCamera = null, ff = null;
|
||
var initError = null;
|
||
|
||
try {
|
||
scene = gm.lz4().jz1_1;
|
||
mainCamera = scene.iyx_1;
|
||
ff = scene.gyw_1.d75_1;
|
||
if (!mainCamera || !mainCamera.u35_1 || !mainCamera.r35_1 || !mainCamera.o37) {
|
||
throw new Error('相机字段缺失');
|
||
}
|
||
console.log('[自由视角] 主相机:', mainCamera.constructor.name,
|
||
'l35=(' + mainCamera.u35_1.f20_1.toFixed(2) + ',' + mainCamera.u35_1.g20_1.toFixed(2) + ',' + mainCamera.u35_1.h20_1.toFixed(2) + ')',
|
||
'k35=(' + mainCamera.r35_1.f20_1.toFixed(0) + ',' + mainCamera.r35_1.g20_1.toFixed(0) + ',' + mainCamera.r35_1.h20_1.toFixed(0) + ')');
|
||
} catch(e) {
|
||
initError = e;
|
||
console.error('[自由视角] 初始化失败:', e.message);
|
||
}
|
||
if (initError) return;
|
||
|
||
// ===== 自由视角状态 =====
|
||
var freeLookActive = false;
|
||
var running = true;
|
||
|
||
// 自由视角参数(从当前相机参数初始化)
|
||
var freeYaw = 0, freePitch = 0, freeRoll = 0;
|
||
var freePosX = 0, freePosY = 0, freePosZ = 0;
|
||
var moveSpeed = 1200; // 基础移动速度(世界单位/秒)
|
||
var speedMultiplier = 1; // 滚轮调速倍数(0.2~5.0)
|
||
|
||
// 按键状态
|
||
var keys = {};
|
||
var mouseSensitivity = 0.005; // 鼠标灵敏度
|
||
|
||
// ===== Hook mainCamera.h37 =====
|
||
var hadOwnH37 = mainCamera.hasOwnProperty('o37');
|
||
var origH37 = mainCamera.o37;
|
||
|
||
function applyFreeLookParams() {
|
||
mainCamera.u35_1.f20_1 = freePitch; // pitch (f)
|
||
mainCamera.u35_1.g20_1 = freeRoll; // roll (g) 保持初始值
|
||
mainCamera.u35_1.h20_1 = freeYaw; // yaw (h) 鼠标左右控制
|
||
mainCamera.r35_1.f20_1 = freePosX;
|
||
mainCamera.r35_1.g20_1 = freePosY;
|
||
mainCamera.r35_1.h20_1 = freePosZ;
|
||
}
|
||
|
||
// 缓存从 viewMatrix 提取的前方向/右方向(h37 更新后设置)
|
||
// o37 行向量布局(列主序存储):
|
||
// 行0 = (d[0], d[4], d[8]) = right 向量
|
||
// 行1 = (d[1], d[5], d[9]) = up 向量
|
||
// 行2 = (d[2], d[6], d[10]) = -forward 向量
|
||
var cachedFwdX = 0, cachedFwdY = 0, cachedRightX = 0, cachedRightY = 0;
|
||
var cachedInitialized = false;
|
||
var suspended = false; // 后视镜等模块渲染时临时暂停 hook
|
||
|
||
mainCamera.o37 = function() {
|
||
if (freeLookActive && !suspended) {
|
||
applyFreeLookParams();
|
||
origH37.call(this);
|
||
// 从更新后的 viewMatrix (u36_1) 提取前方向和右方向(水平分量 x,y)
|
||
if (this.b37_1) {
|
||
cachedFwdX = -this.b37_1[2]; // forward.x
|
||
cachedFwdY = -this.b37_1[6]; // forward.y
|
||
cachedRightX = this.b37_1[0]; // right.x
|
||
cachedRightY = this.b37_1[4]; // right.y
|
||
cachedInitialized = true;
|
||
}
|
||
} else {
|
||
return origH37.call(this);
|
||
}
|
||
};
|
||
console.log('[自由视角] mainCamera.o37 已 Hook');
|
||
|
||
// ===== 开启/关闭自由视角 =====
|
||
function enableFreeLook() {
|
||
// 从当前相机参数初始化(平滑过渡)
|
||
// l35_1: f=pitch, g=roll, h=yaw
|
||
freePitch = mainCamera.u35_1.f20_1;
|
||
freeRoll = mainCamera.u35_1.g20_1; // roll 保持初始值
|
||
freeYaw = mainCamera.u35_1.h20_1; // yaw 鼠标控制
|
||
freePosX = mainCamera.r35_1.f20_1;
|
||
freePosY = mainCamera.r35_1.g20_1;
|
||
freePosZ = mainCamera.r35_1.h20_1;
|
||
freeLookActive = true;
|
||
console.log('[自由视角] 已开启 — WASD移动 | 鼠标旋转 | 空格上升/Shift下降 | 滚轮调速 | Alt+F关闭');
|
||
updateHud();
|
||
}
|
||
|
||
function disableFreeLook() {
|
||
freeLookActive = false;
|
||
keys = {}; // 清空按键状态,防止卡死
|
||
console.log('[自由视角] 已关闭,恢复游戏相机控制');
|
||
updateHud();
|
||
}
|
||
|
||
function toggleFreeLook() {
|
||
if (freeLookActive) disableFreeLook();
|
||
else enableFreeLook();
|
||
}
|
||
|
||
// ===== 键盘事件(capture阶段拦截,防止游戏接收WASD) =====
|
||
// 用 e.code(物理键位)匹配,比 e.key 更稳定,不受 Shift 修饰影响
|
||
var keyMap = {
|
||
'KeyW': 'w', 'KeyA': 'a', 'KeyS': 's', 'KeyD': 'd',
|
||
'KeyQ': 'q', 'KeyE': 'e',
|
||
'Space': ' ',
|
||
'ShiftLeft': 'shift', 'ShiftRight': 'shift'
|
||
};
|
||
|
||
function onKeyDown(e) {
|
||
// 焦点在输入框/选择框时不拦截(允许面板输入)
|
||
var ae = document.activeElement;
|
||
if (ae && (ae.tagName === 'INPUT' || ae.tagName === 'SELECT' || ae.tagName === 'TEXTAREA')) return;
|
||
// Alt+F 切换(全局,无论是否开启)
|
||
if (e.altKey && (e.key === 'f' || e.key === 'F')) {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
toggleFreeLook();
|
||
return;
|
||
}
|
||
if (!freeLookActive) return;
|
||
var key = keyMap[e.code] || e.key.toLowerCase();
|
||
if (keyMap[e.code]) {
|
||
keys[key] = true;
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
}
|
||
}
|
||
|
||
function onKeyUp(e) {
|
||
// 焦点在输入框/选择框时不拦截
|
||
var ae = document.activeElement;
|
||
if (ae && (ae.tagName === 'INPUT' || ae.tagName === 'SELECT' || ae.tagName === 'TEXTAREA')) return;
|
||
var key = keyMap[e.code] || e.key.toLowerCase();
|
||
if (keyMap[e.code]) {
|
||
keys[key] = false; // 总是清理按键状态,防止卡死
|
||
// 只在自由视角激活时拦截事件,暂停时放行给游戏
|
||
if (freeLookActive) {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
}
|
||
}
|
||
}
|
||
|
||
// ===== 鼠标事件(自由视角激活时鼠标移动直接旋转,不按键) =====
|
||
function onMouseMove(e) {
|
||
if (!freeLookActive) return;
|
||
// 鼠标左移→视角左转(yaw减),鼠标上移→视角上抬(pitch增)
|
||
freeYaw -= e.movementX * mouseSensitivity;
|
||
freePitch -= e.movementY * mouseSensitivity;
|
||
// 限制俯仰角到±80°,避免接近90°时的万向锁翻转
|
||
var limit = 80 * Math.PI / 180;
|
||
if (freePitch > limit) freePitch = limit;
|
||
if (freePitch < -limit) freePitch = -limit;
|
||
}
|
||
|
||
function onWheel(e) {
|
||
if (!freeLookActive) return;
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
// 滚轮上=加速,下=减速
|
||
if (e.deltaY < 0) speedMultiplier = Math.min(5.0, speedMultiplier + 0.2);
|
||
else speedMultiplier = Math.max(0.2, speedMultiplier - 0.2);
|
||
updateHud();
|
||
}
|
||
|
||
// capture 阶段拦截键盘
|
||
window.addEventListener('keydown', onKeyDown, true);
|
||
window.addEventListener('keyup', onKeyUp, true);
|
||
document.addEventListener('mousemove', onMouseMove);
|
||
window.addEventListener('wheel', onWheel, true);
|
||
|
||
// ===== HUD 显示 =====
|
||
var hud = document.createElement('div');
|
||
hud.id = 'hornetFreeLookHud';
|
||
hud.style.cssText = 'position:fixed;bottom:10px;left:10px;z-index:99999;' +
|
||
'background:rgba(0,0,0,0.7);color:#8f8;padding:6px 10px;border-radius:4px;' +
|
||
'font:12px monospace;pointer-events:none;border:1px solid rgba(120,255,120,0.5);' +
|
||
'display:none;user-select:none;';
|
||
document.body.appendChild(hud);
|
||
|
||
function updateHud() {
|
||
if (!freeLookActive) { hud.style.display = 'none'; return; }
|
||
hud.style.display = 'block';
|
||
hud.innerHTML =
|
||
'[自由视角 ON] 速度:' + (moveSpeed * speedMultiplier).toFixed(0) + '/秒<br>' +
|
||
'位置:(' + freePosX.toFixed(0) + ',' + freePosY.toFixed(0) + ',' + freePosZ.toFixed(0) + ')<br>' +
|
||
'Yaw:' + freeYaw.toFixed(2) + ' Pitch:' + freePitch.toFixed(2) + '<br>' +
|
||
'WASD移动 鼠标旋转 空格上升/Shift下降 滚轮调速 Alt+F关闭';
|
||
}
|
||
|
||
// ===== 主循环:处理按键 → 更新位置 =====
|
||
var rafId = null;
|
||
var lastTime = performance.now();
|
||
|
||
function loop(now) {
|
||
if (!running) return;
|
||
|
||
// 换图自刷新:gameMode 实例变化时整体重启,重新 Hook 新相机的 o37
|
||
var NewGm = window.__gameMode__;
|
||
if (NewGm && NewGm !== gm) {
|
||
console.log('[自由视角] 检测到新战斗,正在重启以绑定新相机...');
|
||
self.Stop();
|
||
_start();
|
||
return;
|
||
}
|
||
// 旧实例已失效、新实例尚未捕获:跳过本帧
|
||
if (!window.__gameMode__) { rafId = requestAnimationFrame(loop); return; }
|
||
|
||
var dt = (now - lastTime) / 1000;
|
||
lastTime = now;
|
||
if (dt > 0.1) dt = 0.1; // 防止卡顿后大跳
|
||
|
||
if (freeLookActive) {
|
||
var speed = moveSpeed * speedMultiplier * dt; // 每秒 moveSpeed 单位
|
||
|
||
// 相机前方向/右方向:优先用 viewMatrix 提取的真实方向(跟随鼠标朝向)
|
||
var fwdX, fwdY, rightX, rightY;
|
||
if (cachedInitialized) {
|
||
// 从 viewMatrix 提取的水平方向分量(列主序)
|
||
fwdX = cachedFwdX;
|
||
fwdY = cachedFwdY;
|
||
rightX = cachedRightX;
|
||
rightY = cachedRightY;
|
||
// 归一化水平方向(pitch 不为0时向量长度<1,保持移动速度一致)
|
||
var fwdLen = Math.sqrt(fwdX * fwdX + fwdY * fwdY);
|
||
if (fwdLen > 1e-6) { fwdX /= fwdLen; fwdY /= fwdLen; }
|
||
var rightLen = Math.sqrt(rightX * rightX + rightY * rightY);
|
||
if (rightLen > 1e-6) { rightX /= rightLen; rightY /= rightLen; }
|
||
} else {
|
||
// fallback: sin/cos(viewMatrix 尚未初始化时)
|
||
fwdX = -Math.sin(freeYaw);
|
||
fwdY = -Math.cos(freeYaw);
|
||
rightX = Math.cos(freeYaw);
|
||
rightY = -Math.sin(freeYaw);
|
||
}
|
||
|
||
var moved = false;
|
||
// W/S 前后(水平移动,可与其他键组合)
|
||
if (keys['w']) { freePosX += fwdX * speed; freePosY += fwdY * speed; moved = true; }
|
||
if (keys['s']) { freePosX -= fwdX * speed; freePosY -= fwdY * speed; moved = true; }
|
||
// A/D 左右(水平移动,可与其他键组合)
|
||
if (keys['a']) { freePosX -= rightX * speed; freePosY -= rightY * speed; moved = true; }
|
||
if (keys['d']) { freePosX += rightX * speed; freePosY += rightY * speed; moved = true; }
|
||
// 空格上升 / Shift 下降(绝对 z 轴,可与 WASD 组合)
|
||
if (keys[' ']) { freePosZ += speed; moved = true; }
|
||
if (keys['shift']) { freePosZ -= speed; moved = true; }
|
||
// Q/E 升降(备用)
|
||
if (keys['q']) { freePosZ -= speed; moved = true; }
|
||
if (keys['e']) { freePosZ += speed; moved = true; }
|
||
|
||
if (moved) updateHud();
|
||
}
|
||
|
||
rafId = requestAnimationFrame(loop);
|
||
}
|
||
rafId = requestAnimationFrame(loop);
|
||
|
||
// ===== 对外接口 =====
|
||
var self = {
|
||
Stop: function() {
|
||
running = false;
|
||
if (rafId) cancelAnimationFrame(rafId);
|
||
disableFreeLook();
|
||
// 恢复 h37
|
||
if (hadOwnH37) mainCamera.o37 = origH37;
|
||
else delete mainCamera.o37;
|
||
// 移除监听
|
||
window.removeEventListener('keydown', onKeyDown, true);
|
||
window.removeEventListener('keyup', onKeyUp, true);
|
||
document.removeEventListener('mousemove', onMouseMove);
|
||
window.removeEventListener('wheel', onWheel, true);
|
||
if (hud.parentNode) hud.parentNode.removeChild(hud);
|
||
window.__hornetFreeLook__ = null;
|
||
console.log('[自由视角] 已停止');
|
||
},
|
||
Status: function() {
|
||
// 返回字符串供面板 HUD 显示(其他工具也是字符串)
|
||
if (!running) return '未运行';
|
||
return freeLookActive ? '已开启' : '已暂停';
|
||
},
|
||
GetRunning: function() { return freeLookActive; },
|
||
IsActive: function() { return freeLookActive; },
|
||
Toggle: function() { toggleFreeLook(); },
|
||
Enable: function() { if (!freeLookActive) enableFreeLook(); },
|
||
Disable: function() { if (freeLookActive) disableFreeLook(); },
|
||
// 供其他模块(雷达/3D透视)获取虚拟相机参数
|
||
GetPosition: function() { return freeLookActive ? { x: freePosX, y: freePosY, z: freePosZ } : null; },
|
||
GetDirection: function() { return freeLookActive ? { yaw: freeYaw, pitch: freePitch } : null; },
|
||
// 供后视镜等模块渲染时临时暂停 h37 hook
|
||
SetSuspended: function(s) { suspended = s; }
|
||
};
|
||
|
||
window.__hornetFreeLook__ = self;
|
||
window.__hornetTools__ = window.__hornetTools__ || {};
|
||
window.__hornetTools__.FreeLook = self;
|
||
|
||
// 启动时自动激活自由视角
|
||
enableFreeLook();
|
||
|
||
console.log('[自由视角] 就绪 — 已激活,Alt+F 切换激活/暂停');
|
||
}
|
||
|
||
function _stop() {
|
||
if (window.__hornetFreeLook__) window.__hornetFreeLook__.Stop();
|
||
}
|
||
function _status() {
|
||
return window.__hornetFreeLook__ ? window.__hornetFreeLook__.Status() : '未运行';
|
||
}
|
||
function _getRunning() {
|
||
return !!(window.__hornetFreeLook__ && window.__hornetFreeLook__.GetRunning());
|
||
}
|
||
|
||
window.__hornetTools__ = window.__hornetTools__ || {};
|
||
window.__hornetTools__.FreeLook = {
|
||
Start: _start,
|
||
Stop: _stop,
|
||
Status: _status,
|
||
GetRunning: _getRunning
|
||
};
|
||
})();
|
||
|
||
|
||
// ===== hornet_warsandbox.js =====
|
||
// ===== 3DTank 战争沙盘 - 真实3D俯瞰渲染 =====
|
||
// 在浏览器控制台运行(需先运行 hook_gamemode.js 和 hornet_lib.js)
|
||
// 功能:80%x90% 可拖拽缩放的全景俯瞰图,使用游戏原生渲染器渲染 3D 场景
|
||
// 原理:Hook gl.bindFramebuffer 重定向到自定义 FBO,用俯瞰相机参数调用游戏渲染器
|
||
// 操作:拖拽标题移动窗口 | 拖拽地图平移 | 滚轮缩放 | 双击居中 | Alt+M 开关
|
||
|
||
(function() {
|
||
function _start() {
|
||
console.log('===== 3DTank 战争沙盘(3D俯瞰版)启动 =====');
|
||
|
||
if (!window.__gameMode__) {
|
||
console.error('请先运行 hook_gamemode.js');
|
||
return;
|
||
}
|
||
if (window.__hornetWarSandbox__) {
|
||
window.__hornetWarSandbox__.Stop();
|
||
console.log('已停止旧战争沙盘');
|
||
}
|
||
|
||
var gm = window.__gameMode__;
|
||
var lib = window.__hornetLib__;
|
||
if (!lib) {
|
||
console.error('[沙盘] 请先运行 hornet_lib.js');
|
||
return;
|
||
}
|
||
var teamFns = lib.LoadTeamFunctions();
|
||
var FindComponentByField = lib.FindComponentByField;
|
||
var GetTankPosition = lib.GetTankPosition;
|
||
var GetTankState = lib.GetTankState;
|
||
var IsTankAlive = lib.IsTankAlive;
|
||
var GetTankName = lib.GetTankName;
|
||
var GetTankHealth = lib.GetTankHealth;
|
||
function GetPlayerTank() { return lib.GetPlayerTank(gm); }
|
||
function IsFriendly(tank, playerTank) { return lib.IsFriendly(tank, playerTank, gm); }
|
||
|
||
function getTankDirection(tank) {
|
||
// 从车体皮肤组件(KMt)的Bp节点世界矩阵提取前向方向
|
||
// dzc_1 是局部空间向量且可能未设置,不可靠
|
||
// 皮肤字段:新 build 是 w14w_1;旧 build 是 t14l_1(新版 t14l_1 已改成悬停车体状态 Qmt)
|
||
var hullComp = FindComponentByField(tank, 'w14w_1') ||
|
||
FindComponentByField(tank, 't14l_1') ||
|
||
FindComponentByField(tank, 'w14x_1') ||
|
||
FindComponentByField(tank, 'p14l_1');
|
||
if (!hullComp) return null;
|
||
var skin = (hullComp.w14w_1 && hullComp.w14w_1.g17r_1) ? hullComp.w14w_1 :
|
||
(hullComp.t14l_1 && hullComp.t14l_1.g17r_1) ? hullComp.t14l_1 :
|
||
(hullComp.p14l_1 && hullComp.p14l_1.g17r_1) ? hullComp.p14l_1 : null;
|
||
if (!skin) return null;
|
||
try {
|
||
var partCount = skin.g17r_1.e1();
|
||
for (var p = 0; p < partCount; p++) {
|
||
var part = skin.g17r_1.g1(p);
|
||
if (!part || !part.j17r_1) continue;
|
||
var nodeCount = part.j17r_1.e1();
|
||
for (var n = 0; n < nodeCount; n++) {
|
||
var bpNode = part.j17r_1.g1(n);
|
||
if (!bpNode || !bpNode.n35_1) continue;
|
||
// 不按 g35_1 过滤:它是引擎的细节/淡出权重(0~1),不是幽灵标志,
|
||
// 过滤会漏掉低细节节点、导致取不到朝向
|
||
var m = bpNode.n35_1;
|
||
// 列主序世界矩阵:Col0=(m[0],m[1],m[2]) Col1=(m[4],m[5],m[6]) Col2=(m[8],m[9],m[10])
|
||
// 游戏坐标:x,y 水平面; z(h20_1)=高度。找"上"列(|z|最大),其余为水平方向
|
||
var az0 = Math.abs(m[2]), az1 = Math.abs(m[6]), az2 = Math.abs(m[10]);
|
||
// 尝试 Col1(Y前向约定,Z-up游戏常见)作为前向,若 Col1 是"上"则用 Col2
|
||
var fx, fy;
|
||
if (az1 < 0.7) {
|
||
// Col1 不是上方向,用作前向
|
||
fx = m[4]; fy = m[5];
|
||
} else if (az2 < 0.7) {
|
||
// Col1 是上,用 Col2 作前向
|
||
fx = m[8]; fy = m[9];
|
||
} else {
|
||
// Col0 不是上,用作前向
|
||
fx = m[0]; fy = m[1];
|
||
}
|
||
var len = Math.sqrt(fx*fx + fy*fy);
|
||
if (len > 0.01) {
|
||
return { x: fx/len, y: fy/len, z: 0 };
|
||
}
|
||
}
|
||
}
|
||
} catch(e) {}
|
||
return null;
|
||
}
|
||
|
||
// ===== 获取游戏渲染对象 =====
|
||
var scene = null, ff = null, rootNode = null, mainCamera = null, gl = null;
|
||
var initError = null;
|
||
try {
|
||
scene = gm.lz4().jz1_1;
|
||
ff = scene.gyw_1.d75_1;
|
||
rootNode = scene.fyx_1;
|
||
mainCamera = scene.iyx_1;
|
||
gl = ff.w39_1.b74_1;
|
||
if (!ff || !rootNode || !mainCamera || !gl) throw new Error('关键字段缺失');
|
||
} catch(e) {
|
||
initError = e;
|
||
console.error('[沙盘] 初始化失败:', e.message);
|
||
}
|
||
if (initError) return;
|
||
|
||
// ===== FBO 配置(固定内部分辨率,性能优先)=====
|
||
var FBO_W = 960, FBO_H = 720;
|
||
var Config = {
|
||
fov: 1.0, // 俯瞰 FOV
|
||
near: 100, far: 80000,
|
||
minHeight: 3000, // 最小相机高度(放大)
|
||
maxHeight: 60000, // 最大相机高度(缩小)
|
||
defaultHeight: 20000
|
||
};
|
||
// 设置参数(localStorage 持久化)
|
||
var ShowGrid = true;
|
||
try {
|
||
if (localStorage.getItem('hornet_ws_showgrid') === 'false') ShowGrid = false;
|
||
} catch(e) {}
|
||
var MarkerSize = 1.0;
|
||
try {
|
||
var __savedMS = localStorage.getItem('hornet_ws_marker_size');
|
||
if (__savedMS) {
|
||
var __ms = parseFloat(__savedMS);
|
||
if (!isNaN(__ms) && __ms >= 0.5 && __ms <= 3.0) MarkerSize = __ms;
|
||
}
|
||
} catch(e) {}
|
||
|
||
// ===== 创建 UI =====
|
||
var container = document.createElement('div');
|
||
container.id = 'hornetWarSandbox';
|
||
var winW = Math.floor(window.innerWidth * 0.8);
|
||
var winH = Math.floor(window.innerHeight * 0.9);
|
||
container.style.cssText =
|
||
'position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);z-index:100002;' +
|
||
'width:' + winW + 'px;height:' + winH + 'px;' +
|
||
'background:rgba(8,12,20,0.82);backdrop-filter:blur(16px);-webkit-backdrop-filter:blur(16px);' +
|
||
'border:1px solid rgba(125,249,255,0.25);border-radius:12px;' +
|
||
'box-shadow:0 12px 48px rgba(0,0,0,0.7);display:flex;flex-direction:column;overflow:hidden;' +
|
||
'user-select:none;-webkit-user-select:none;';
|
||
document.body.appendChild(container);
|
||
|
||
// ===== 窗口布局持久化(位置 + 尺寸)=====
|
||
function SaveWinLayout() {
|
||
try {
|
||
var r = container.getBoundingClientRect();
|
||
localStorage.setItem('hornet_layout_warsandbox', JSON.stringify({
|
||
left: Math.round(r.left), top: Math.round(r.top),
|
||
width: Math.round(r.width), height: Math.round(r.height)
|
||
}));
|
||
} catch(e) {}
|
||
}
|
||
try {
|
||
var __wsSaved = localStorage.getItem('hornet_layout_warsandbox');
|
||
if (__wsSaved) {
|
||
var __wsP = JSON.parse(__wsSaved);
|
||
if (__wsP && typeof __wsP.left === 'number' && typeof __wsP.top === 'number') {
|
||
container.style.transform = 'none';
|
||
container.style.left = Math.max(0, __wsP.left) + 'px';
|
||
container.style.top = Math.max(0, __wsP.top) + 'px';
|
||
if (typeof __wsP.width === 'number') container.style.width = Math.max(320, __wsP.width) + 'px';
|
||
if (typeof __wsP.height === 'number') container.style.height = Math.max(240, __wsP.height) + 'px';
|
||
}
|
||
}
|
||
} catch(e) {}
|
||
|
||
var titleBar = document.createElement('div');
|
||
titleBar.style.cssText =
|
||
'display:flex;justify-content:space-between;align-items:center;padding:10px 16px;' +
|
||
'background:rgba(125,249,255,0.06);border-bottom:1px solid rgba(125,249,255,0.15);cursor:move;' +
|
||
'font:14px Consolas,monospace;color:#7df9ff;flex-shrink:0;';
|
||
titleBar.innerHTML = '<span style="font-weight:bold;text-shadow:0 0 4px rgba(125,249,255,0.4);">战争沙盘</span>';
|
||
container.appendChild(titleBar);
|
||
|
||
var ctrlBox = document.createElement('div');
|
||
ctrlBox.style.cssText = 'display:flex;gap:10px;align-items:center;';
|
||
titleBar.appendChild(ctrlBox);
|
||
|
||
var infoLabel = document.createElement('span');
|
||
infoLabel.style.cssText = 'font-size:11px;color:#5a7a8a;';
|
||
ctrlBox.appendChild(infoLabel);
|
||
|
||
var closeBtn = document.createElement('button');
|
||
closeBtn.textContent = 'X';
|
||
closeBtn.style.cssText =
|
||
'padding:2px 8px;border:1px solid rgba(255,100,100,0.4);border-radius:4px;' +
|
||
'background:rgba(200,50,50,0.3);color:#ff8080;cursor:pointer;font:12px Consolas;';
|
||
closeBtn.onclick = function() { _stop(); };
|
||
ctrlBox.appendChild(closeBtn);
|
||
|
||
var canvas = document.createElement('canvas');
|
||
canvas.style.cssText = 'flex:1;cursor:grab;display:block;';
|
||
container.appendChild(canvas);
|
||
var ctx = canvas.getContext('2d');
|
||
|
||
function resizeCanvas() {
|
||
var rect = canvas.getBoundingClientRect();
|
||
canvas.width = Math.floor(rect.width);
|
||
canvas.height = Math.floor(rect.height);
|
||
}
|
||
resizeCanvas();
|
||
|
||
// ===== 右下角缩放手柄:允许自由缩放窗口 =====
|
||
var resizeGrip = document.createElement('div');
|
||
resizeGrip.style.cssText =
|
||
'position:absolute;right:0;bottom:0;width:18px;height:18px;cursor:nwse-resize;z-index:2;' +
|
||
'background:linear-gradient(135deg,transparent 45%,rgba(125,249,255,0.35) 45%);' +
|
||
'border-bottom-right-radius:12px;';
|
||
container.appendChild(resizeGrip);
|
||
|
||
var __rsx = 0, __rsy = 0, __rsw = 0, __rsh = 0;
|
||
BindPointerDrag(resizeGrip, function(e) {
|
||
var r = container.getBoundingClientRect();
|
||
__rsx = e.clientX; __rsy = e.clientY; __rsw = r.width; __rsh = r.height;
|
||
return true;
|
||
}, function(e) {
|
||
container.style.transform = 'none';
|
||
container.style.width = Math.max(320, __rsw + (e.clientX - __rsx)) + 'px';
|
||
container.style.height = Math.max(240, __rsh + (e.clientY - __rsy)) + 'px';
|
||
resizeCanvas();
|
||
}, function() {
|
||
resizeCanvas();
|
||
SaveWinLayout();
|
||
});
|
||
|
||
// ===== FBO 创建 =====
|
||
var sandboxFbo = null, sandboxTex = null, sandboxDepthRb = null;
|
||
var pixels = new Uint8Array(FBO_W * FBO_H * 4);
|
||
var pixels32 = new Uint32Array(pixels.buffer);
|
||
var imageData = ctx.createImageData(FBO_W, FBO_H);
|
||
var tempCanvas = document.createElement('canvas');
|
||
tempCanvas.width = FBO_W; tempCanvas.height = FBO_H;
|
||
var tempCtx = tempCanvas.getContext('2d');
|
||
var pbos = [null, null], pboIndex = 0, pboReady = false, pboSupported = false;
|
||
|
||
try {
|
||
sandboxFbo = gl.createFramebuffer();
|
||
sandboxTex = gl.createTexture();
|
||
sandboxDepthRb = gl.createRenderbuffer();
|
||
gl.bindTexture(gl.TEXTURE_2D, sandboxTex);
|
||
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, FBO_W, FBO_H, 0, gl.RGBA, gl.UNSIGNED_BYTE, null);
|
||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
|
||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
|
||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
|
||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
|
||
gl.bindRenderbuffer(gl.RENDERBUFFER, sandboxDepthRb);
|
||
gl.renderbufferStorage(gl.RENDERBUFFER, gl.DEPTH_COMPONENT16, FBO_W, FBO_H);
|
||
gl.bindFramebuffer(gl.FRAMEBUFFER, sandboxFbo);
|
||
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, sandboxTex, 0);
|
||
gl.framebufferRenderbuffer(gl.FRAMEBUFFER, gl.DEPTH_ATTACHMENT, gl.RENDERBUFFER, sandboxDepthRb);
|
||
var status = gl.checkFramebufferStatus(gl.FRAMEBUFFER);
|
||
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
|
||
gl.bindTexture(gl.TEXTURE_2D, null);
|
||
gl.bindRenderbuffer(gl.RENDERBUFFER, null);
|
||
if (status !== gl.FRAMEBUFFER_COMPLETE) throw new Error('FBO不完整: ' + status);
|
||
// PBO
|
||
try {
|
||
var byteSize = FBO_W * FBO_H * 4;
|
||
for (var i = 0; i < 2; i++) {
|
||
pbos[i] = gl.createBuffer();
|
||
gl.bindBuffer(gl.PIXEL_PACK_BUFFER, pbos[i]);
|
||
gl.bufferData(gl.PIXEL_PACK_BUFFER, byteSize, gl.STREAM_READ);
|
||
}
|
||
gl.bindBuffer(gl.PIXEL_PACK_BUFFER, null);
|
||
pboSupported = true;
|
||
} catch(e) { pboSupported = false; }
|
||
console.log('[沙盘] FBO ' + FBO_W + 'x' + FBO_H + ' 已创建' + (pboSupported ? ' (PBO)' : ' (同步)'));
|
||
} catch(e) {
|
||
initError = e;
|
||
console.error('[沙盘] FBO创建失败:', e.message);
|
||
}
|
||
if (initError) return;
|
||
|
||
// ===== Hook gl.bindFramebuffer + BS.h3c/g3c =====
|
||
var sandboxRendering = false;
|
||
var mainFbo = null;
|
||
var origBindFramebuffer = gl.bindFramebuffer.bind(gl);
|
||
gl.bindFramebuffer = function(target, fbo) {
|
||
if (!sandboxRendering && target === gl.FRAMEBUFFER) mainFbo = fbo;
|
||
if (sandboxRendering && target === gl.FRAMEBUFFER && fbo !== sandboxFbo) {
|
||
return origBindFramebuffer(target, sandboxFbo);
|
||
}
|
||
return origBindFramebuffer(target, fbo);
|
||
};
|
||
var origH3c = ff.w39_1.o3c.bind(ff.w39_1);
|
||
var origG3c = ff.w39_1.n3c.bind(ff.w39_1);
|
||
ff.w39_1.o3c = function() { return sandboxRendering ? FBO_W : origH3c(); };
|
||
ff.w39_1.n3c = function() { return sandboxRendering ? FBO_H : origG3c(); };
|
||
|
||
// UI 层禁用
|
||
var uiLayerIndices = [20, 21, 22, 23, 24];
|
||
var fakeQueue = {
|
||
e1: function() { return 0; }, g1: function(i) { return null; },
|
||
w2a: function(x) {}, d3: function() {}, ii: function(x) {}
|
||
};
|
||
|
||
// ===== 视图状态 =====
|
||
var viewCenterX = 0, viewCenterY = 0;
|
||
var camHeight = Config.defaultHeight;
|
||
var viewYaw = 0; // 地图旋转(0=北朝上)
|
||
var initialized = false;
|
||
|
||
// ===== 指针拖动绑定(Pointer Events + setPointerCapture,避免松手后粘手)=====
|
||
// HandleEl: 触发拖动的元素;OnStart/OnMove/OnEnd 分别处理开始/移动/结束
|
||
function BindPointerDrag(HandleEl, OnStart, OnMove, OnEnd) {
|
||
var Active = false;
|
||
var PointerId = null;
|
||
|
||
function Finish() {
|
||
if (!Active) return;
|
||
Active = false;
|
||
if (PointerId !== null && HandleEl.releasePointerCapture) {
|
||
try { HandleEl.releasePointerCapture(PointerId); } catch (Err) {}
|
||
}
|
||
PointerId = null;
|
||
OnEnd();
|
||
}
|
||
|
||
if (window.PointerEvent) {
|
||
HandleEl.addEventListener('pointerdown', function(e) {
|
||
if (e.button !== 0) return;
|
||
if (OnStart(e) === false) return;
|
||
Active = true;
|
||
PointerId = e.pointerId;
|
||
try { HandleEl.setPointerCapture(e.pointerId); } catch (Err) {}
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
}, true);
|
||
HandleEl.addEventListener('pointermove', function(e) {
|
||
if (!Active || (PointerId !== null && e.pointerId !== PointerId)) return;
|
||
if (e.buttons === 0) { Finish(); return; } // 兜底:按键已松开
|
||
OnMove(e);
|
||
e.preventDefault();
|
||
}, true);
|
||
HandleEl.addEventListener('pointerup', Finish, true);
|
||
HandleEl.addEventListener('pointercancel', Finish, true);
|
||
HandleEl.addEventListener('lostpointercapture', Finish, true);
|
||
window.addEventListener('pointerup', Finish, true);
|
||
} else {
|
||
HandleEl.addEventListener('mousedown', function(e) {
|
||
if (e.button !== 0) return;
|
||
if (OnStart(e) === false) return;
|
||
Active = true;
|
||
e.preventDefault();
|
||
}, true);
|
||
document.addEventListener('mousemove', function(e) {
|
||
if (!Active) return;
|
||
if (e.buttons === 0) { Finish(); return; }
|
||
OnMove(e);
|
||
}, true);
|
||
document.addEventListener('mouseup', Finish, true);
|
||
}
|
||
|
||
window.addEventListener('blur', Finish);
|
||
window.addEventListener('mouseleave', Finish);
|
||
return { End: Finish, IsActive: function() { return Active; } };
|
||
}
|
||
|
||
// ===== 窗口拖动 =====
|
||
var dsx = 0, dsy = 0, dwx = 0, dwy = 0;
|
||
BindPointerDrag(titleBar, function(e) {
|
||
if (e.target.tagName === 'BUTTON') return false;
|
||
dsx = e.clientX; dsy = e.clientY;
|
||
var rect = container.getBoundingClientRect();
|
||
dwx = rect.left; dwy = rect.top;
|
||
container.style.transform = 'none';
|
||
container.style.left = dwx + 'px'; container.style.top = dwy + 'px';
|
||
}, function(e) {
|
||
var nx = Math.max(0, Math.min(window.innerWidth - 100, dwx + e.clientX - dsx));
|
||
var ny = Math.max(0, Math.min(window.innerHeight - 40, dwy + e.clientY - dsy));
|
||
container.style.left = nx + 'px'; container.style.top = ny + 'px';
|
||
}, function() { SaveWinLayout(); });
|
||
|
||
// ===== 地图平移 =====
|
||
var psx = 0, psy = 0, pcx = 0, pcy = 0;
|
||
BindPointerDrag(canvas, function(e) {
|
||
psx = e.clientX; psy = e.clientY;
|
||
pcx = viewCenterX; pcy = viewCenterY;
|
||
canvas.style.cursor = 'grabbing';
|
||
}, function(e) {
|
||
// 俯瞰视图:屏幕像素→世界单位,X/Y 方向缩放不同(FBO 拉伸到 canvas)
|
||
var ext = getViewExtents();
|
||
var dx = (e.clientX - psx) * ext.wppX;
|
||
var dy = (e.clientY - psy) * ext.wppY;
|
||
viewCenterX = pcx - dx;
|
||
viewCenterY = pcy + dy; // y 反转
|
||
}, function() {
|
||
canvas.style.cursor = 'grab';
|
||
});
|
||
|
||
// 滚轮缩放(改变相机高度)
|
||
canvas.addEventListener('wheel', function(e) {
|
||
e.preventDefault();
|
||
var factor = e.deltaY < 0 ? 0.85 : 1.15;
|
||
var newHeight = camHeight * factor;
|
||
if (newHeight < Config.minHeight || newHeight > Config.maxHeight) return;
|
||
camHeight = newHeight;
|
||
}, { passive: false });
|
||
|
||
// 双击居中
|
||
canvas.addEventListener('dblclick', function() {
|
||
var player = GetPlayerTank();
|
||
if (player) {
|
||
var pos = GetTankPosition(player);
|
||
if (pos) { viewCenterX = pos.x; viewCenterY = pos.y; }
|
||
} else {
|
||
var fl = window.__hornetFreeLook__;
|
||
if (fl && fl.IsActive && fl.IsActive()) {
|
||
var p = fl.GetPosition();
|
||
if (p) { viewCenterX = p.x; viewCenterY = p.y; }
|
||
}
|
||
}
|
||
});
|
||
|
||
// ===== 世界→屏幕投影(俯瞰视图,用于 2D 叠加层)=====
|
||
// 注意:FBO(960x720) 被拉伸到 canvas(cw x ch),若两者比例不同则 X/Y 缩放不同
|
||
// 相机以 FBO_W/FBO_H 为 aspect 渲染,可见世界范围:
|
||
// visH = 2*camHeight*tan(fov/2) (世界单位,纵向)
|
||
// visW = visH * (FBO_W/FBO_H) (世界单位,横向)
|
||
// FBO 拉伸到 canvas 后:
|
||
// worldPerPixelX = visW / cw
|
||
// worldPerPixelY = visH / ch
|
||
function getViewExtents() {
|
||
var ch = canvas.height, cw = canvas.width;
|
||
var visH = 2 * camHeight * Math.tan(Config.fov / 2);
|
||
var visW = visH * (FBO_W / FBO_H);
|
||
return {
|
||
visW: visW, visH: visH,
|
||
wppX: visW / cw, // X方向 世界单位/像素
|
||
wppY: visH / ch // Y方向 世界单位/像素
|
||
};
|
||
}
|
||
function worldToScreen(wx, wy) {
|
||
var ext = getViewExtents();
|
||
var cw = canvas.width, ch = canvas.height;
|
||
return {
|
||
x: cw / 2 + (wx - viewCenterX) / ext.wppX,
|
||
y: ch / 2 - (wy - viewCenterY) / ext.wppY
|
||
};
|
||
}
|
||
|
||
// ===== 2D 叠加层绘制 =====
|
||
function drawOverlays() {
|
||
var cw = canvas.width, ch = canvas.height;
|
||
var playerTank = GetPlayerTank();
|
||
|
||
// 网格(X/Y 方向缩放不同,用 ext 计算)
|
||
if (ShowGrid) {
|
||
ctx.strokeStyle = 'rgba(125,249,255,0.08)';
|
||
ctx.lineWidth = 1;
|
||
var steps = [500, 1000, 2000, 5000, 10000, 20000, 50000];
|
||
var step = 5000;
|
||
var ext = getViewExtents();
|
||
// 用较小的 wpp 选择网格间距(保证密度合适)
|
||
var minWpp = Math.min(ext.wppX, ext.wppY);
|
||
for (var i = 0; i < steps.length; i++) {
|
||
if (steps[i] / minWpp > 60) { step = steps[i]; break; }
|
||
}
|
||
var left = viewCenterX - (cw/2) * ext.wppX;
|
||
var right = viewCenterX + (cw/2) * ext.wppX;
|
||
var top = viewCenterY + (ch/2) * ext.wppY;
|
||
var bottom = viewCenterY - (ch/2) * ext.wppY;
|
||
var startX = Math.floor(left/step)*step;
|
||
var startY = Math.floor(bottom/step)*step;
|
||
ctx.beginPath();
|
||
for (var wx = startX; wx <= right; wx += step) {
|
||
var p = worldToScreen(wx, 0);
|
||
ctx.moveTo(p.x, 0); ctx.lineTo(p.x, ch);
|
||
}
|
||
for (var wy = startY; wy <= top; wy += step) {
|
||
var p = worldToScreen(0, wy);
|
||
ctx.moveTo(0, p.y); ctx.lineTo(cw, p.y);
|
||
}
|
||
ctx.stroke();
|
||
// 距离标签
|
||
ctx.fillStyle = 'rgba(125,249,255,0.35)';
|
||
ctx.font = '10px Consolas';
|
||
ctx.textAlign = 'left'; ctx.textBaseline = 'top';
|
||
for (var wx2 = startX; wx2 <= right; wx2 += step) {
|
||
var p = worldToScreen(wx2, 0);
|
||
ctx.fillText((wx2/100).toFixed(0)+'m', p.x+2, 2);
|
||
}
|
||
}
|
||
|
||
// 坦克标记
|
||
var tanks = [];
|
||
try {
|
||
var list = gm.m10b_1.j11y();
|
||
var n = list.e1();
|
||
for (var i = 0; i < n; i++) { var t = list.g1(i); if (t) tanks.push(t); }
|
||
} catch(e) {}
|
||
var margin = 50;
|
||
|
||
tanks.forEach(function(tank) {
|
||
var pos = GetTankPosition(tank);
|
||
if (!pos) return;
|
||
var p = worldToScreen(pos.x, pos.y);
|
||
if (p.x < -margin || p.x > cw+margin || p.y < -margin || p.y > ch+margin) return;
|
||
|
||
var isPlayer = (tank === playerTank);
|
||
var friendly = IsFriendly(tank, playerTank);
|
||
var alive = IsTankAlive(tank);
|
||
var state = GetTankState(tank);
|
||
if (state !== 3 && !isPlayer) return;
|
||
|
||
var color, glow;
|
||
if (isPlayer) { color = '#ffdd44'; glow = 'rgba(255,221,68,0.7)'; }
|
||
else if (friendly) { color = '#44ff88'; glow = 'rgba(68,255,136,0.5)'; }
|
||
else { color = '#ff5566'; glow = 'rgba(255,85,102,0.5)'; }
|
||
|
||
var radius = (isPlayer ? 8 : 6) * MarkerSize;
|
||
if (!alive) { radius = 4 * MarkerSize; color = '#666'; glow = 'none'; }
|
||
|
||
// 方向指示(真实坦克朝向)
|
||
var dir = getTankDirection(tank);
|
||
if (dir && alive) {
|
||
var dirLen = Math.sqrt(dir.x*dir.x + dir.y*dir.y);
|
||
if (dirLen > 0.01) {
|
||
var dx = dir.x / dirLen, dy = dir.y / dirLen;
|
||
var arrowLen = 22;
|
||
ctx.strokeStyle = color;
|
||
ctx.lineWidth = 2.5;
|
||
ctx.beginPath();
|
||
ctx.moveTo(p.x, p.y);
|
||
ctx.lineTo(p.x + dx * arrowLen, p.y - dy * arrowLen);
|
||
ctx.stroke();
|
||
// 箭头头
|
||
var ax = p.x + dx * arrowLen, ay = p.y - dy * arrowLen;
|
||
var ang = Math.atan2(-dy, dx);
|
||
ctx.beginPath();
|
||
ctx.moveTo(ax, ay);
|
||
ctx.lineTo(ax - 7*Math.cos(ang-0.4), ay - 7*Math.sin(ang-0.4));
|
||
ctx.moveTo(ax, ay);
|
||
ctx.lineTo(ax - 7*Math.cos(ang+0.4), ay - 7*Math.sin(ang+0.4));
|
||
ctx.stroke();
|
||
}
|
||
}
|
||
|
||
// 光晕标记
|
||
if (alive) {
|
||
ctx.save();
|
||
ctx.shadowColor = glow;
|
||
ctx.shadowBlur = 12;
|
||
ctx.fillStyle = color;
|
||
ctx.beginPath();
|
||
ctx.arc(p.x, p.y, radius, 0, Math.PI*2);
|
||
ctx.fill();
|
||
ctx.restore();
|
||
} else {
|
||
ctx.fillStyle = color;
|
||
ctx.fillRect(p.x-3, p.y-3, 6, 6);
|
||
}
|
||
|
||
// 玩家外圈
|
||
if (isPlayer && alive) {
|
||
ctx.strokeStyle = 'rgba(255,221,68,0.6)';
|
||
ctx.lineWidth = 2;
|
||
ctx.beginPath();
|
||
ctx.arc(p.x, p.y, radius+6, 0, Math.PI*2);
|
||
ctx.stroke();
|
||
}
|
||
|
||
// 名称
|
||
var name = GetTankName(tank);
|
||
if (name) {
|
||
ctx.font = 'bold 11px Consolas';
|
||
ctx.textAlign = 'center';
|
||
ctx.textBaseline = 'bottom';
|
||
ctx.strokeStyle = 'rgba(0,0,0,0.85)';
|
||
ctx.lineWidth = 3;
|
||
ctx.strokeText(name, p.x, p.y - radius - 5);
|
||
ctx.fillStyle = alive ? '#fff' : '#666';
|
||
ctx.fillText(name, p.x, p.y - radius - 5);
|
||
}
|
||
|
||
// 血量条
|
||
if (alive) {
|
||
var health = GetTankHealth(tank);
|
||
if (health) {
|
||
var pct = health.cur / health.max;
|
||
if (pct > 1) pct = 1;
|
||
var barW = 34, barH = 5;
|
||
var bx = p.x - barW/2, by = p.y + radius + 4;
|
||
ctx.fillStyle = 'rgba(0,0,0,0.7)';
|
||
ctx.fillRect(bx, by, barW, barH);
|
||
var hpColor = pct > 0.6 ? '#2ecc71' : (pct > 0.3 ? '#f5a623' : '#e05555');
|
||
ctx.fillStyle = hpColor;
|
||
ctx.fillRect(bx, by, barW * pct, barH);
|
||
ctx.strokeStyle = 'rgba(255,255,255,0.3)';
|
||
ctx.lineWidth = 0.5;
|
||
ctx.strokeRect(bx, by, barW, barH);
|
||
}
|
||
}
|
||
});
|
||
|
||
// 罗盘
|
||
var ccx = cw - 50, ccy = 50, cr = 28;
|
||
ctx.save();
|
||
ctx.strokeStyle = 'rgba(125,249,255,0.4)';
|
||
ctx.lineWidth = 1.5;
|
||
ctx.beginPath(); ctx.arc(ccx, ccy, cr, 0, Math.PI*2); ctx.stroke();
|
||
ctx.fillStyle = 'rgba(8,12,20,0.6)';
|
||
ctx.beginPath(); ctx.arc(ccx, ccy, cr-1, 0, Math.PI*2); ctx.fill();
|
||
ctx.fillStyle = '#ff5566';
|
||
ctx.beginPath();
|
||
ctx.moveTo(ccx, ccy - cr + 4);
|
||
ctx.lineTo(ccx - 5, ccy);
|
||
ctx.lineTo(ccx + 5, ccy);
|
||
ctx.closePath(); ctx.fill();
|
||
ctx.fillStyle = 'rgba(200,210,220,0.6)';
|
||
ctx.beginPath();
|
||
ctx.moveTo(ccx, ccy + cr - 4);
|
||
ctx.lineTo(ccx - 5, ccy);
|
||
ctx.lineTo(ccx + 5, ccy);
|
||
ctx.closePath(); ctx.fill();
|
||
ctx.fillStyle = '#7df9ff';
|
||
ctx.font = 'bold 12px Consolas';
|
||
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
|
||
ctx.fillText('N', ccx, ccy - cr - 8);
|
||
ctx.restore();
|
||
|
||
// 中心十字准星
|
||
ctx.strokeStyle = 'rgba(125,249,255,0.2)';
|
||
ctx.lineWidth = 1;
|
||
ctx.beginPath();
|
||
ctx.moveTo(cw/2 - 15, ch/2); ctx.lineTo(cw/2 + 15, ch/2);
|
||
ctx.moveTo(cw/2, ch/2 - 15); ctx.lineTo(cw/2, ch/2 + 15);
|
||
ctx.stroke();
|
||
}
|
||
|
||
// ===== 主渲染循环 =====
|
||
var running = true;
|
||
var rafId = null;
|
||
var frameCount = 0;
|
||
|
||
function render() {
|
||
if (!running) return;
|
||
|
||
// 换图自刷新:gameMode 实例变化时整体重启,重新获取场景/相机/GL hook
|
||
var NewGm = window.__gameMode__;
|
||
if (NewGm && NewGm !== gm) {
|
||
console.log('[沙盘] 检测到新战斗,正在重启战争沙盘...');
|
||
self.Stop();
|
||
_start();
|
||
return;
|
||
}
|
||
|
||
frameCount++;
|
||
// 旧实例已失效、新实例尚未捕获:跳过本帧
|
||
if (!window.__gameMode__) { rafId = requestAnimationFrame(render); return; }
|
||
var cw = canvas.width, ch = canvas.height;
|
||
|
||
// 初始化视图中心
|
||
if (!initialized) {
|
||
var player = GetPlayerTank();
|
||
if (player) {
|
||
var pos = GetTankPosition(player);
|
||
if (pos) { viewCenterX = pos.x; viewCenterY = pos.y; initialized = true; }
|
||
}
|
||
}
|
||
|
||
// 暂停 FreeLook
|
||
var fl = window.__hornetFreeLook__;
|
||
if (fl && typeof fl.SetSuspended === 'function') fl.SetSuspended(true);
|
||
|
||
try {
|
||
// 保存主相机参数
|
||
var sPitch = mainCamera.u35_1.f20_1;
|
||
var sRoll = mainCamera.u35_1.g20_1;
|
||
var sYaw = mainCamera.u35_1.h20_1;
|
||
var sX = mainCamera.r35_1.f20_1;
|
||
var sY = mainCamera.r35_1.g20_1;
|
||
var sZ = mainCamera.r35_1.h20_1;
|
||
var sL36 = mainCamera.u36_1, sM36 = mainCamera.v36_1;
|
||
var sS36 = mainCamera.z36_1, sQ36 = mainCamera.x36_1, sR36 = mainCamera.y36_1;
|
||
|
||
// 设置俯瞰相机参数
|
||
// pitch=-π/2 朝下看,roll=0 不翻转,yaw=0 北朝上
|
||
mainCamera.u35_1.f20_1 = -Math.PI / 2;
|
||
mainCamera.u35_1.g20_1 = 0;
|
||
mainCamera.u35_1.h20_1 = 0;
|
||
mainCamera.r35_1.f20_1 = viewCenterX;
|
||
mainCamera.r35_1.g20_1 = viewCenterY;
|
||
mainCamera.r35_1.h20_1 = camHeight;
|
||
mainCamera.u36_1 = FBO_W;
|
||
mainCamera.v36_1 = FBO_H;
|
||
mainCamera.z36_1 = Config.fov;
|
||
mainCamera.x36_1 = Config.near;
|
||
mainCamera.y36_1 = Config.far;
|
||
mainCamera.o37();
|
||
|
||
// 禁用阴影/后处理
|
||
var savedX3n = ff.y38_1.e3o_1;
|
||
var savedY3n = ff.y38_1.f3o_1;
|
||
var savedZ3n = ff.y38_1.k3o_1;
|
||
ff.y38_1.e3o_1 = false;
|
||
ff.y38_1.f3o_1 = false;
|
||
ff.y38_1.k3o_1 = false;
|
||
|
||
// 禁用 UI 层
|
||
var savedUiQueues = {};
|
||
for (var ui = 0; ui < uiLayerIndices.length; ui++) {
|
||
var li = uiLayerIndices[ui];
|
||
savedUiQueues[li] = ff.e39_1[li];
|
||
ff.e39_1[li] = fakeQueue;
|
||
}
|
||
|
||
// 渲染到 FBO
|
||
sandboxRendering = true;
|
||
origBindFramebuffer(gl.FRAMEBUFFER, sandboxFbo);
|
||
gl.viewport(0, 0, FBO_W, FBO_H);
|
||
gl.clearColor(0.05, 0.08, 0.12, 1.0);
|
||
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
|
||
ff.f3t(rootNode, mainCamera);
|
||
sandboxRendering = false;
|
||
|
||
// Readback
|
||
origBindFramebuffer(gl.FRAMEBUFFER, sandboxFbo);
|
||
if (pboSupported) {
|
||
var curPbo = pboIndex, prevPbo = 1 - pboIndex;
|
||
gl.bindBuffer(gl.PIXEL_PACK_BUFFER, pbos[curPbo]);
|
||
gl.readPixels(0, 0, FBO_W, FBO_H, gl.RGBA, gl.UNSIGNED_BYTE, 0);
|
||
if (pboReady) {
|
||
gl.bindBuffer(gl.PIXEL_PACK_BUFFER, pbos[prevPbo]);
|
||
gl.getBufferSubData(gl.PIXEL_PACK_BUFFER, 0, pixels);
|
||
}
|
||
gl.bindBuffer(gl.PIXEL_PACK_BUFFER, null);
|
||
pboIndex = prevPbo;
|
||
pboReady = true;
|
||
} else {
|
||
gl.readPixels(0, 0, FBO_W, FBO_H, gl.RGBA, gl.UNSIGNED_BYTE, pixels);
|
||
}
|
||
|
||
// 强制 alpha=255
|
||
for (var ai = 0; ai < pixels32.length; ai++) pixels32[ai] = pixels32[ai] | 0xFF000000;
|
||
imageData.data.set(pixels);
|
||
|
||
// Y 翻转显示(roll=0 时 GL readback 产生上下颠倒图像,需翻转)
|
||
tempCtx.putImageData(imageData, 0, 0);
|
||
ctx.clearRect(0, 0, cw, ch);
|
||
ctx.save();
|
||
ctx.imageSmoothingEnabled = true;
|
||
ctx.scale(1, -1);
|
||
ctx.drawImage(tempCanvas, 0, -ch, cw, ch);
|
||
ctx.restore();
|
||
|
||
// 恢复主 FBO
|
||
origBindFramebuffer(gl.FRAMEBUFFER, mainFbo);
|
||
|
||
// 恢复渲染状态
|
||
ff.y38_1.e3o_1 = savedX3n;
|
||
ff.y38_1.f3o_1 = savedY3n;
|
||
ff.y38_1.k3o_1 = savedZ3n;
|
||
for (var ui2 = 0; ui2 < uiLayerIndices.length; ui2++) {
|
||
ff.e39_1[uiLayerIndices[ui2]] = savedUiQueues[uiLayerIndices[ui2]];
|
||
}
|
||
|
||
// 恢复主相机
|
||
mainCamera.u35_1.f20_1 = sPitch;
|
||
mainCamera.u35_1.g20_1 = sRoll;
|
||
mainCamera.u35_1.h20_1 = sYaw;
|
||
mainCamera.r35_1.f20_1 = sX;
|
||
mainCamera.r35_1.g20_1 = sY;
|
||
mainCamera.r35_1.h20_1 = sZ;
|
||
mainCamera.u36_1 = sL36; mainCamera.v36_1 = sM36;
|
||
mainCamera.z36_1 = sS36; mainCamera.x36_1 = sQ36; mainCamera.y36_1 = sR36;
|
||
mainCamera.o37();
|
||
|
||
// 绘制 2D 叠加层(坦克标记/网格/罗盘)
|
||
drawOverlays();
|
||
|
||
} catch(e) {
|
||
console.error('[沙盘] 渲染失败:', e.message);
|
||
sandboxRendering = false;
|
||
} finally {
|
||
// 确保 FreeLook 恢复
|
||
if (fl && typeof fl.SetSuspended === 'function') fl.SetSuspended(false);
|
||
}
|
||
|
||
// 信息更新
|
||
if (frameCount % 30 === 0) {
|
||
var ext2 = getViewExtents();
|
||
var viewRange = cw * ext2.wppX; // 横向可视范围
|
||
infoLabel.textContent = '高度:' + (camHeight/100).toFixed(0) + 'm | 范围:' +
|
||
(viewRange/100).toFixed(0) + 'm | 拖拽平移 滚轮缩放 双击居中';
|
||
}
|
||
|
||
rafId = requestAnimationFrame(render);
|
||
}
|
||
|
||
window.addEventListener('resize', resizeCanvas);
|
||
rafId = requestAnimationFrame(render);
|
||
console.log('[沙盘] 战争沙盘已就绪 — 拖拽地图平移 | 滚轮缩放 | 双击居中玩家 | Alt+M 或 X 关闭');
|
||
|
||
// ===== 对外接口 =====
|
||
var self = {
|
||
Stop: function() {
|
||
running = false;
|
||
if (rafId) cancelAnimationFrame(rafId);
|
||
// 恢复 hook
|
||
gl.bindFramebuffer = origBindFramebuffer;
|
||
ff.w39_1.o3c = origH3c;
|
||
ff.w39_1.n3c = origG3c;
|
||
window.removeEventListener('resize', resizeCanvas);
|
||
if (container.parentNode) container.parentNode.removeChild(container);
|
||
window.__hornetWarSandbox__ = null;
|
||
console.log('[沙盘] 战争沙盘已关闭');
|
||
},
|
||
Status: function() { return running ? '已开启' : '未运行'; },
|
||
GetRunning: function() { return running; },
|
||
SetShowGrid: function(on) {
|
||
ShowGrid = !!on;
|
||
try { localStorage.setItem('hornet_ws_showgrid', ShowGrid ? 'true' : 'false'); } catch(e) {}
|
||
console.log('[沙盘] 网格: ' + (ShowGrid ? '显示' : '隐藏'));
|
||
},
|
||
GetShowGrid: function() { return ShowGrid; },
|
||
SetMarkerSize: function(s) {
|
||
var n = parseFloat(s);
|
||
if (!isNaN(n) && n >= 0.5 && n <= 3.0) {
|
||
MarkerSize = n;
|
||
try { localStorage.setItem('hornet_ws_marker_size', String(n)); } catch(e) {}
|
||
console.log('[沙盘] 标记大小: ' + n);
|
||
} else {
|
||
console.warn('[沙盘] 标记大小应在 0.5~3.0');
|
||
}
|
||
},
|
||
GetMarkerSize: function() { return MarkerSize; }
|
||
};
|
||
window.__hornetWarSandbox__ = self;
|
||
window.__hornetTools__ = window.__hornetTools__ || {};
|
||
window.__hornetTools__.WarSandbox = self;
|
||
}
|
||
|
||
function _stop() {
|
||
if (window.__hornetWarSandbox__) window.__hornetWarSandbox__.Stop();
|
||
}
|
||
function _status() {
|
||
return window.__hornetWarSandbox__ ? window.__hornetWarSandbox__.Status() : '未运行';
|
||
}
|
||
function _getRunning() {
|
||
return !!(window.__hornetWarSandbox__ && window.__hornetWarSandbox__.GetRunning());
|
||
}
|
||
|
||
window.__hornetTools__ = window.__hornetTools__ || {};
|
||
window.__hornetTools__.WarSandbox = {
|
||
Start: _start,
|
||
Stop: _stop,
|
||
Status: _status,
|
||
GetRunning: _getRunning,
|
||
SetShowGrid: function(on) { if (window.__hornetWarSandbox__) window.__hornetWarSandbox__.SetShowGrid(on); },
|
||
GetShowGrid: function() { return window.__hornetWarSandbox__ ? window.__hornetWarSandbox__.GetShowGrid() : true; },
|
||
SetMarkerSize: function(s) { if (window.__hornetWarSandbox__) window.__hornetWarSandbox__.SetMarkerSize(s); },
|
||
GetMarkerSize: function() { return window.__hornetWarSandbox__ ? window.__hornetWarSandbox__.GetMarkerSize() : 1.0; }
|
||
};
|
||
})();
|
||
|
||
|
||
// ===== hornet_mouse.js =====
|
||
// hornet_mouse.js — 鼠标输入稳定器
|
||
// 目标:缓解原版「指针锁定下鼠标乱晃 / 难控制」的问题(游戏自身行为,非本工具链引入)。
|
||
//
|
||
// 游戏侧机制(module_03569):
|
||
// · 锁定:Lh() 先 requestPointerLock({unadjustedMovement:true})(原始输入),**失败则静默回退**
|
||
// 成不带 unadjustedMovement 的普通锁定 —— 后者会带上 OS 鼠标加速,手感立刻变飘。
|
||
// · 视角:mousemove 里直接把 e.movementX / e.movementY 喂给输入管理器
|
||
// (fal_1.ia9(qh(), movementX, movementY)),没有任何尖峰/跳变过滤。
|
||
//
|
||
// 本模块做三件事,全部可在面板「鼠标稳定」里开关:
|
||
// ① 强制原始输入:接管 Element.prototype.requestPointerLock,始终带 unadjustedMovement,
|
||
// 并把「回退/失败」打到控制台(手感异常的第一嫌疑人)。
|
||
// ② 锁后/恢复跳变抑制:指针锁定刚建立、或事件流中断(切窗口/失焦)后恢复的头 DropMs 毫秒内
|
||
// 丢弃 mousemove —— 这一两下常带巨大 movementX/Y,会直接把视角甩飞。
|
||
// ③ 尖峰钳制:单事件 |movementX| / |movementY| 超过 MaxDelta(0 = 关闭)时截断,
|
||
// 抑制驱动/焦点抖动造成的瞬时尖峰。
|
||
//
|
||
// 状态:HUD 里显示「鼠标: RAW 48/s 尖峰320 钳0 丢1」,或
|
||
// console.log(window.__hornetTools__.Mouse.GetStatus())
|
||
(function () {
|
||
'use strict';
|
||
|
||
var lib = window.__hornetLib__;
|
||
if (!lib) {
|
||
console.error('[鼠标] 请先加载 hornet_lib.js');
|
||
return;
|
||
}
|
||
|
||
// ===== 配置(localStorage 持久化)=====
|
||
var CONFIG_KEY = 'hornet_mouse_cfg';
|
||
var Config = {
|
||
ForceRaw: true, // 强制原始输入(unadjustedMovement),关掉则完全不干预指针锁定
|
||
DropMs: 120, // 锁定建立 / 事件流恢复后的忽略时长(ms,0 = 不丢事件)
|
||
GapMs: 250, // 事件间隔超过它视为「恢复」,同样触发忽略窗口(ms)
|
||
MaxDelta: 0 // 单事件位移钳制阈值(px,0 = 不钳制)
|
||
};
|
||
try {
|
||
var Saved = JSON.parse(localStorage.getItem(CONFIG_KEY) || '{}');
|
||
if (typeof Saved.ForceRaw === 'boolean') Config.ForceRaw = Saved.ForceRaw;
|
||
if (typeof Saved.DropMs === 'number') Config.DropMs = Saved.DropMs;
|
||
if (typeof Saved.GapMs === 'number') Config.GapMs = Saved.GapMs;
|
||
if (typeof Saved.MaxDelta === 'number') Config.MaxDelta = Saved.MaxDelta;
|
||
} catch (e) {}
|
||
function SaveConfig() {
|
||
try { localStorage.setItem(CONFIG_KEY, JSON.stringify(Config)); } catch (e) {}
|
||
}
|
||
|
||
function Now() {
|
||
return (window.performance && window.performance.now) ? window.performance.now() : Date.now();
|
||
}
|
||
|
||
// ===== 运行状态与统计 =====
|
||
var Stat = {
|
||
running: false,
|
||
rawMode: '未知', // 原始输入 / 带加速 / 未知
|
||
locked: false,
|
||
dropUntil: 0, // 该时刻前的 mousemove 全部丢弃
|
||
dropped: 0,
|
||
clamped: 0,
|
||
events: 0,
|
||
maxAbs: 0, // 本统计窗口内最大单事件位移
|
||
lastEventAt: 0,
|
||
rate: 0,
|
||
rateEvents: 0,
|
||
rateWindow: 0,
|
||
lastAnomalyAt: 0
|
||
};
|
||
|
||
var OrigRequestPointerLock = null;
|
||
var Attached = false;
|
||
|
||
// ===== ① 指针锁定:强制原始输入 =====
|
||
function InstallLockPatch() {
|
||
if (OrigRequestPointerLock) return;
|
||
OrigRequestPointerLock = Element.prototype.requestPointerLock;
|
||
Element.prototype.requestPointerLock = function (Options) {
|
||
var Opt = Options;
|
||
if (Config.ForceRaw && !(Opt && Opt.unadjustedMovement)) {
|
||
Opt = { unadjustedMovement: true }; // 显式请求原始输入(不改调用方对象)
|
||
}
|
||
// 只有在「本来没锁」时才算一次真正的锁定(避免已锁定时的重复请求反复刷新忽略窗口,
|
||
// 那会让鼠标一直被丢弃而失去响应)
|
||
var WasLocked = !!document.pointerLockElement;
|
||
Stat.locked = true;
|
||
if (!WasLocked) {
|
||
Stat.lastEventAt = 0; // 重新计时,避免把旧的间隔当成「恢复」
|
||
Stat.dropUntil = Config.DropMs > 0 ? (Now() + Config.DropMs) : 0;
|
||
}
|
||
|
||
var P;
|
||
try {
|
||
P = OrigRequestPointerLock.call(this, Opt);
|
||
} catch (Err) {
|
||
console.error('[鼠标] requestPointerLock 调用异常:', Err && Err.message);
|
||
return null;
|
||
}
|
||
var WantRaw = !!(Opt && Opt.unadjustedMovement);
|
||
if (P && typeof P.then === 'function') {
|
||
P.then(
|
||
function () {
|
||
Stat.rawMode = WantRaw ? '原始输入' : '带加速';
|
||
console.log('[鼠标] 指针锁定成功,模式=' + Stat.rawMode +
|
||
(WantRaw ? '' : '(带 OS 加速,手感会飘;可在面板勾选「强制原始输入」)'));
|
||
},
|
||
function (Err) {
|
||
Stat.rawMode = '未知';
|
||
console.warn('[鼠标] 指针锁定失败: ' + (Err && Err.message));
|
||
},
|
||
);
|
||
} else {
|
||
Stat.rawMode = WantRaw ? '原始输入' : '带加速';
|
||
}
|
||
return P;
|
||
};
|
||
}
|
||
|
||
function UninstallLockPatch() {
|
||
if (!OrigRequestPointerLock) return;
|
||
Element.prototype.requestPointerLock = OrigRequestPointerLock;
|
||
OrigRequestPointerLock = null;
|
||
}
|
||
|
||
// ===== ②③ mousemove 拦截:丢弃跳变 + 钳制尖峰 =====
|
||
// 注册在 window 的捕获阶段:一定早于游戏挂在画布/document 上的处理器,丢弃/改写才有效
|
||
function OnMouseMoveCapture(e) {
|
||
Stat.events++;
|
||
var T = Now();
|
||
|
||
// 事件流「恢复」判定(切窗口/失焦回来时的第一下常是巨幅位移)
|
||
if (Config.GapMs > 0 && Stat.lastEventAt && (T - Stat.lastEventAt) > Config.GapMs && Config.DropMs > 0) {
|
||
Stat.dropUntil = T + Config.DropMs;
|
||
}
|
||
Stat.lastEventAt = T;
|
||
|
||
// ② 忽略窗口内:整条事件丢弃(stopImmediatePropagation 阻断游戏处理器)
|
||
if (Stat.dropUntil && T < Stat.dropUntil) {
|
||
Stat.dropped++;
|
||
e.stopImmediatePropagation();
|
||
return;
|
||
}
|
||
|
||
var Dx = e.movementX, Dy = e.movementY;
|
||
if (typeof Dx !== 'number' || typeof Dy !== 'number') return;
|
||
|
||
var Ax = Math.abs(Dx), Ay = Math.abs(Dy);
|
||
if (Ax > Stat.maxAbs) Stat.maxAbs = Ax;
|
||
if (Ay > Stat.maxAbs) Stat.maxAbs = Ay;
|
||
|
||
// ③ 尖峰钳制:改写事件上的 movementX/Y(own property 遮蔽原型 getter)
|
||
if (Config.MaxDelta > 0 && (Ax > Config.MaxDelta || Ay > Config.MaxDelta)) {
|
||
var Nx = Ax > Config.MaxDelta ? (Dx > 0 ? Config.MaxDelta : -Config.MaxDelta) : Dx;
|
||
var Ny = Ay > Config.MaxDelta ? (Dy > 0 ? Config.MaxDelta : -Config.MaxDelta) : Dy;
|
||
try {
|
||
Object.defineProperty(e, 'movementX', { value: Nx, configurable: true });
|
||
Object.defineProperty(e, 'movementY', { value: Ny, configurable: true });
|
||
Stat.clamped++;
|
||
} catch (Err) {
|
||
// 属性不可写则退化为丢弃该事件(同样能压住甩飞)
|
||
Stat.clamped++;
|
||
e.stopImmediatePropagation();
|
||
return;
|
||
}
|
||
}
|
||
|
||
// 事件率统计(2s 窗口),尖峰异常时节流提示,帮助定位是「抖动」还是「跳变」
|
||
Stat.rateEvents++;
|
||
if (!Stat.rateWindow) Stat.rateWindow = T;
|
||
if (T - Stat.rateWindow >= 2000) {
|
||
Stat.rate = Math.round((Stat.rateEvents * 1000) / (T - Stat.rateWindow));
|
||
Stat.rateWindow = T;
|
||
Stat.rateEvents = 0;
|
||
var Bad = Stat.maxAbs > 400 && (T - Stat.lastAnomalyAt) > 3000;
|
||
if (Bad) {
|
||
Stat.lastAnomalyAt = T;
|
||
console.warn('[鼠标] 出现大位移尖峰: ' + Stat.maxAbs.toFixed(0) + 'px(事件率 ' + Stat.rate +
|
||
'/s,累计钳制 ' + Stat.clamped + ' 次)。若频繁甩飞,把「尖峰钳制」调成 250~400 试试');
|
||
}
|
||
Stat.maxAbs = 0; // 只统计窗口内最大值
|
||
}
|
||
}
|
||
|
||
// ===== 启动 / 停止 =====
|
||
function _start() {
|
||
if (Stat.running) return;
|
||
if (Config.ForceRaw) InstallLockPatch();
|
||
if (!Attached) {
|
||
window.addEventListener('mousemove', OnMouseMoveCapture, true);
|
||
Attached = true;
|
||
}
|
||
Stat.running = true;
|
||
console.log('[鼠标] 稳定器已启动 强制原始输入=' + Config.ForceRaw +
|
||
' 锁后忽略=' + Config.DropMs + 'ms 尖峰钳制=' + (Config.MaxDelta > 0 ? Config.MaxDelta + 'px' : '关'));
|
||
}
|
||
|
||
function _stop() {
|
||
if (!Stat.running) return;
|
||
UninstallLockPatch();
|
||
if (Attached) {
|
||
window.removeEventListener('mousemove', OnMouseMoveCapture, true);
|
||
Attached = false;
|
||
}
|
||
Stat.running = false;
|
||
Stat.dropped = 0; Stat.clamped = 0; Stat.events = 0; Stat.maxAbs = 0;
|
||
Stat.dropUntil = 0; Stat.lastEventAt = 0; Stat.rawMode = '未知';
|
||
console.log('[鼠标] 稳定器已停止');
|
||
}
|
||
|
||
var Api = {
|
||
config: Config,
|
||
_start: _start,
|
||
_stop: _stop,
|
||
setForceRaw: function (On) { Config.ForceRaw = !!On; SaveConfig(); if (Stat.running) { if (On) InstallLockPatch(); else UninstallLockPatch(); } },
|
||
setDropMs: function (Ms) { Config.DropMs = Math.max(0, Number(Ms) || 0); SaveConfig(); },
|
||
setMaxDelta: function (Px) { Config.MaxDelta = Math.max(0, Number(Px) || 0); SaveConfig(); },
|
||
getStatus: function () {
|
||
return {
|
||
running: Stat.running, rawMode: Stat.rawMode, rate: Stat.rate,
|
||
maxAbs: Math.round(Stat.maxAbs), clamped: Stat.clamped, dropped: Stat.dropped, events: Stat.events
|
||
};
|
||
}
|
||
};
|
||
window.__hornetMouse__ = Api;
|
||
|
||
var Tool = {
|
||
Start: function () { _start(); },
|
||
Stop: function () { _stop(); },
|
||
Status: function () {
|
||
if (!Stat.running) return '已停止';
|
||
return Stat.rawMode + ' ' + Stat.rate + '/s 尖峰' + Math.round(Stat.maxAbs) +
|
||
' 钳' + Stat.clamped + ' 丢' + Stat.dropped;
|
||
},
|
||
GetRunning: function () { return Stat.running; },
|
||
SetForceRaw: function (On) { Api.setForceRaw(On); },
|
||
GetForceRaw: function () { return Config.ForceRaw; },
|
||
SetDropMs: function (Ms) { Api.setDropMs(Ms); },
|
||
GetDropMs: function () { return Config.DropMs; },
|
||
SetMaxDelta: function (Px) { Api.setMaxDelta(Px); },
|
||
GetMaxDelta: function () { return Config.MaxDelta; },
|
||
GetStatus: function () { return Api.getStatus(); }
|
||
};
|
||
|
||
window.__hornetTools__ = window.__hornetTools__ || {};
|
||
window.__hornetTools__.Mouse = Tool;
|
||
console.log('[3DTank] 鼠标稳定模块已加载');
|
||
})();
|
||
|
||
|
||
// ===== hornet_panel.js =====
|
||
// ===== 3DTank 控制面板 =====
|
||
// 功能:Alt+T 切换面板显示,控制各工具开关
|
||
// 左上角 HUD 实时显示 GHT 捕获状态和各工具运行状态
|
||
// HUD 和面板支持鼠标拖动
|
||
// 依赖:hook_gamemode.js + hornet_lib.js + 各 hornet_*.js 工具模块
|
||
|
||
(function() {
|
||
console.log('[3DTank] 控制面板加载');
|
||
|
||
// ===== 清理旧面板 =====
|
||
if (window.__hornetPanel__) {
|
||
window.__hornetPanel__.Destroy();
|
||
console.log('已清理旧控制面板');
|
||
}
|
||
|
||
// 通用拖动功能
|
||
// elem: 要拖动的元素,handle: 触发拖动的元素(默认=elem)
|
||
// ignoreTags: 点击这些标签时不触发拖动(如 BUTTON)
|
||
// key: 可选,传入后把窗口位置存到 localStorage(hornet_layout_<key>)并在创建时还原
|
||
// 实现要点:Pointer Events + setPointerCapture 把指针"锁"在 handle 上,
|
||
// pointerup/pointercancel 必定回到 handle;并在 window 捕获阶段加兜底,
|
||
// 避免面板内 stopPropagation 或游戏画布抢事件导致"松手后仍粘着鼠标"。
|
||
// 返回:{ End, IsActive },供隐藏面板时强制结束拖动
|
||
function MakeDraggable(elem, handle, ignoreTags, key) {
|
||
handle = handle || elem;
|
||
ignoreTags = ignoreTags || ['BUTTON', 'INPUT', 'SELECT', 'TEXTAREA'];
|
||
handle.style.cursor = 'move';
|
||
|
||
var StorageKey = key ? ('hornet_layout_' + key) : null;
|
||
|
||
function ClampX(v) { return Math.max(0, Math.min(window.innerWidth - 40, v)); }
|
||
function ClampY(v) { return Math.max(0, Math.min(window.innerHeight - 24, v)); }
|
||
|
||
// 还原上次保存的位置
|
||
if (StorageKey) {
|
||
try {
|
||
var __saved = localStorage.getItem(StorageKey);
|
||
if (__saved) {
|
||
var __p = JSON.parse(__saved);
|
||
if (__p && typeof __p.left === 'number' && typeof __p.top === 'number') {
|
||
elem.style.transform = 'none';
|
||
elem.style.left = ClampX(__p.left) + 'px';
|
||
elem.style.top = ClampY(__p.top) + 'px';
|
||
}
|
||
}
|
||
} catch(e) {}
|
||
}
|
||
|
||
function SavePos() {
|
||
if (!StorageKey) return;
|
||
try {
|
||
var r = elem.getBoundingClientRect();
|
||
localStorage.setItem(StorageKey, JSON.stringify({ left: Math.round(r.left), top: Math.round(r.top) }));
|
||
} catch(e) {}
|
||
}
|
||
|
||
var Active = false;
|
||
var PointerId = null;
|
||
var GrabX = 0;
|
||
var GrabY = 0;
|
||
|
||
function MoveTo(ClientX, ClientY) {
|
||
elem.style.left = ClampX(ClientX - GrabX) + 'px';
|
||
elem.style.top = ClampY(ClientY - GrabY) + 'px';
|
||
}
|
||
|
||
function Begin(ClientX, ClientY) {
|
||
var Rect = elem.getBoundingClientRect();
|
||
// 移除 transform 居中,改用绝对定位,避免拖动起点跳变
|
||
elem.style.transform = 'none';
|
||
elem.style.left = Rect.left + 'px';
|
||
elem.style.top = Rect.top + 'px';
|
||
GrabX = ClientX - Rect.left;
|
||
GrabY = ClientY - Rect.top;
|
||
Active = true;
|
||
handle.style.cursor = 'grabbing';
|
||
}
|
||
|
||
function End() {
|
||
if (!Active) return;
|
||
Active = false;
|
||
if (PointerId !== null && handle.releasePointerCapture) {
|
||
try { handle.releasePointerCapture(PointerId); } catch (Err) {}
|
||
}
|
||
PointerId = null;
|
||
handle.style.cursor = 'move';
|
||
SavePos();
|
||
}
|
||
|
||
function CanStart(Target) {
|
||
return !Target || ignoreTags.indexOf(Target.tagName) < 0;
|
||
}
|
||
|
||
if (window.PointerEvent) {
|
||
// 捕获阶段监听:先于元素自身的 stopPropagation 执行
|
||
handle.addEventListener('pointerdown', function (e) {
|
||
if (e.button !== 0) return;
|
||
if (!CanStart(e.target)) return;
|
||
Begin(e.clientX, e.clientY);
|
||
PointerId = e.pointerId;
|
||
try { handle.setPointerCapture(e.pointerId); } catch (Err) {}
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
}, true);
|
||
|
||
handle.addEventListener('pointermove', function (e) {
|
||
if (!Active || (PointerId !== null && e.pointerId !== PointerId)) return;
|
||
// 兜底:按键已经松开(例如在窗口外松手)→ 结束拖动
|
||
if (e.buttons === 0) { End(); return; }
|
||
MoveTo(e.clientX, e.clientY);
|
||
e.preventDefault();
|
||
}, true);
|
||
|
||
handle.addEventListener('pointerup', function () { End(); }, true);
|
||
handle.addEventListener('pointercancel', function () { End(); }, true);
|
||
handle.addEventListener('lostpointercapture', function () { End(); }, true);
|
||
window.addEventListener('pointerup', function () { End(); }, true);
|
||
} else {
|
||
// 旧浏览器回退:mouse 事件 + document 捕获阶段监听
|
||
handle.addEventListener('mousedown', function (e) {
|
||
if (e.button !== 0) return;
|
||
if (!CanStart(e.target)) return;
|
||
Begin(e.clientX, e.clientY);
|
||
e.preventDefault();
|
||
}, true);
|
||
document.addEventListener('mousemove', function (e) {
|
||
if (!Active) return;
|
||
if (e.buttons === 0) { End(); return; }
|
||
MoveTo(e.clientX, e.clientY);
|
||
}, true);
|
||
document.addEventListener('mouseup', function () { End(); }, true);
|
||
}
|
||
|
||
// 公共兜底:窗口失焦 / 页面切后台 / 鼠标离开窗口
|
||
window.addEventListener('blur', End);
|
||
window.addEventListener('mouseleave', End);
|
||
document.addEventListener('visibilitychange', function () { if (document.hidden) End(); });
|
||
|
||
return { End: End, IsActive: function () { return Active; } };
|
||
}
|
||
|
||
// ===== 工具列表(动态从 __hornetTools__ 获取)=====
|
||
function GetToolList() {
|
||
var tools = window.__hornetTools__ || {};
|
||
return [
|
||
{ key: 'Radar', label: '雷达', obj: tools.Radar },
|
||
{ key: 'Wireframe', label: '3D透视', obj: tools.Wireframe, hasMode: true },
|
||
{ key: 'Rearview', label: '后视镜', obj: tools.Rearview },
|
||
{ key: 'AimWarning', label: '瞄准警告', obj: tools.AimWarning },
|
||
{ key: 'FreeLook', label: '自由视角', obj: tools.FreeLook },
|
||
{ key: 'WarSandbox', label: '战争沙盘', obj: tools.WarSandbox },
|
||
{ key: 'Mouse', label: '鼠标稳定', obj: tools.Mouse }
|
||
];
|
||
}
|
||
|
||
// ===== 工具启用状态(持久化 + 默认启用)=====
|
||
// 默认除「自由视角」外全部开启:自由视角会直接接管相机,默认开启过于突兀
|
||
var ToolKeys = ['Radar', 'Wireframe', 'Rearview', 'AimWarning', 'FreeLook', 'WarSandbox', 'Mouse'];
|
||
var ToolDefaults = { Radar: true, Wireframe: true, Rearview: true, AimWarning: true, FreeLook: false, WarSandbox: true, Mouse: true };
|
||
var ToolEnabled = {};
|
||
try {
|
||
var __savedEnabled = JSON.parse(localStorage.getItem('hornet_tool_enabled') || '{}');
|
||
ToolKeys.forEach(function(k) {
|
||
ToolEnabled[k] = (typeof __savedEnabled[k] === 'boolean') ? __savedEnabled[k] : ToolDefaults[k];
|
||
});
|
||
} catch(e) {
|
||
ToolKeys.forEach(function(k) { ToolEnabled[k] = ToolDefaults[k]; });
|
||
}
|
||
// 把各工具当前的运行状态写回持久化(开关时调用)
|
||
function SaveToolEnabled() {
|
||
try {
|
||
var tools = window.__hornetTools__ || {};
|
||
ToolKeys.forEach(function(k) {
|
||
var t = tools[k];
|
||
if (t && typeof t.GetRunning === 'function') ToolEnabled[k] = !!t.GetRunning();
|
||
});
|
||
localStorage.setItem('hornet_tool_enabled', JSON.stringify(ToolEnabled));
|
||
} catch(e) {}
|
||
}
|
||
|
||
// 需要 ght 才能初始化的工具(要 hook 相机 / GL)——等捕获后再启动,避免失败报错刷屏
|
||
var GameModeTools = { FreeLook: true, Rearview: true, WarSandbox: true };
|
||
|
||
// 自动按保存状态开启:覆盖层(雷达/透视/瞄准)无 ght 也能先开并出 UI;
|
||
// 每 30 帧检查一次,全部进入运行状态即停
|
||
var AutoEnableDone = false;
|
||
var AutoEnableFrame = 0;
|
||
function TryAutoEnable() {
|
||
if (AutoEnableDone) return;
|
||
AutoEnableFrame++;
|
||
if (AutoEnableFrame % 30 !== 0) return;
|
||
|
||
var HasGm = !!window.__gameMode__;
|
||
var tools = window.__hornetTools__ || {};
|
||
var pending = 0;
|
||
ToolKeys.forEach(function(k) {
|
||
if (!ToolEnabled[k]) return;
|
||
if (GameModeTools[k] && !HasGm) { pending++; return; } // 等 ght 捕获
|
||
var t = tools[k];
|
||
if (!t || typeof t.Start !== 'function') return;
|
||
if (typeof t.GetRunning === 'function' && t.GetRunning()) return;
|
||
pending++;
|
||
try { t.Start(); } catch(e) {}
|
||
});
|
||
if (pending === 0) {
|
||
AutoEnableDone = true;
|
||
UpdateButtons();
|
||
console.log('[3DTank] 已按保存状态自动开启工具(覆盖层无 ght 也可先开;后视镜/沙盘/自由视角等 ght 捕获后开)');
|
||
}
|
||
}
|
||
|
||
// ===== HUD(左上角状态显示)—— 已按需求移除:**不挂载到页面**,等于不再显示。
|
||
// 注意:保留 hud 对象与 UpdateHud 循环 —— 循环里还负责 TryAutoEnable()(按保存状态自动开启工具)。
|
||
var hud = document.createElement('div');
|
||
hud.id = '3dtank-hud';
|
||
// 原样式 + document.body.appendChild(hud) 已移除(那就是常驻左上角的「3DTank 控制台」小窗)
|
||
var HudDrag = MakeDraggable(hud, null, null, 'hud');
|
||
|
||
// ===== 面板(可拖动,Alt+T 切换)=====
|
||
var panel = document.createElement('div');
|
||
panel.id = '3dtank-panel';
|
||
// 初始居中:用 left/top 50% + transform,拖动后移除 transform
|
||
panel.style.cssText =
|
||
'position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);z-index:100001;' +
|
||
'background:rgba(12,16,24,0.72);backdrop-filter:blur(20px);-webkit-backdrop-filter:blur(20px);' +
|
||
'border:1px solid rgba(125,249,255,0.2);border-radius:12px;' +
|
||
'padding:18px 22px;color:#e8eef5;font:13px Consolas,monospace;min-width:340px;' +
|
||
'box-shadow:0 8px 32px rgba(0,0,0,0.6),0 0 1px rgba(125,249,255,0.3),inset 0 1px 0 rgba(255,255,255,0.05);' +
|
||
'display:none;user-select:none;-webkit-user-select:none;';
|
||
|
||
// 标题(可拖动手柄)
|
||
var title = document.createElement('div');
|
||
title.textContent = '3DTank 控制面板';
|
||
title.style.cssText =
|
||
'font-size:16px;font-weight:bold;color:#7df9ff;margin-bottom:4px;' +
|
||
'text-shadow:0 0 6px rgba(125,249,255,0.4);' +
|
||
'cursor:move;padding-bottom:8px;border-bottom:1px solid rgba(125,249,255,0.15);' +
|
||
'letter-spacing:0.5px;';
|
||
panel.appendChild(title);
|
||
|
||
var subtitle = document.createElement('div');
|
||
subtitle.textContent = 'Alt+T 切换显示/隐藏 | 拖动标题移动面板';
|
||
subtitle.style.cssText = 'font-size:11px;color:#5a7a8a;margin:6px 0 14px;';
|
||
panel.appendChild(subtitle);
|
||
|
||
// 工具按钮容器
|
||
var btnContainer = document.createElement('div');
|
||
panel.appendChild(btnContainer);
|
||
|
||
// ===== 3D透视设置子面板(可折叠)=====
|
||
var wfSettings = document.createElement('div');
|
||
wfSettings.style.cssText = 'margin-top:12px;padding-top:10px;border-top:1px solid rgba(125,249,255,0.08);';
|
||
|
||
var wfHeader = document.createElement('div');
|
||
wfHeader.style.cssText = 'display:flex;justify-content:space-between;align-items:center;cursor:pointer;color:#7df9ff;font-weight:bold;font-size:13px;';
|
||
wfHeader.innerHTML = '<span>3D透视设置</span><span id="wfToggle" style="font-size:11px;color:#5a7a8a;">▼</span>';
|
||
wfSettings.appendChild(wfHeader);
|
||
|
||
var wfBody = document.createElement('div');
|
||
wfBody.style.cssText = 'margin-top:8px;display:block;';
|
||
wfSettings.appendChild(wfBody);
|
||
var wfCollapsed = false;
|
||
wfHeader.onclick = function() {
|
||
wfCollapsed = !wfCollapsed;
|
||
wfBody.style.display = wfCollapsed ? 'none' : 'block';
|
||
document.getElementById('wfToggle').textContent = wfCollapsed ? '▶' : '▼';
|
||
};
|
||
|
||
// 行布局辅助
|
||
function MakeRow(labelText) {
|
||
var row = document.createElement('div');
|
||
row.style.cssText = 'display:flex;justify-content:space-between;align-items:center;padding:6px 0;';
|
||
var lab = document.createElement('span');
|
||
lab.style.cssText = 'color:#b8c8d8;font-size:12px;';
|
||
lab.textContent = labelText;
|
||
row.appendChild(lab);
|
||
var ctrl = document.createElement('div');
|
||
ctrl.style.cssText = 'display:flex;gap:6px;align-items:center;';
|
||
row.appendChild(ctrl);
|
||
wfBody.appendChild(row);
|
||
return { row: row, ctrl: ctrl };
|
||
}
|
||
|
||
// 线宽调节
|
||
var lwRow = MakeRow('线宽');
|
||
var lwSlider = document.createElement('input');
|
||
lwSlider.type = 'range';
|
||
lwSlider.min = '0.5'; lwSlider.max = '8'; lwSlider.step = '0.5';
|
||
lwSlider.style.cssText = 'width:120px;accent-color:#7df9ff;';
|
||
var lwVal = document.createElement('span');
|
||
lwVal.style.cssText = 'color:#7df9ff;font-size:11px;min-width:28px;text-align:right;';
|
||
lwRow.ctrl.appendChild(lwSlider);
|
||
lwRow.ctrl.appendChild(lwVal);
|
||
lwSlider.oninput = function() {
|
||
var v = parseFloat(lwSlider.value);
|
||
lwVal.textContent = v.toFixed(1);
|
||
var tools = window.__hornetTools__ || {};
|
||
if (tools.Wireframe) tools.Wireframe.SetLineWidth(v);
|
||
};
|
||
|
||
// 渲染模式选择
|
||
var hmRow = MakeRow('渲染模式');
|
||
var hmSelect = document.createElement('select');
|
||
hmSelect.style.cssText =
|
||
'padding:3px 6px;border:1px solid rgba(125,249,255,0.25);border-radius:4px;' +
|
||
'background:rgba(20,30,40,0.6);color:#e8eef5;font:11px Consolas;cursor:pointer;';
|
||
var hmOptions = [
|
||
{ v: 'team', t: '队伍模式(蓝友/红敌)' },
|
||
{ v: 'whitelist', t: '白名单(仅名单内渲染)' },
|
||
{ v: 'blacklist', t: '黑名单(名单内不渲染)' }
|
||
];
|
||
hmOptions.forEach(function(o) {
|
||
var opt = document.createElement('option');
|
||
opt.value = o.v; opt.textContent = o.t;
|
||
hmSelect.appendChild(opt);
|
||
});
|
||
hmRow.ctrl.appendChild(hmSelect);
|
||
hmSelect.onchange = function() {
|
||
var tools = window.__hornetTools__ || {};
|
||
if (tools.Wireframe) tools.Wireframe.SetHighlightMode(hmSelect.value);
|
||
};
|
||
|
||
// 名单管理
|
||
var hlRow = MakeRow('名单');
|
||
var hlInput = document.createElement('input');
|
||
hlInput.type = 'text';
|
||
hlInput.placeholder = '玩家名';
|
||
hlInput.style.cssText =
|
||
'width:120px;padding:3px 6px;border:1px solid rgba(125,249,255,0.25);border-radius:4px;' +
|
||
'background:rgba(20,30,40,0.6);color:#e8eef5;font:11px Consolas;';
|
||
var hlAddBtn = document.createElement('button');
|
||
hlAddBtn.textContent = '+';
|
||
hlAddBtn.style.cssText =
|
||
'padding:3px 8px;border:1px solid rgba(46,204,113,0.4);border-radius:4px;' +
|
||
'background:rgba(46,204,113,0.2);color:#2ecc71;cursor:pointer;font:bold 12px Consolas;';
|
||
hlRow.ctrl.appendChild(hlInput);
|
||
hlRow.ctrl.appendChild(hlAddBtn);
|
||
|
||
var hlList = document.createElement('div');
|
||
hlList.style.cssText = 'margin-top:6px;max-height:120px;overflow-y:auto;font-size:11px;';
|
||
wfBody.appendChild(hlList);
|
||
|
||
function RefreshHlList() {
|
||
var tools = window.__hornetTools__ || {};
|
||
var arr = tools.Wireframe ? tools.Wireframe.GetHighlightList() : [];
|
||
hlList.innerHTML = '';
|
||
if (arr.length === 0) {
|
||
var empty = document.createElement('div');
|
||
empty.style.cssText = 'color:#5a7a8a;padding:4px 0;font-style:italic;';
|
||
empty.textContent = '(空)';
|
||
hlList.appendChild(empty);
|
||
return;
|
||
}
|
||
arr.forEach(function(name) {
|
||
var item = document.createElement('div');
|
||
item.style.cssText = 'display:flex;justify-content:space-between;align-items:center;padding:3px 4px;border-bottom:1px solid rgba(125,249,255,0.05);';
|
||
var ns = document.createElement('span');
|
||
ns.style.cssText = 'color:#b8c8d8;';
|
||
ns.textContent = name;
|
||
item.appendChild(ns);
|
||
var delBtn = document.createElement('button');
|
||
delBtn.textContent = '×';
|
||
delBtn.style.cssText =
|
||
'padding:1px 6px;border:1px solid rgba(224,85,85,0.4);border-radius:3px;' +
|
||
'background:rgba(224,85,85,0.2);color:#e05555;cursor:pointer;font:bold 11px Consolas;';
|
||
delBtn.onclick = function() {
|
||
if (tools.Wireframe) tools.Wireframe.RemoveHighlight(name);
|
||
RefreshHlList();
|
||
};
|
||
item.appendChild(delBtn);
|
||
hlList.appendChild(item);
|
||
});
|
||
}
|
||
function AddFromInput() {
|
||
var name = hlInput.value.trim();
|
||
if (!name) return;
|
||
var tools = window.__hornetTools__ || {};
|
||
if (tools.Wireframe) tools.Wireframe.AddHighlight(name);
|
||
hlInput.value = '';
|
||
RefreshHlList();
|
||
}
|
||
hlAddBtn.onclick = AddFromInput;
|
||
hlInput.addEventListener('keydown', function(e) {
|
||
if (e.key === 'Enter') { e.preventDefault(); AddFromInput(); }
|
||
});
|
||
|
||
var hlClearRow = document.createElement('div');
|
||
hlClearRow.style.cssText = 'margin-top:4px;text-align:right;';
|
||
var hlClearBtn = document.createElement('button');
|
||
hlClearBtn.textContent = '清空名单';
|
||
hlClearBtn.style.cssText =
|
||
'padding:3px 8px;border:1px solid rgba(224,85,85,0.4);border-radius:4px;' +
|
||
'background:rgba(224,85,85,0.15);color:#e05555;cursor:pointer;font:11px Consolas;';
|
||
hlClearBtn.onclick = function() {
|
||
var tools = window.__hornetTools__ || {};
|
||
if (tools.Wireframe) tools.Wireframe.ClearHighlight();
|
||
RefreshHlList();
|
||
};
|
||
hlClearRow.appendChild(hlClearBtn);
|
||
wfBody.appendChild(hlClearRow);
|
||
|
||
panel.appendChild(wfSettings);
|
||
|
||
// ===== 通用可折叠设置子面板辅助函数 =====
|
||
function MakeSettingsSection(titleText) {
|
||
var sec = document.createElement('div');
|
||
sec.style.cssText = 'margin-top:10px;padding-top:8px;border-top:1px solid rgba(125,249,255,0.08);';
|
||
var hdr = document.createElement('div');
|
||
hdr.style.cssText = 'display:flex;justify-content:space-between;align-items:center;cursor:pointer;color:#7df9ff;font-weight:bold;font-size:12px;';
|
||
var toggleChar = document.createElement('span');
|
||
toggleChar.style.cssText = 'font-size:10px;color:#5a7a8a;';
|
||
toggleChar.textContent = '▶';
|
||
hdr.innerHTML = '<span>' + titleText + '</span>';
|
||
hdr.appendChild(toggleChar);
|
||
sec.appendChild(hdr);
|
||
var body = document.createElement('div');
|
||
body.style.cssText = 'margin-top:6px;display:none;';
|
||
sec.appendChild(body);
|
||
hdr.onclick = function() {
|
||
var shown = body.style.display !== 'none';
|
||
body.style.display = shown ? 'none' : 'block';
|
||
toggleChar.textContent = shown ? '▶' : '▼';
|
||
};
|
||
panel.appendChild(sec);
|
||
function row(labelText) {
|
||
var r = document.createElement('div');
|
||
r.style.cssText = 'display:flex;justify-content:space-between;align-items:center;padding:5px 0;';
|
||
var lab = document.createElement('span');
|
||
lab.style.cssText = 'color:#b8c8d8;font-size:11px;';
|
||
lab.textContent = labelText;
|
||
r.appendChild(lab);
|
||
var ctrl = document.createElement('div');
|
||
ctrl.style.cssText = 'display:flex;gap:6px;align-items:center;';
|
||
r.appendChild(ctrl);
|
||
body.appendChild(r);
|
||
return { row: r, ctrl: ctrl };
|
||
}
|
||
function slider(labelText, min, max, step, fmt, onInput) {
|
||
var r = row(labelText);
|
||
var s = document.createElement('input');
|
||
s.type = 'range'; s.min = String(min); s.max = String(max); s.step = String(step);
|
||
s.style.cssText = 'width:110px;accent-color:#7df9ff;';
|
||
var v = document.createElement('span');
|
||
v.style.cssText = 'color:#7df9ff;font-size:10px;min-width:32px;text-align:right;';
|
||
s.oninput = function() {
|
||
var val = parseFloat(s.value);
|
||
v.textContent = fmt(val);
|
||
onInput(val);
|
||
};
|
||
r.ctrl.appendChild(s); r.ctrl.appendChild(v);
|
||
return {
|
||
set: function(val) { s.value = val; v.textContent = fmt(parseFloat(val)); },
|
||
get: function() { return parseFloat(s.value); }
|
||
};
|
||
}
|
||
function checkbox(labelText, onChange) {
|
||
var r = row(labelText);
|
||
var c = document.createElement('input');
|
||
c.type = 'checkbox';
|
||
c.style.cssText = 'accent-color:#7df9ff;width:16px;height:16px;cursor:pointer;';
|
||
c.onchange = function() { onChange(c.checked); };
|
||
r.ctrl.appendChild(c);
|
||
return {
|
||
set: function(on) { c.checked = !!on; },
|
||
get: function() { return c.checked; }
|
||
};
|
||
}
|
||
return { sec: sec, body: body, row: row, slider: slider, checkbox: checkbox };
|
||
}
|
||
|
||
// ===== 雷达设置 =====
|
||
var radarSec = MakeSettingsSection('雷达设置');
|
||
var radarRangeCtl = radarSec.slider('范围(m)', 30, 600, 10, function(v){ return v.toFixed(0); }, function(v) {
|
||
var tools = window.__hornetTools__ || {};
|
||
if (tools.Radar) tools.Radar.SetRange(v * 100);
|
||
});
|
||
var radarOpacityCtl = radarSec.slider('透明度', 0.2, 1.0, 0.05, function(v){ return v.toFixed(2); }, function(v) {
|
||
var tools = window.__hornetTools__ || {};
|
||
if (tools.Radar) tools.Radar.SetOpacity(v);
|
||
});
|
||
|
||
// ===== 鼠标稳定设置 =====
|
||
// 强制原始输入:游戏锁定失败会静默回退成带 OS 加速的普通锁定,手感会飘
|
||
// 锁后忽略:锁定刚建立 / 失焦恢复后的头几毫秒位移常是巨幅跳变,直接丢弃
|
||
// 尖峰钳制:单事件位移超过阈值就截断(0 = 关闭)
|
||
var mouseSec = MakeSettingsSection('鼠标稳定');
|
||
var mouseRawCtl = mouseSec.checkbox('强制原始输入', function(on) {
|
||
var tools = window.__hornetTools__ || {};
|
||
if (tools.Mouse) tools.Mouse.SetForceRaw(on);
|
||
});
|
||
var mouseDropCtl = mouseSec.slider('锁后忽略(ms)', 0, 500, 20, function(v){ return v.toFixed(0); }, function(v) {
|
||
var tools = window.__hornetTools__ || {};
|
||
if (tools.Mouse) tools.Mouse.SetDropMs(v);
|
||
});
|
||
var mouseClampCtl = mouseSec.slider('尖峰钳制(px)', 0, 1000, 25, function(v){ return v.toFixed(0); }, function(v) {
|
||
var tools = window.__hornetTools__ || {};
|
||
if (tools.Mouse) tools.Mouse.SetMaxDelta(v);
|
||
});
|
||
(function () { // 回填鼠标模块里已保存的配置
|
||
var tools = window.__hornetTools__ || {};
|
||
if (!tools.Mouse) return;
|
||
mouseRawCtl.set(tools.Mouse.GetForceRaw());
|
||
mouseDropCtl.set(tools.Mouse.GetDropMs());
|
||
mouseClampCtl.set(tools.Mouse.GetMaxDelta());
|
||
})();
|
||
|
||
// ===== 后视镜设置 =====
|
||
var rvSec = MakeSettingsSection('后视镜设置');
|
||
var rvFovCtl = rvSec.slider('FOV', 0.5, 2.5, 0.05, function(v){ return v.toFixed(2); }, function(v) {
|
||
var tools = window.__hornetTools__ || {};
|
||
if (tools.Rearview) tools.Rearview.SetFov(v);
|
||
});
|
||
var rvOpacityCtl = rvSec.slider('透明度', 0.2, 1.0, 0.05, function(v){ return v.toFixed(2); }, function(v) {
|
||
var tools = window.__hornetTools__ || {};
|
||
if (tools.Rearview) tools.Rearview.SetOpacity(v);
|
||
});
|
||
|
||
// ===== 瞄准警告设置 =====
|
||
var awSec = MakeSettingsSection('瞄准警告设置');
|
||
var awSensCtl = awSec.slider('灵敏度', 0.3, 3.0, 0.1, function(v){ return v.toFixed(1); }, function(v) {
|
||
var tools = window.__hornetTools__ || {};
|
||
if (tools.AimWarning) tools.AimWarning.SetSensitivity(v);
|
||
});
|
||
var awSoundCtl = awSec.checkbox('警告声音', function(on) {
|
||
var tools = window.__hornetTools__ || {};
|
||
if (tools.AimWarning) tools.AimWarning.SetSound(on);
|
||
});
|
||
|
||
// ===== 战争沙盘设置 =====
|
||
var wsSec = MakeSettingsSection('战争沙盘设置');
|
||
var wsGridCtl = wsSec.checkbox('显示网格', function(on) {
|
||
var tools = window.__hornetTools__ || {};
|
||
if (tools.WarSandbox) tools.WarSandbox.SetShowGrid(on);
|
||
});
|
||
var wsMarkerCtl = wsSec.slider('标记大小', 0.5, 3.0, 0.1, function(v){ return v.toFixed(1); }, function(v) {
|
||
var tools = window.__hornetTools__ || {};
|
||
if (tools.WarSandbox) tools.WarSandbox.SetMarkerSize(v);
|
||
});
|
||
|
||
// ===== 电磁炮(RAILGUN / z4t)设置 =====
|
||
// 锁定/蓄力时长实时覆盖 window.__hornetLockSec__(游戏内 z4t.c18o() 优先读它),
|
||
// 拖动滑块**立即生效**,不需要重打包或刷新页面。
|
||
// 实测门槛:1.5s / 2.0s / 原版3~5s = 满伤;1.0s / 0.1s = 无伤害 ⇒ 门槛在 1.0~1.5s。
|
||
var rgSec = MakeSettingsSection('电磁炮设置');
|
||
var rgSecVal = 1.5;
|
||
function ApplyLock(v) { window.__hornetLockSec__ = (v > 0 ? v : undefined); }
|
||
function SaveLock(on, v) {
|
||
try {
|
||
localStorage.setItem('hornet_lock_on', on ? '1' : '0');
|
||
localStorage.setItem('hornet_lock_sec', String(v));
|
||
} catch (e) {}
|
||
}
|
||
var rgOnCtl = rgSec.checkbox('启用时长覆盖', function(on) {
|
||
ApplyLock(on ? rgSecVal : 0);
|
||
SaveLock(on, rgSecVal);
|
||
});
|
||
var rgLockCtl = rgSec.slider('锁定/蓄力(s)', 0.1, 5.0, 0.05, function(v) { return v.toFixed(2); }, function(v) {
|
||
rgSecVal = v;
|
||
if (rgOnCtl.get()) ApplyLock(v);
|
||
SaveLock(rgOnCtl.get(), v);
|
||
});
|
||
(function () { // 回填上次保存的配置;**默认开启覆盖、默认值 1.5s**(实测满伤的最小锁定/蓄力时长)
|
||
try {
|
||
var on = localStorage.getItem('hornet_lock_on') !== '0';
|
||
var v = parseFloat(localStorage.getItem('hornet_lock_sec'));
|
||
if (isFinite(v) && v > 0) rgSecVal = v;
|
||
rgLockCtl.set(rgSecVal);
|
||
rgOnCtl.set(on);
|
||
ApplyLock(on ? rgSecVal : 0);
|
||
} catch (e) {}
|
||
})();
|
||
|
||
// 从工具读取当前设置(打开面板时同步)
|
||
function SyncWfSettings() {
|
||
var tools = window.__hornetTools__ || {};
|
||
if (tools.Wireframe) {
|
||
var lw = tools.Wireframe.GetLineWidth();
|
||
lwSlider.value = lw; lwVal.textContent = lw.toFixed(1);
|
||
var hm = tools.Wireframe.GetHighlightMode();
|
||
hmSelect.value = hm;
|
||
}
|
||
RefreshHlList();
|
||
// 同步其他工具设置
|
||
if (tools.Radar) {
|
||
radarRangeCtl.set(tools.Radar.GetRange() / 100);
|
||
radarOpacityCtl.set(tools.Radar.GetOpacity());
|
||
}
|
||
if (tools.Rearview) {
|
||
rvFovCtl.set(tools.Rearview.GetFov());
|
||
rvOpacityCtl.set(tools.Rearview.GetOpacity());
|
||
}
|
||
if (tools.AimWarning) {
|
||
awSensCtl.set(tools.AimWarning.GetSensitivity());
|
||
awSoundCtl.set(tools.AimWarning.GetSound());
|
||
}
|
||
if (tools.WarSandbox) {
|
||
wsGridCtl.set(tools.WarSandbox.GetShowGrid());
|
||
wsMarkerCtl.set(tools.WarSandbox.GetMarkerSize());
|
||
}
|
||
}
|
||
|
||
// 底部状态
|
||
var footer = document.createElement('div');
|
||
footer.style.cssText = 'margin-top:14px;padding-top:10px;border-top:1px solid rgba(125,249,255,0.08);font-size:11px;color:#5a7a8a;text-align:center;';
|
||
footer.textContent = '点击按钮切换 开/关';
|
||
panel.appendChild(footer);
|
||
|
||
// 热键显示区域(UpdateHotkeyDisplay 在热键系统定义后调用)
|
||
var hotkeySection = document.createElement('div');
|
||
hotkeySection.style.cssText = 'margin-top:12px;padding-top:10px;border-top:1px solid rgba(125,249,255,0.08);font-size:11px;';
|
||
function UpdateHotkeyDisplay() {
|
||
if (!HotkeyLabels || !Hotkeys) return;
|
||
var html = '<div style="color:#7a9aaa;margin-bottom:4px;">热键(控制台可改: __hornetPanel__.SetHotkey)</div>';
|
||
Object.keys(HotkeyLabels).forEach(function(k) {
|
||
html += '<div style="display:flex;justify-content:space-between;padding:2px 0;">' +
|
||
'<span style="color:#5a7a8a;">' + HotkeyLabels[k] + '</span>' +
|
||
'<span style="color:#7df9ff;font-weight:bold;">' + Hotkeys[k] + '</span></div>';
|
||
});
|
||
hotkeySection.innerHTML = html;
|
||
}
|
||
panel.appendChild(hotkeySection);
|
||
|
||
document.body.appendChild(panel);
|
||
// 面板通过标题栏拖动
|
||
var PanelDrag = MakeDraggable(panel, title, null, 'panel');
|
||
|
||
// ===== 阻止面板内事件冒泡到 window(防止游戏/工具拦截键盘鼠标)=====
|
||
// 鼠标事件:面板上所有鼠标事件都阻止冒泡(防止游戏转视角/开炮等)
|
||
['mousedown','mouseup','mousemove','wheel','click','dblclick','contextmenu'].forEach(function(evtName) {
|
||
panel.addEventListener(evtName, function(e) { e.stopPropagation(); });
|
||
});
|
||
// 键盘事件:只在输入元素上阻止冒泡(保留面板上其他位置的热键功能)
|
||
['keydown','keypress','keyup'].forEach(function(evtName) {
|
||
panel.addEventListener(evtName, function(e) {
|
||
var tag = e.target.tagName;
|
||
if (tag === 'INPUT' || tag === 'SELECT' || tag === 'TEXTAREA') {
|
||
e.stopPropagation();
|
||
}
|
||
});
|
||
});
|
||
// HUD 也阻止鼠标事件冒泡(防止拖动HUD时游戏响应)
|
||
['mousedown','mouseup','mousemove','wheel','click','contextmenu'].forEach(function(evtName) {
|
||
hud.addEventListener(evtName, function(e) { e.stopPropagation(); });
|
||
});
|
||
|
||
// ===== 构建工具按钮(含模式切换)=====
|
||
var btnMap = {}; // key -> { row, btn, label, tool, modeBtns }
|
||
function BuildButtons() {
|
||
btnContainer.innerHTML = '';
|
||
btnMap = {};
|
||
var list = GetToolList();
|
||
list.forEach(function(t) {
|
||
var row = document.createElement('div');
|
||
row.style.cssText = 'display:flex;justify-content:space-between;align-items:center;padding:8px 0;border-bottom:1px solid rgba(125,249,255,0.06);';
|
||
|
||
var label = document.createElement('span');
|
||
label.textContent = t.label;
|
||
label.style.cssText = 'color:#b8c8d8;font-size:13px;';
|
||
row.appendChild(label);
|
||
|
||
// 右侧按钮容器
|
||
var btnBox = document.createElement('div');
|
||
btnBox.style.cssText = 'display:flex;gap:4px;align-items:center;';
|
||
|
||
var modeBtns = null;
|
||
|
||
// Wireframe 模式切换按钮
|
||
if (t.hasMode && t.obj && t.obj.SetMode) {
|
||
modeBtns = {};
|
||
var modes = [
|
||
{ mode: 'wireframe', text: '线框' },
|
||
{ mode: 'outline', text: '描边' }
|
||
];
|
||
modes.forEach(function(mb) {
|
||
var b = document.createElement('button');
|
||
b.textContent = mb.text;
|
||
b.style.cssText =
|
||
'padding:4px 8px;border:1px solid rgba(125,249,255,0.25);border-radius:4px;' +
|
||
'font:11px Consolas,monospace;cursor:pointer;color:#e8eef5;min-width:44px;' +
|
||
'background:rgba(20,30,40,0.6);transition:all 0.2s;';
|
||
b.onclick = function() {
|
||
if (t.obj) {
|
||
t.obj.SetMode(mb.mode);
|
||
UpdateButtons();
|
||
}
|
||
};
|
||
btnBox.appendChild(b);
|
||
modeBtns[mb.mode] = b;
|
||
});
|
||
}
|
||
|
||
var btn = document.createElement('button');
|
||
btn.style.cssText =
|
||
'padding:6px 16px;border:none;border-radius:6px;font:bold 12px Consolas,monospace;' +
|
||
'cursor:pointer;min-width:72px;color:#fff;transition:all 0.2s;' +
|
||
'box-shadow:0 2px 8px rgba(0,0,0,0.3);';
|
||
btn.onclick = function() {
|
||
if (!t.obj) {
|
||
console.warn('[3DTank] 工具 ' + t.key + ' 未加载');
|
||
return;
|
||
}
|
||
if (t.obj.GetRunning()) {
|
||
t.obj.Stop();
|
||
} else {
|
||
t.obj.Start();
|
||
}
|
||
UpdateButtons();
|
||
SaveToolEnabled();
|
||
};
|
||
btnBox.appendChild(btn);
|
||
|
||
row.appendChild(btnBox);
|
||
btnContainer.appendChild(row);
|
||
btnMap[t.key] = { btn: btn, label: label, tool: t.obj, modeBtns: modeBtns };
|
||
});
|
||
}
|
||
BuildButtons();
|
||
|
||
// ===== 更新按钮状态 =====
|
||
function UpdateButtons() {
|
||
Object.keys(btnMap).forEach(function(key) {
|
||
var info = btnMap[key];
|
||
if (!info.tool) {
|
||
info.btn.textContent = '未加载';
|
||
info.btn.style.background = '#555';
|
||
info.btn.disabled = true;
|
||
return;
|
||
}
|
||
var running = info.tool.GetRunning();
|
||
info.btn.textContent = running ? '关闭' : '开启';
|
||
info.btn.style.background = running
|
||
? 'linear-gradient(135deg,#e05555,#c0333d)'
|
||
: 'linear-gradient(135deg,#2ecc71,#27ae60)';
|
||
info.btn.disabled = false;
|
||
|
||
// 更新模式按钮高亮
|
||
if (info.modeBtns) {
|
||
var curMode = null;
|
||
try { curMode = window.__hornet3DWireframe__ && window.__hornet3DWireframe__.mode; } catch(e) {}
|
||
Object.keys(info.modeBtns).forEach(function(mk) {
|
||
var mb = info.modeBtns[mk];
|
||
if (mk === curMode && running) {
|
||
mb.style.background = 'linear-gradient(135deg,#2ecc71,#27ae60)';
|
||
mb.style.opacity = '1';
|
||
mb.style.color = '#fff';
|
||
} else {
|
||
mb.style.background = 'rgba(20,30,40,0.6)';
|
||
mb.style.opacity = '0.6';
|
||
mb.style.color = '#8899aa';
|
||
}
|
||
});
|
||
}
|
||
});
|
||
}
|
||
|
||
// ===== 更新 HUD(HUD 本体已移除,这里主要保留 TryAutoEnable 与循环)=====
|
||
function UpdateHud() {
|
||
requestAnimationFrame(UpdateHud);
|
||
TryAutoEnable(); // gameMode 捕获后自动按保存状态开启工具
|
||
if (!hud.parentNode) return; // HUD 未挂载(已移除)→ 不必拼状态文本/写 DOM
|
||
var ght = window.__gameMode__;
|
||
var lines = [];
|
||
lines.push('3DTank 控制台 [拖动移动]');
|
||
lines.push('GHT: ' + (ght ? '已捕获' : '未捕获'));
|
||
var list = GetToolList();
|
||
list.forEach(function(t) {
|
||
var status = t.obj ? t.obj.Status() : '未加载';
|
||
var dot = t.obj && t.obj.GetRunning() ? '●' : '○';
|
||
lines.push(dot + ' ' + t.label + ': ' + status);
|
||
});
|
||
hud.textContent = lines.join('\n');
|
||
hud.style.color = ght ? '#7df9ff' : '#f5a623';
|
||
}
|
||
UpdateHud();
|
||
|
||
// ===== 热键系统(localStorage 持久化)=====
|
||
var DefaultHotkeys = {
|
||
panel: 'Alt+T',
|
||
radar: 'Alt+R',
|
||
wireframe: 'Alt+W',
|
||
rearview: 'Alt+V',
|
||
aimWarning: 'Alt+A',
|
||
freeLook: 'Alt+F',
|
||
warSandbox: 'Alt+M'
|
||
};
|
||
var HotkeyLabels = {
|
||
panel: '面板',
|
||
radar: '雷达',
|
||
wireframe: '3D透视',
|
||
rearview: '后视镜',
|
||
aimWarning: '瞄准警告',
|
||
freeLook: '自由视角',
|
||
warSandbox: '战争沙盘'
|
||
};
|
||
var Hotkeys = {};
|
||
try {
|
||
var saved = localStorage.getItem('3dtank_hotkeys');
|
||
var parsed = saved ? JSON.parse(saved) : {};
|
||
Object.keys(DefaultHotkeys).forEach(function(k) {
|
||
Hotkeys[k] = parsed[k] || DefaultHotkeys[k];
|
||
});
|
||
} catch(e) {
|
||
Object.keys(DefaultHotkeys).forEach(function(k) { Hotkeys[k] = DefaultHotkeys[k]; });
|
||
}
|
||
|
||
function SaveHotkeys() {
|
||
try { localStorage.setItem('3dtank_hotkeys', JSON.stringify(Hotkeys)); } catch(e) {}
|
||
}
|
||
|
||
function EventToHotkey(e) {
|
||
var parts = [];
|
||
if (e.altKey) parts.push('Alt');
|
||
if (e.ctrlKey) parts.push('Ctrl');
|
||
if (e.shiftKey) parts.push('Shift');
|
||
var key = e.key;
|
||
if (key.length === 1) key = key.toUpperCase();
|
||
if (key === ' ') key = 'Space';
|
||
parts.push(key);
|
||
return parts.join('+');
|
||
}
|
||
|
||
function HidePanel() {
|
||
panel.style.display = 'none';
|
||
PanelDrag.End(); // 防止隐藏时拖动状态残留(粘手)
|
||
}
|
||
|
||
// 释放鼠标指针锁定:游戏中鼠标被 Pointer Lock 捕获时指针隐藏、锁定在画布上,
|
||
// 面板点不到,需退出锁定让光标恢复可交互
|
||
function ReleasePointerLock() {
|
||
try {
|
||
if (document.exitPointerLock && document.pointerLockElement) {
|
||
document.exitPointerLock();
|
||
}
|
||
} catch(e) {}
|
||
}
|
||
|
||
function ShowPanel() {
|
||
panel.style.display = 'block';
|
||
ReleasePointerLock();
|
||
UpdateButtons();
|
||
SyncWfSettings();
|
||
}
|
||
|
||
function TogglePanel() {
|
||
if (panel.style.display === 'block') {
|
||
HidePanel();
|
||
} else {
|
||
ShowPanel();
|
||
}
|
||
}
|
||
|
||
function OnHotkey(e) {
|
||
if (e.key === 'Escape' && panel.style.display === 'block') {
|
||
HidePanel();
|
||
return;
|
||
}
|
||
var hk = EventToHotkey(e);
|
||
if (hk === Hotkeys.panel) {
|
||
e.preventDefault();
|
||
TogglePanel();
|
||
return;
|
||
}
|
||
var toolMap = { radar: 'Radar', wireframe: 'Wireframe', rearview: 'Rearview', aimWarning: 'AimWarning', freeLook: 'FreeLook', warSandbox: 'WarSandbox' };
|
||
Object.keys(toolMap).forEach(function(k) {
|
||
if (hk === Hotkeys[k]) {
|
||
e.preventDefault();
|
||
var tools = window.__hornetTools__ || {};
|
||
var tool = tools[toolMap[k]];
|
||
if (tool) {
|
||
if (tool.GetRunning()) tool.Stop();
|
||
else tool.Start();
|
||
UpdateButtons();
|
||
SaveToolEnabled();
|
||
}
|
||
}
|
||
});
|
||
}
|
||
// F5 刷新页面(游戏会吞掉 F5)——捕获阶段兜底,先于游戏处理
|
||
window.addEventListener('keydown', function(e) {
|
||
if (e.key === 'F5') { e.preventDefault(); e.stopImmediatePropagation(); location.reload(); }
|
||
}, true);
|
||
window.addEventListener('keydown', OnHotkey);
|
||
// 热键系统定义完成,更新显示
|
||
UpdateHotkeyDisplay();
|
||
|
||
// ===== 暴露接口 =====
|
||
window.__hornetPanel__ = {
|
||
Toggle: TogglePanel,
|
||
Show: ShowPanel,
|
||
Hide: HidePanel,
|
||
UpdateButtons: UpdateButtons,
|
||
RebuildButtons: BuildButtons,
|
||
GetHotkeys: function() { var r = {}; Object.keys(Hotkeys).forEach(function(k) { r[k] = Hotkeys[k]; }); return r; },
|
||
SetHotkey: function(name, key) {
|
||
if (DefaultHotkeys[name]) {
|
||
Hotkeys[name] = key;
|
||
SaveHotkeys();
|
||
UpdateHotkeyDisplay();
|
||
console.log('[3DTank] 热键 ' + name + ' 已设为 ' + key);
|
||
} else {
|
||
console.warn('[3DTank] 未知热键名: ' + name + ',可用: ' + Object.keys(DefaultHotkeys).join(', '));
|
||
}
|
||
},
|
||
ResetHotkeys: function() {
|
||
Object.keys(DefaultHotkeys).forEach(function(k) { Hotkeys[k] = DefaultHotkeys[k]; });
|
||
SaveHotkeys();
|
||
UpdateHotkeyDisplay();
|
||
console.log('[3DTank] 热键已重置为默认值');
|
||
},
|
||
Destroy: function() {
|
||
HudDrag.End();
|
||
PanelDrag.End();
|
||
if (hud.parentNode) hud.parentNode.removeChild(hud);
|
||
if (panel.parentNode) panel.parentNode.removeChild(panel);
|
||
window.removeEventListener('keydown', OnHotkey);
|
||
}
|
||
};
|
||
|
||
console.log('[3DTank] 控制面板已就绪,按 Alt+T 打开面板,HUD/面板/窗口可拖动,热键已加载');
|
||
})();
|
||
|