旧 main.b7114b43.js 已被新构建替换(10,011,039 字节 / 1335 模块),本地镜像整体重切到新版并清掉旧产物;hornet 工具链按 tools/map_symbols.js 生成的新旧符号映射改名后重新打包(3dtank_bundle.js 与 unpacked/hornet_bundle.js 同步)。tools/symbol_map.json(8.9MB 映射产物)与切割解包目录不进库,可用 map_symbols.js / split.js 重新生成。
5497 lines
209 KiB
JavaScript
5497 lines
209 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 验证坦克列表,避免捕获未初始化实例
|
||
//
|
||
// 原理:
|
||
// module_87072.$_$.ac = yht 函数(接收场景,返回 ght 实例)
|
||
// module_87072.$_$.zb = ght 类(GameMode 组件类)
|
||
// module_87072.$_$.bc = kht 函数(接收场景,返回 GameMode entity)
|
||
// yht(scene).xzq_1 = Tht 实例(坦克列表管理器)
|
||
// Tht.z11b() = a12c_1(坦克列表,Lx 实例)
|
||
|
||
(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.$_$.zb;
|
||
var yhtFunc = mod.$_$.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.q100_1.r11l();
|
||
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.$_$.ac = 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__.q100_1.r11l();
|
||
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__.q100_1.r11l();
|
||
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__.q100_1.r11l();
|
||
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__.q100_1.r11l();
|
||
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 导出)
|
||
// ZCt (mod.$_$.kd): 从坦克对象获取队伍ID
|
||
// m2 (mod.$_$.rk): 判断两个队伍ID是否相同且非中立 = t.equals(n) && !t.equals(E$())
|
||
function LoadTeamFunctions() {
|
||
if (ZCt && m2Fn) return { ZCt: ZCt, m2Fn: m2Fn };
|
||
try {
|
||
var mod87072 = window.__wp_require__(87072);
|
||
if (mod87072 && mod87072.$_$) {
|
||
ZCt = mod87072.$_$.kd;
|
||
m2Fn = mod87072.$_$.rk;
|
||
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.pyt_1 || !entity.pyt_1.s2a_1) return null;
|
||
var result = null;
|
||
entity.pyt_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;
|
||
}
|
||
|
||
// ===== 获取坦克位置(G$t 物理组件的 pz7_1 = O$向量) =====
|
||
// 返回 {x, y, z},x/y 水平面,z 高度
|
||
function GetTankPosition(tank) {
|
||
var physicsComp = FindComponentByField(tank, 'gzh_1');
|
||
if (physicsComp && physicsComp.gzh_1) {
|
||
return {
|
||
x: physicsComp.gzh_1.f20_1,
|
||
y: physicsComp.gzh_1.g20_1,
|
||
z: physicsComp.gzh_1.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.pyt_1 || !tank.pyt_1.s2a_1) return -1;
|
||
var tankComp = null;
|
||
tank.pyt_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.t101_1 && comp.o101_1 !== undefined) tankComp = comp;
|
||
} catch(e) {}
|
||
});
|
||
if (tankComp && tankComp.o101_1) return tankComp.o101_1.k3_1;
|
||
return -1;
|
||
}
|
||
|
||
// 只有 ACTIVE(3) 才是战斗中的坦克
|
||
function IsTankAlive(tank) { return GetTankState(tank) === 3; }
|
||
|
||
// ===== 获取坦克名称(yot 用户信息组件) =====
|
||
// s120_1 = userName, u120_1 = clanTag(可为null), t120_1 = userRank
|
||
function GetTankName(tank) {
|
||
var userComp = FindComponentByField(tank, 'k12a_1');
|
||
if (!userComp) return null;
|
||
var name = userComp.k12a_1 || '';
|
||
var clanTag = userComp.m12a_1 || '';
|
||
return clanTag ? (clanTag + ' ' + name) : name;
|
||
}
|
||
|
||
// ===== 获取坦克血量(Vkt 血量组件) =====
|
||
// f122_1 = 最大血量(max), g122_1 = 当前血量(current, 死亡时=0)
|
||
function GetTankHealth(tank) {
|
||
var healthComp = FindComponentByField(tank, 'y12b_1');
|
||
if (!healthComp) return null;
|
||
var max = healthComp.x12b_1 || 0;
|
||
var cur = healthComp.y12b_1 || 0;
|
||
if (max <= 0) return null;
|
||
return { current: cur, max: max, percent: cur / max };
|
||
}
|
||
|
||
// ===== 获取玩家坦克 =====
|
||
// 优先 ezr_1(当前观察坦克),备用 fzr_1(本地控制坦克),再备用坦克列表第一个
|
||
// checkPosition: 是否检查位置有效性(默认true;测试脚本传false以在死亡状态控制)
|
||
function GetPlayerTank(gm, checkPosition) {
|
||
if (checkPosition === undefined) checkPosition = true;
|
||
var t = gm.x100_1;
|
||
if (t && (!checkPosition || GetTankPosition(t))) return t;
|
||
t = gm.y100_1;
|
||
if (t && (!checkPosition || GetTankPosition(t))) return t;
|
||
try {
|
||
var tl = gm.q100_1.r11l();
|
||
if (tl.e1() > 0) {
|
||
t = tl.g1(0);
|
||
if (t && (!checkPosition || GetTankPosition(t))) return t;
|
||
}
|
||
} catch(e) {}
|
||
return null;
|
||
}
|
||
|
||
// ===== 判断是否友方 =====
|
||
// 策略1(优先):ZCt 获取双方队伍ID,m2 比较(同队且非中立)
|
||
// 策略2(备用):比较 gm.gzr_1(本地队伍ID)与坦克队伍ID
|
||
function IsFriendly(tank, playerTank, gm) {
|
||
if (!tank || !playerTank) return false;
|
||
if (ZCt && m2Fn) {
|
||
try {
|
||
var tankTeam = ZCt(tank);
|
||
var playerTeam = ZCt(playerTank);
|
||
if (tankTeam && playerTeam) return m2Fn(tankTeam, playerTeam);
|
||
} catch(e) {}
|
||
}
|
||
try {
|
||
if (gm && gm.z100_1 && ZCt) {
|
||
var team = ZCt(tank);
|
||
if (team && gm.z100_1) {
|
||
return team.equals(gm.z100_1) && m2Fn ? m2Fn(team, gm.z100_1) : team.equals(gm.z100_1);
|
||
}
|
||
}
|
||
} catch(e) {}
|
||
return false;
|
||
}
|
||
|
||
// ===== 获取相机信息($r/gq 相机组件) =====
|
||
// 遍历 GameMode entity 和所有坦克 entity 查找活跃相机组件
|
||
// 策略1:查找 $r/gq 相机组件(有 kym_1 位置 和 lym_1 方向)
|
||
// 策略2:查找 k7 相机控制器(有 qz9_1 和 gz9_1),从 gz9_1 获取相机组件
|
||
// 返回 {yaw, pitch, fov, pos:{x,y,z}}
|
||
// 注意:lym_1.h20_1 是 roll(绕Z),lym_1.f20_1 是 pitch(绕X)
|
||
function GetCameraInfo(gm) {
|
||
var entities = [];
|
||
try {
|
||
var gameScene = gm.jyw().iyt_1;
|
||
var gmEntity = gameScene.xyu("GameMode");
|
||
if (gmEntity) entities.push(gmEntity);
|
||
} catch(e) {}
|
||
try {
|
||
var tankList = gm.q100_1.r11l();
|
||
var tCount = tankList.e1();
|
||
for (var i = 0; i < tCount; i++) {
|
||
var t = tankList.g1(i);
|
||
if (t) entities.push(t);
|
||
}
|
||
} catch(e) {}
|
||
|
||
for (var i = 0; i < entities.length; i++) {
|
||
var entity = entities[i];
|
||
if (!entity || !entity.pyt_1 || !entity.pyt_1.s2a_1) continue;
|
||
var found = null;
|
||
entity.pyt_1.s2a_1.forEach(function(valueList, key) {
|
||
if (found) return;
|
||
try {
|
||
var comp = (valueList && typeof valueList.g1 === 'function') ? valueList.g1(0) : valueList;
|
||
if (!comp) return;
|
||
// 策略1:$r/gq 相机组件
|
||
if (comp.cyw_1 && comp.dyw_1 &&
|
||
comp.cyw_1.f20_1 !== undefined && comp.dyw_1.f20_1 !== undefined) {
|
||
if (Math.abs(comp.dyw_1.h20_1) > 0.001 || Math.abs(comp.dyw_1.f20_1) > 0.001) {
|
||
found = comp;
|
||
return;
|
||
}
|
||
}
|
||
// 策略2:k7 相机控制器
|
||
if (comp.jzj_1 && comp.zzi_1) {
|
||
var camComp = comp.zzi_1;
|
||
if (camComp && camComp.cyw_1 && camComp.dyw_1 &&
|
||
(Math.abs(camComp.dyw_1.h20_1) > 0.001 || Math.abs(camComp.dyw_1.f20_1) > 0.001)) {
|
||
found = camComp;
|
||
return;
|
||
}
|
||
}
|
||
} catch(e) {}
|
||
});
|
||
if (found) {
|
||
return {
|
||
yaw: found.dyw_1.h20_1,
|
||
pitch: found.dyw_1.f20_1,
|
||
fov: found.byw_1 || 1.0471976,
|
||
pos: {
|
||
x: found.cyw_1.f20_1,
|
||
y: found.cyw_1.g20_1,
|
||
z: found.cyw_1.h20_1
|
||
}
|
||
};
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
// ===== 获取全局碰撞检测器(uMt 组件,挂在 GameMode entity 上) =====
|
||
// g16x(startVec, dirVec, maxDist, resultObj, filterObj) = 全局射线检测
|
||
function GetUMtComponent(gm) {
|
||
try {
|
||
var gmEntity = gm.jyw();
|
||
if (!gmEntity || !gmEntity.pyt_1 || !gmEntity.pyt_1.s2a_1) return null;
|
||
var uMt = null;
|
||
gmEntity.pyt_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.m179 === '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,
|
||
c179_1: null,
|
||
b179_1: null,
|
||
d179: function(t, n, i, r, e) {
|
||
this.y178_1.k26(t);
|
||
this.z178_1.k26(n);
|
||
this.a179_1 = i;
|
||
this.c179_1 = r;
|
||
this.b179_1 = e;
|
||
},
|
||
d3: function() { this.b179_1 = null; }
|
||
};
|
||
var filter = {
|
||
o1co_1: { h1: function() { return false; }, d3: function() {} },
|
||
w179: function() { return true; },
|
||
d3: function() { this.o1co_1.d3(); },
|
||
p1co: function() {}
|
||
};
|
||
var hit = uMt.m179(startVec, dirVec, maxDist, result, filter);
|
||
return (hit && result.a179_1 < maxDist) ? result.a179_1 : -1;
|
||
} catch(e) {
|
||
return -1;
|
||
}
|
||
}
|
||
|
||
// ===== 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) =====
|
||
// 适用于固定定位的小窗口(雷达、后视镜等)
|
||
// 实现要点:Pointer Events + setPointerCapture,保证 pointerup/pointercancel 必定回到元素,
|
||
// 并在 window 捕获阶段兜底,避免游戏画布抢事件后"松手仍粘着鼠标"。
|
||
function MakeDraggable(elem) {
|
||
if (!elem) return null;
|
||
elem.style.cursor = 'move';
|
||
|
||
var Active = false;
|
||
var PointerId = null;
|
||
var GrabX = 0;
|
||
var GrabY = 0;
|
||
|
||
function MoveTo(ClientX, ClientY) {
|
||
var NextX = Math.max(0, Math.min(window.innerWidth - 50, ClientX - GrabX));
|
||
var NextY = Math.max(0, Math.min(window.innerHeight - 30, ClientY - GrabY));
|
||
elem.style.left = NextX + 'px';
|
||
elem.style.top = NextY + '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';
|
||
}
|
||
|
||
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,
|
||
GetPlayerTank: GetPlayerTank,
|
||
IsFriendly: IsFriendly,
|
||
GetCameraInfo: GetCameraInfo,
|
||
GetUMtComponent: GetUMtComponent,
|
||
MkVec: MkVec,
|
||
RaycastDistance: RaycastDistance,
|
||
Project3D: Project3D,
|
||
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雷达启动 =====');
|
||
|
||
if (!window.__gameMode__) {
|
||
console.error('请先运行 hook_gamemode.js');
|
||
return;
|
||
}
|
||
|
||
// ===== 清理旧雷达 =====
|
||
if (window.__hornetRadar__) {
|
||
window.__hornetRadar__.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;
|
||
|
||
// 组件查找:使用公共库(已修复 falsy 值漏判 bug)
|
||
var FindComponentByField = lib.FindComponentByField;
|
||
|
||
// 坦克位置:使用公共库
|
||
var GetTankPosition = lib.GetTankPosition;
|
||
|
||
// ===== 获取坦克方向(Kkt 组件的 dzc_1) =====
|
||
function getTankDirection(tank) {
|
||
var hullComp = FindComponentByField(tank, 'vzl_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;
|
||
}
|
||
|
||
// ===== 获取相机方向(gq/$r 组件的 lym_1.h20_1 = 偏航角) =====
|
||
// 相机组件可能在 GameMode entity、玩家坦克或任何坦克上
|
||
// 策略1:查找 $r/gq 相机组件(有 kym_1 和 lym_1 字段)
|
||
// 策略2:查找 k7 相机控制器(有 qz9_1 和 gz9_1 字段),从 gz9_1 获取相机组件
|
||
function getCameraHeading() {
|
||
var entities = [];
|
||
|
||
// 1. GameMode entity
|
||
try {
|
||
var gameScene = gm.jyw().iyt_1;
|
||
var gmEntity = gameScene.xyu("GameMode");
|
||
if (gmEntity) entities.push(gmEntity);
|
||
} catch(e) {}
|
||
|
||
// 2. 所有坦克列表中的 entity
|
||
try {
|
||
var tankList = gm.q100_1.r11l();
|
||
var tCount = tankList.e1();
|
||
for (var i = 0; i < tCount; i++) {
|
||
var t = tankList.g1(i);
|
||
if (t) entities.push(t);
|
||
}
|
||
} catch(e) {}
|
||
|
||
// 在每个 entity 上查找相机组件
|
||
for (var i = 0; i < entities.length; i++) {
|
||
var entity = entities[i];
|
||
if (!entity || !entity.pyt_1 || !entity.pyt_1.s2a_1) continue;
|
||
|
||
var found = null;
|
||
entity.pyt_1.s2a_1.forEach(function(valueList, key) {
|
||
if (found) return;
|
||
try {
|
||
var comp = (valueList && typeof valueList.g1 === 'function') ? valueList.g1(0) : valueList;
|
||
if (!comp) return;
|
||
|
||
// 策略1:查找 $r/gq 相机组件(有 kym_1 和 lym_1 字段)
|
||
if (comp.cyw_1 && comp.dyw_1 &&
|
||
comp.cyw_1.f20_1 !== undefined && comp.dyw_1.f20_1 !== undefined) {
|
||
// 检查是否是活跃的相机(lym_1 不全为0)
|
||
if (Math.abs(comp.dyw_1.h20_1) > 0.001 || Math.abs(comp.dyw_1.f20_1) > 0.001) {
|
||
found = comp;
|
||
return;
|
||
}
|
||
}
|
||
|
||
// 策略2:查找 k7 相机控制器(有 qz9_1 和 gz9_1 字段)
|
||
if (comp.jzj_1 && comp.zzi_1) {
|
||
var camComp = comp.zzi_1;
|
||
if (camComp && camComp.cyw_1 && camComp.dyw_1 &&
|
||
(Math.abs(camComp.dyw_1.h20_1) > 0.001 || Math.abs(camComp.dyw_1.f20_1) > 0.001)) {
|
||
found = camComp;
|
||
return;
|
||
}
|
||
}
|
||
} catch(e) {}
|
||
});
|
||
|
||
if (found) {
|
||
// lym_1.h20_1 = -atan2(t, n) = 偏航角
|
||
// lym_1.f20_1 = atan2(i, e) = 俯仰角
|
||
return {
|
||
yaw: -found.dyw_1.h20_1, // 偏航角(转为正向)
|
||
pitch: found.dyw_1.f20_1, // 俯仰角
|
||
pos: {
|
||
x: found.cyw_1.f20_1,
|
||
y: found.cyw_1.g20_1,
|
||
z: found.cyw_1.h20_1
|
||
}
|
||
};
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
// 玩家坦克:使用公共库
|
||
function GetPlayerTank() { return lib.GetPlayerTank(gm); }
|
||
|
||
// 友方判断:使用公共库
|
||
function IsFriendly(tank, playerTank) { return lib.IsFriendly(tank, playerTank, gm); }
|
||
|
||
// ===== 获取全局碰撞检测器(uMt 组件,挂在 GameMode entity 上) =====
|
||
var UMtComponent = null;
|
||
function getUMtComponent() {
|
||
if (UMtComponent) return UMtComponent;
|
||
try {
|
||
var gmEntity = gm.jyw();
|
||
if (!gmEntity || !gmEntity.pyt_1 || !gmEntity.pyt_1.s2a_1) return null;
|
||
gmEntity.pyt_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.m179 === '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, c179_1: null, b179_1: null,
|
||
d179: function(t, n, i, r, e) {
|
||
this.y178_1.k26(t); this.z178_1.k26(n);
|
||
this.a179_1 = i; this.c179_1 = r; this.b179_1 = e;
|
||
},
|
||
d3: function() { this.b179_1 = null; }
|
||
};
|
||
var filter = {
|
||
o1co_1: { h1: function() { return false; }, d3: function() {} },
|
||
w179: function() { return true; },
|
||
d3: function() {}, p1co: function() {}
|
||
};
|
||
var hit = uMt.m179(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);
|
||
|
||
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++;
|
||
|
||
try {
|
||
var tankList = gm.q100_1.r11l();
|
||
var count = tankList.e1();
|
||
radar.tankCount = count;
|
||
|
||
// 获取玩家坦克(带备用方案)
|
||
var playerTank = GetPlayerTank();
|
||
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 = getCameraHeading();
|
||
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;
|
||
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;
|
||
|
||
// 判断友方/敌方
|
||
var friendly = IsFriendly(tank, playerTank);
|
||
var color = friendly ? '#4488ff' : '#ff4444';
|
||
if (friendly) friendlyCount++; else enemyCount++;
|
||
|
||
// 画坦克点
|
||
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);
|
||
|
||
// 信息文本
|
||
ctx.fillStyle = 'rgba(255, 255, 255, 0.7)';
|
||
ctx.font = '9px Arial';
|
||
ctx.textAlign = 'left';
|
||
ctx.fillText('Tanks: ' + count + ' (B:' + friendlyCount + ' R:' + enemyCount + ')', 5, 12);
|
||
if (radar.playerFound) {
|
||
ctx.fillText('Player: ' + (playerTank ? playerTank.hyt_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透视启动 =====');
|
||
|
||
if (!window.__gameMode__) {
|
||
console.error('请先运行 hook_gamemode.js');
|
||
return;
|
||
}
|
||
|
||
// ===== 清理旧透视 =====
|
||
if (window.__hornet3DWireframe__) {
|
||
window.__hornet3DWireframe__.stop();
|
||
console.log('已停止旧透视');
|
||
}
|
||
if (window.__hornet3DOutline__) {
|
||
window.__hornet3DOutline__.stop();
|
||
console.log('已停止旧描边透视');
|
||
}
|
||
|
||
var gm = window.__gameMode__;
|
||
|
||
// ===== 当前模式 =====
|
||
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 TransformVertex = lib.TransformVertex;
|
||
|
||
// ===== 获取 KMt / TDt 渲染组件 =====
|
||
function GetKMtComponent(entity) {
|
||
var comp = FindComponentByField(entity, 'u14l_1');
|
||
if (comp) return comp;
|
||
comp = FindComponentByField(entity, 't14l_1');
|
||
if (comp) return comp;
|
||
comp = FindComponentByField(entity, 'p14l_1');
|
||
return comp;
|
||
}
|
||
|
||
function GetTDtComponent(entity) {
|
||
var comp = FindComponentByField(entity, 'n11c_1');
|
||
if (comp) return comp;
|
||
comp = FindComponentByField(entity, 'q112_1');
|
||
return comp;
|
||
}
|
||
|
||
// ===== 从GPU读取顶点位置数据(缓冲区[0]) =====
|
||
function ReadVertexPositionsFromTp(tp) {
|
||
if (!tp || !tp.t4w_1) return null;
|
||
var tl = tp.t4w_1.g1(0);
|
||
if (!tl || !tl.d3x_1) return null;
|
||
var vq = tl.d3x_1;
|
||
var bs = vq.s3h_1;
|
||
if (!bs || !bs.b74_1) return null;
|
||
var gl = bs.b74_1;
|
||
var webglBuf = vq.r3x_1 && vq.r3x_1.z73_1;
|
||
if (!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读取索引数据 =====
|
||
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 || !il.q3p_1) continue;
|
||
var fq = il.q3p_1;
|
||
var bs = fq.s3h_1;
|
||
if (!bs || !bs.b74_1) continue;
|
||
var gl = bs.b74_1;
|
||
var webglBuf = fq.j3x_1 && fq.j3x_1.z73_1;
|
||
if (!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 = 600;
|
||
|
||
var StrideDiagPrinted = 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 = 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;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
// ===== 幽灵节点检测(三重检测:z34_1 + 世界矩阵 + 位置) =====
|
||
var NodeMatrixHistory = new WeakMap();
|
||
var TankPrevPos = new Map();
|
||
var GhostThreshold = 30;
|
||
var GhostCooldown = 120;
|
||
|
||
function IsNodeGhost(bpNode, tankIsMoving) {
|
||
if (!bpNode || !bpNode.n35_1) return false;
|
||
// 方案1:z34_1 字段检测(主方案)
|
||
var z34Val = bpNode.g35_1;
|
||
if (z34Val !== undefined && z34Val < 0.9) return true;
|
||
// 方案2:世界矩阵更新检测(后备方案)
|
||
var curMatrix = bpNode.n35_1;
|
||
var hist = NodeMatrixHistory.get(bpNode);
|
||
if (!hist) {
|
||
hist = { m: new Array(16), unchanged: 0, cooldown: 0 };
|
||
for (var i = 0; i < 16; i++) hist.m[i] = curMatrix[i];
|
||
NodeMatrixHistory.set(bpNode, hist);
|
||
return false;
|
||
}
|
||
var same = true;
|
||
for (var i = 0; i < 16; i++) {
|
||
if (Math.abs(hist.m[i] - curMatrix[i]) > 0.01) { same = false; break; }
|
||
}
|
||
if (same) hist.unchanged++;
|
||
else {
|
||
hist.unchanged = 0;
|
||
for (var i = 0; i < 16; i++) hist.m[i] = curMatrix[i];
|
||
}
|
||
if (hist.cooldown > 0) { hist.cooldown--; return true; }
|
||
if (tankIsMoving && hist.unchanged > GhostThreshold) {
|
||
hist.cooldown = GhostCooldown;
|
||
return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
// ===== 递归收集mesh节点(每帧重新获取 Bp 节点引用) =====
|
||
function CollectMeshNodes(bpNode, nodes, depth, group) {
|
||
if (!bpNode || depth > 5) return;
|
||
var name = bpNode.e35_1 || '';
|
||
if (bpNode.t3m_1) {
|
||
var gpuData = GetGpuData(bpNode.t3m_1);
|
||
if (gpuData) {
|
||
nodes.push({
|
||
name: name, node: bpNode, tp: bpNode.t3m_1,
|
||
positions: gpuData.positions, indices: gpuData.indices, stride: gpuData.stride, group: group
|
||
});
|
||
}
|
||
}
|
||
if (bpNode.q4x_1 && typeof bpNode.q4x_1.e1 === 'function') {
|
||
try {
|
||
var lodCount = bpNode.q4x_1.e1();
|
||
for (var lod = 0; lod < lodCount && lod < 1; lod++) {
|
||
var lodLevel = bpNode.q4x_1.g1(lod);
|
||
if (!lodLevel) continue;
|
||
var lodTp = lodLevel.t3m_1 || lodLevel.v3m_1;
|
||
if (!lodTp) continue;
|
||
var lodGpuData = GetGpuData(lodTp);
|
||
if (lodGpuData) {
|
||
nodes.push({
|
||
name: name + '_lod' + lod, node: bpNode, tp: lodTp,
|
||
positions: lodGpuData.positions, indices: lodGpuData.indices, stride: lodGpuData.stride, group: group
|
||
});
|
||
}
|
||
}
|
||
} catch(e) {}
|
||
}
|
||
if (bpNode.p35_1 && typeof bpNode.p35_1.e1 === 'function') {
|
||
var childCount = bpNode.p35_1.e1();
|
||
for (var c = 0; c < childCount; c++) {
|
||
try {
|
||
var child = bpNode.p35_1.g1(c);
|
||
if (child) CollectMeshNodes(child, nodes, depth + 1, group);
|
||
} catch(e) {}
|
||
}
|
||
}
|
||
}
|
||
|
||
// ===== 提取坦克的mesh数据 =====
|
||
function GetTankMeshData(tank) {
|
||
var nodes = [];
|
||
var kmt = GetKMtComponent(tank);
|
||
if (kmt && kmt.t14l_1 && kmt.t14l_1.s17b_1) {
|
||
var skin = kmt.t14l_1;
|
||
try {
|
||
var partCount = skin.s17b_1.e1();
|
||
for (var p = 0; p < partCount; p++) {
|
||
var part = skin.s17b_1.g1(p);
|
||
if (!part || !part.v17b_1) continue;
|
||
var nodeCount = part.v17b_1.e1();
|
||
for (var n = 0; n < nodeCount; n++) {
|
||
var bpNode = part.v17b_1.g1(n);
|
||
if (!bpNode) continue;
|
||
CollectMeshNodes(bpNode, nodes, 0, 'hull');
|
||
}
|
||
}
|
||
} catch(e) {}
|
||
}
|
||
var tdt = GetTDtComponent(tank);
|
||
if (tdt && tdt.n11c_1 && tdt.n11c_1.m17a_1) {
|
||
try { CollectMeshNodes(tdt.n11c_1.m17a_1, nodes, 0, 'turret'); } catch(e) {}
|
||
}
|
||
return nodes;
|
||
}
|
||
|
||
// ===== 检查节点是否有效(幽灵检测 + 位置检查) =====
|
||
function IsNodeValid(bpNode, pos, tankIsMoving, diagStats) {
|
||
var worldMatrix = bpNode.n35_1;
|
||
if (!worldMatrix) return null;
|
||
|
||
if (diagStats) diagStats.totalNodes++;
|
||
|
||
// 幽灵检测1:z34_1 字段
|
||
var z34Val = bpNode.g35_1;
|
||
if (z34Val !== undefined && z34Val < 0.9) {
|
||
if (diagStats) diagStats.ghostNodes++;
|
||
return null;
|
||
}
|
||
|
||
// 幽灵检测2:世界矩阵更新
|
||
if (IsNodeGhost(bpNode, tankIsMoving)) {
|
||
if (diagStats) diagStats.ghostNodes++;
|
||
return null;
|
||
}
|
||
|
||
// 位置检查
|
||
var nodeDx = worldMatrix[12] - pos.x;
|
||
var nodeDy = worldMatrix[13] - pos.y;
|
||
var nodeDz = worldMatrix[14] - pos.z;
|
||
if (nodeDx*nodeDx + nodeDy*nodeDy + nodeDz*nodeDz > 4000000) {
|
||
if (diagStats) diagStats.filteredNodes++;
|
||
return null;
|
||
}
|
||
|
||
return worldMatrix;
|
||
}
|
||
|
||
// ===== 线框模式:绘制单个节点的所有边 =====
|
||
function DrawWireframeNode(ctx, meshData, worldMatrix, pos, camInfo, screenW, screenH) {
|
||
var positions = meshData.positions;
|
||
var indicesArr = meshData.indices;
|
||
var stride = meshData.stride || 3;
|
||
|
||
var vertexCount = Math.floor(positions.length / stride);
|
||
var projected = new Array(vertexCount);
|
||
for (var v = 0; v < vertexCount; v++) {
|
||
var wp = TransformVertex(worldMatrix, positions[v*stride], positions[v*stride+1], positions[v*stride+2]);
|
||
// 顶点级合理性检查
|
||
var ddx = wp.x - pos.x, ddy = wp.y - pos.y, ddz = wp.z - pos.z;
|
||
if (ddx*ddx + ddy*ddy + ddz*ddz > 6250000) {
|
||
projected[v] = null;
|
||
continue;
|
||
}
|
||
projected[v] = Project3D(wp, camInfo.pos, camInfo.yaw, camInfo.pitch, camInfo.fov, screenW, screenH);
|
||
}
|
||
|
||
var drawnEdges = new Set();
|
||
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 (!projected[i0] || !projected[i1] || !projected[i2]) continue;
|
||
var edges = [
|
||
[Math.min(i0, i1), Math.max(i0, i1)],
|
||
[Math.min(i1, i2), Math.max(i1, i2)],
|
||
[Math.min(i2, i0), Math.max(i2, i0)]
|
||
];
|
||
for (var e = 0; e < 3; e++) {
|
||
var key = edges[e][0] + '_' + edges[e][1];
|
||
if (drawnEdges.has(key)) continue;
|
||
drawnEdges.add(key);
|
||
ctx.moveTo(projected[edges[e][0]].x, projected[edges[e][0]].y);
|
||
ctx.lineTo(projected[edges[e][1]].x, projected[edges[e][1]].y);
|
||
}
|
||
}
|
||
}
|
||
ctx.stroke();
|
||
}
|
||
|
||
// ===== 描边模式:屏幕空间光栅化轮廓提取 =====
|
||
// 算法(真正的"屏幕几何"方法):
|
||
// 1. 把所有面向相机的三角形光栅化(扫描线填充)到像素掩码
|
||
// 2. 车身和炮塔在屏幕上重叠时,填充区域自动合并
|
||
// 3. 找出填充区域的边界像素(填充像素的4邻居中有未填充像素)
|
||
// 4. 只画边界像素 = 屏幕空间外轮廓
|
||
var PixelMask = null;
|
||
var PixelMaskW = 0;
|
||
var PixelMaskH = 0;
|
||
|
||
function GetPixelMask(w, h) {
|
||
if (!PixelMask || PixelMaskW !== w || PixelMaskH !== h) {
|
||
PixelMask = new Uint8Array(w * h);
|
||
PixelMaskW = w;
|
||
PixelMaskH = h;
|
||
} else {
|
||
PixelMask.fill(0);
|
||
}
|
||
return PixelMask;
|
||
}
|
||
|
||
// 扫描线光栅化三角形
|
||
function RasterizeTriangle(pixels, w, h, 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] = 1;
|
||
}
|
||
}
|
||
}
|
||
|
||
function DrawTankOutline(ctx, meshNodes, pos, tankIsMoving, camInfo, screenW, screenH, diagStats, color) {
|
||
var pixels = GetPixelMask(screenW, screenH);
|
||
|
||
// 记录坦克的屏幕边界框(用于后续边界检测优化)
|
||
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 positions = meshData.positions;
|
||
var indicesArr = meshData.indices;
|
||
var stride = meshData.stride || 3;
|
||
|
||
// 变换顶点到世界空间并投影到屏幕
|
||
var vertexCount = Math.floor(positions.length / stride);
|
||
var worldVerts = new Array(vertexCount);
|
||
var screenVerts = new Array(vertexCount);
|
||
for (var v = 0; v < vertexCount; v++) {
|
||
worldVerts[v] = TransformVertex(worldMatrix, positions[v*stride], positions[v*stride+1], positions[v*stride+2]);
|
||
screenVerts[v] = Project3D(worldVerts[v], camInfo.pos, camInfo.yaw, camInfo.pitch, camInfo.fov, screenW, screenH);
|
||
if (screenVerts[v]) {
|
||
if (screenVerts[v].x < minX) minX = screenVerts[v].x;
|
||
if (screenVerts[v].y < minY) minY = screenVerts[v].y;
|
||
if (screenVerts[v].x > maxX) maxX = screenVerts[v].x;
|
||
if (screenVerts[v].y > maxY) maxY = screenVerts[v].y;
|
||
}
|
||
}
|
||
|
||
// 遍历三角形,光栅化面向相机的
|
||
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];
|
||
var va = worldVerts[a], vb = worldVerts[b], vc = worldVerts[c];
|
||
var sa = screenVerts[a], sb = screenVerts[b], sc = screenVerts[c];
|
||
if (!sa || !sb || !sc) continue;
|
||
|
||
// 计算法线判断是否面向相机
|
||
var e1x = vb.x - va.x, e1y = vb.y - va.y, e1z = vb.z - va.z;
|
||
var e2x = vc.x - va.x, e2y = vc.y - va.y, e2z = vc.z - va.z;
|
||
var nx = e1y * e2z - e1z * e2y;
|
||
var ny = e1z * e2x - e1x * e2z;
|
||
var nz = e1x * e2y - e1y * e2x;
|
||
var cx = (va.x + vb.x + vc.x) / 3;
|
||
var cy = (va.y + vb.y + vc.y) / 3;
|
||
var cz = (va.z + vb.z + vc.z) / 3;
|
||
var viewX = camInfo.pos.x - cx;
|
||
var viewY = camInfo.pos.y - cy;
|
||
var viewZ = camInfo.pos.z - cz;
|
||
var dot = nx * viewX + ny * viewY + nz * viewZ;
|
||
if (dot <= 0) continue; // 背向相机,跳过
|
||
|
||
// 光栅化三角形到像素掩码
|
||
RasterizeTriangle(pixels, screenW, screenH, sa.x, sa.y, sb.x, sb.y, sc.x, sc.y);
|
||
}
|
||
}
|
||
}
|
||
|
||
// 找出边界像素并绘制(只在坦克边界框内遍历)
|
||
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;
|
||
var outlineCount = 0;
|
||
for (var y = by0; y <= by1; y++) {
|
||
var rowOffset = y * screenW;
|
||
for (var x = bx0; x <= bx1; x++) {
|
||
if (pixels[rowOffset + x] === 0) continue;
|
||
// 检查4个邻居是否有未填充像素
|
||
var isBoundary = false;
|
||
if (x === 0 || pixels[rowOffset + x - 1] === 0) isBoundary = true;
|
||
else if (x === screenW - 1 || pixels[rowOffset + x + 1] === 0) isBoundary = true;
|
||
else if (y === 0 || pixels[rowOffset - screenW + x] === 0) isBoundary = true;
|
||
else if (y === screenH - 1 || pixels[rowOffset + screenW + x] === 0) isBoundary = true;
|
||
if (isBoundary) {
|
||
ctx.fillRect(x, y, 3, 3);
|
||
outlineCount++;
|
||
}
|
||
}
|
||
}
|
||
|
||
// 清除像素掩码(供下一个坦克使用)
|
||
for (var y2 = by0; y2 <= by1; y2++) {
|
||
var rowOff = y2 * screenW;
|
||
for (var x2 = bx0; x2 <= bx1; x2++) {
|
||
pixels[rowOff + x2] = 0;
|
||
}
|
||
}
|
||
|
||
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, ghostNodes: 0, outlineEdges: 0 };
|
||
|
||
// ===== 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;
|
||
|
||
// ===== 渲染循环 =====
|
||
function Render() {
|
||
if (!esp.running) return;
|
||
esp.frameCount++;
|
||
|
||
// 定期清空 GPU 数据缓存
|
||
GpuCacheFrameCount++;
|
||
if (GpuCacheFrameCount >= GpuCacheClearInterval) {
|
||
GpuDataCache.clear();
|
||
GpuCacheFrameCount = 0;
|
||
}
|
||
|
||
DiagStats = { totalNodes: 0, filteredNodes: 0, renderedNodes: 0, ghostNodes: 0, outlineEdges: 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 };
|
||
}
|
||
|
||
var tankList = gm.q100_1.r11l();
|
||
var count = tankList.e1();
|
||
var drawnCount = 0;
|
||
|
||
for (var i = 0; i < count; i++) {
|
||
var tank = tankList.g1(i);
|
||
if (!tank || tank === playerTank) continue;
|
||
|
||
var physicsComp = FindComponentByField(tank, 'gzh_1');
|
||
if (!physicsComp || !physicsComp.gzh_1) continue;
|
||
var tankState = GetTankState(tank);
|
||
if (tankState !== 3) continue;
|
||
|
||
var pos = {
|
||
x: physicsComp.gzh_1.f20_1,
|
||
y: physicsComp.gzh_1.g20_1,
|
||
z: physicsComp.gzh_1.h20_1
|
||
};
|
||
|
||
// 坦克移动检测
|
||
var tankId = tank.hyt_1;
|
||
var prevPos = TankPrevPos.get(tankId);
|
||
var tankIsMoving = false;
|
||
if (prevPos) {
|
||
var pdx = pos.x - prevPos.x, pdy = pos.y - prevPos.y, pdz = pos.z - prevPos.z;
|
||
tankIsMoving = (pdx*pdx + pdy*pdy + pdz*pdz) > 100;
|
||
}
|
||
TankPrevPos.set(tankId, { x: pos.x, y: pos.y, z: pos.z });
|
||
|
||
// 距离检查(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.hyt_1);
|
||
// 根据 HighlightMode 判断是否渲染该坦克
|
||
if (!ShouldRender(tankName)) continue;
|
||
// 颜色按队伍:友方=蓝,敌方=红
|
||
var color = friendly ? 'rgba(68, 136, 255, 0.6)' : 'rgba(255, 68, 68, 0.6)';
|
||
var tankColor = friendly ? 'rgba(68, 136, 255, 0.9)' : 'rgba(255, 68, 68, 0.9)';
|
||
|
||
// 先画中心点、距离和血量(独立于3D模型渲染)
|
||
var centerProj = Project3D(pos, camInfo.pos, camInfo.yaw, camInfo.pitch, camInfo.fov, screenW, screenH);
|
||
|
||
if (centerProj) {
|
||
// 血量信息
|
||
var health = GetTankHealth(tank);
|
||
var barW = 60, barH = 6;
|
||
var barX = centerProj.x - barW / 2;
|
||
var barY = centerProj.y - barH - 4;
|
||
if (health) {
|
||
// 背景
|
||
ctx.fillStyle = 'rgba(0, 0, 0, 0.6)';
|
||
ctx.fillRect(barX - 1, barY - 1, barW + 2, barH + 2);
|
||
// 血量百分比颜色:绿>60%, 黄>30%, 红
|
||
var hpColor = health.percent > 0.6 ? '#44ff44' :
|
||
health.percent > 0.3 ? '#ffaa00' : '#ff4444';
|
||
ctx.fillStyle = hpColor;
|
||
ctx.fillRect(barX, barY, barW * health.percent, barH);
|
||
// 边框
|
||
ctx.strokeStyle = 'rgba(255, 255, 255, 0.4)';
|
||
ctx.lineWidth = 1;
|
||
ctx.strokeRect(barX, barY, barW, barH);
|
||
// 名称(血量条上方)
|
||
ctx.fillStyle = tankColor;
|
||
ctx.font = 'bold 11px Arial';
|
||
ctx.textAlign = 'center';
|
||
ctx.fillText(tankName, centerProj.x, barY - 14);
|
||
// 距离(血量条上方第二行)
|
||
ctx.font = '10px Arial';
|
||
ctx.fillText(Math.round(distance / 100) + 'm', centerProj.x, barY - 3);
|
||
// 血量数值(血量条下方)
|
||
ctx.fillStyle = hpColor;
|
||
ctx.font = '10px Arial';
|
||
ctx.fillText(Math.round(health.current) + '/' + Math.round(health.max), centerProj.x, barY + barH + 10);
|
||
} else {
|
||
ctx.fillStyle = tankColor;
|
||
ctx.font = 'bold 11px Arial';
|
||
ctx.textAlign = 'center';
|
||
ctx.fillText(tankName, centerProj.x, centerProj.y - 18);
|
||
ctx.font = '10px Arial';
|
||
ctx.fillText(Math.round(distance / 100) + 'm', centerProj.x, centerProj.y - 6);
|
||
}
|
||
// 中心点
|
||
ctx.fillStyle = tankColor;
|
||
ctx.beginPath();
|
||
ctx.arc(centerProj.x, centerProj.y, 2, 0, 2 * Math.PI);
|
||
ctx.fill();
|
||
}
|
||
|
||
// 3D模型渲染(仅在mesh数据可用时)
|
||
var meshNodes = GetTankMeshData(tank);
|
||
if (!meshNodes || meshNodes.length === 0) {
|
||
drawnCount++;
|
||
continue;
|
||
}
|
||
|
||
// 检查是否有炮塔
|
||
var hasTurret = false;
|
||
for (var ti = 0; ti < meshNodes.length; ti++) {
|
||
if (meshNodes[ti].group === 'turret') { hasTurret = true; break; }
|
||
}
|
||
if (!hasTurret) {
|
||
drawnCount++;
|
||
continue;
|
||
}
|
||
|
||
ctx.strokeStyle = color;
|
||
ctx.lineWidth = LineWidth;
|
||
|
||
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, camInfo, screenW, screenH);
|
||
DiagStats.renderedNodes++;
|
||
}
|
||
} else {
|
||
DrawTankOutline(ctx, meshNodes, pos, tankIsMoving, camInfo, screenW, screenH, DiagStats, color);
|
||
}
|
||
|
||
drawnCount++;
|
||
}
|
||
|
||
// 玩家血量显示(左上角)
|
||
var playerHealth = GetTankHealth(playerTank);
|
||
if (playerHealth) {
|
||
var pBarW = 200, pBarH = 12;
|
||
var pBarX = 10, pBarY = 10;
|
||
// 背景
|
||
ctx.fillStyle = 'rgba(0, 0, 0, 0.6)';
|
||
ctx.fillRect(pBarX - 1, pBarY - 1, pBarW + 2, pBarH + 2);
|
||
// 血量颜色
|
||
var pHpColor = playerHealth.percent > 0.6 ? '#44ff44' :
|
||
playerHealth.percent > 0.3 ? '#ffaa00' : '#ff4444';
|
||
ctx.fillStyle = pHpColor;
|
||
ctx.fillRect(pBarX, pBarY, pBarW * playerHealth.percent, pBarH);
|
||
// 边框
|
||
ctx.strokeStyle = 'rgba(255, 255, 255, 0.5)';
|
||
ctx.lineWidth = 1;
|
||
ctx.strokeRect(pBarX, pBarY, pBarW, pBarH);
|
||
// 文字
|
||
ctx.fillStyle = '#ffffff';
|
||
ctx.font = 'bold 11px Arial';
|
||
ctx.textAlign = 'center';
|
||
ctx.fillText('HP: ' + Math.round(playerHealth.current) + ' / ' + Math.round(playerHealth.max) +
|
||
' (' + Math.round(playerHealth.percent * 100) + '%)',
|
||
pBarX + pBarW / 2, pBarY + pBarH - 1);
|
||
}
|
||
|
||
// 屏幕信息
|
||
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 += ' | Ghost: ' + DiagStats.ghostNodes + ' | Frame: ' + esp.frameCount;
|
||
ctx.fillText(infoText, 5, screenH - 5);
|
||
|
||
} 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.jyw().iyt_1;
|
||
console.log('[后视] 场景对象:', scene);
|
||
|
||
// hC 渲染器实例(jr.lye_1)
|
||
var renderer = scene.fyo_1;
|
||
console.log('[后视] 渲染器:', renderer);
|
||
|
||
// Ff 绘制器实例(hC.r74_1)
|
||
ff = renderer.d75_1;
|
||
console.log('[后视] Ff绘制器:', ff);
|
||
|
||
// 场景根节点(jr.kyf_1)
|
||
rootNode = scene.eyp_1;
|
||
console.log('[后视] 根节点:', rootNode);
|
||
|
||
// 主相机 Ha 实例(jr.nyf_1)
|
||
mainCamera = scene.hyp_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('[后视] 相机s35_1:', mainCamera.s35_1);
|
||
console.log('[后视] 相机r35_1:', mainCamera.r35_1);
|
||
console.log('[后视] 相机b37_1:', mainCamera.b37_1);
|
||
|
||
if (!mainCamera.s35_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);
|
||
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.s35_1.f20_1;
|
||
var savedYaw = mainCamera.s35_1.g20_1;
|
||
var savedRoll = mainCamera.s35_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.s36_1;
|
||
var savedM36 = mainCamera.t36_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.s35_1.f20_1 = rearPitch;
|
||
mainCamera.s35_1.g20_1 = Math.PI;
|
||
mainCamera.s35_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.s36_1 = rw;
|
||
mainCamera.t36_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.x3n_1 控制 z39_1(阴影),y3n_1 控制 c3a_1/d3a_1(后处理),z3n_1 控制 f3a_1
|
||
// 禁用后 y3s 只执行主场景渲染 z3k,跳过所有 FBO 切换的 pass
|
||
var savedX3n = ff.w38_1.e3o_1;
|
||
var savedY3n = ff.w38_1.f3o_1;
|
||
var savedZ3n = ff.w38_1.g3o_1;
|
||
ff.w38_1.e3o_1 = false;
|
||
ff.w38_1.f3o_1 = false;
|
||
ff.w38_1.g3o_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.w38_1.e3o_1 = savedX3n;
|
||
ff.w38_1.f3o_1 = savedY3n;
|
||
ff.w38_1.g3o_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.s35_1.f20_1 = savedPitch;
|
||
mainCamera.s35_1.g20_1 = savedYaw;
|
||
mainCamera.s35_1.h20_1 = savedRoll;
|
||
mainCamera.r35_1.f20_1 = savedX;
|
||
mainCamera.r35_1.g20_1 = savedY;
|
||
mainCamera.r35_1.h20_1 = savedZ;
|
||
mainCamera.s36_1 = savedL36;
|
||
mainCamera.t36_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.q100_1.r11l();
|
||
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;
|
||
requestAnimationFrame(Loop);
|
||
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瞄准警告启动 =====');
|
||
|
||
if (!window.__gameMode__) {
|
||
console.error('请先运行 hook_gamemode.js');
|
||
return;
|
||
}
|
||
|
||
// ===== 清理旧实例 =====
|
||
if (window.__hornetAimWarning__) {
|
||
window.__hornetAimWarning__.Stop();
|
||
console.log('已停止旧瞄准警告');
|
||
}
|
||
|
||
var gm = window.__gameMode__;
|
||
|
||
// ===== 设置参数(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 节点世界矩阵(g35_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.jyw();
|
||
if (!gmEntity || !gmEntity.pyt_1 || !gmEntity.pyt_1.s2a_1) return null;
|
||
gmEntity.pyt_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.m179 === '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,
|
||
c179_1: null,
|
||
b179_1: null,
|
||
d179: function(t, n, i, r, e) {
|
||
this.y178_1.k26(t);
|
||
this.z178_1.k26(n);
|
||
this.a179_1 = i;
|
||
this.c179_1 = r;
|
||
this.b179_1 = e;
|
||
},
|
||
d3: function() { this.b179_1 = null; }
|
||
};
|
||
|
||
var filter = {
|
||
o1co_1: { h1: function() { return false; }, d3: function() {} },
|
||
w179: function() { return true; },
|
||
d3: function() { this.o1co_1.d3(); },
|
||
p1co: function() {}
|
||
};
|
||
|
||
var hit = uMt.m179(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);
|
||
|
||
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.q100_1.r11l();
|
||
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.hyt_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.hyt_1);
|
||
threats.push({
|
||
pos: pos,
|
||
distance: aimInfo.distance,
|
||
angle: aimInfo.angle,
|
||
turretDir: aimInfo.turretDir,
|
||
screenProj: proj,
|
||
tankId: tank.hyt_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.jyw().iyt_1;
|
||
mainCamera = scene.hyp_1;
|
||
ff = scene.fyo_1.d75_1;
|
||
if (!mainCamera || !mainCamera.s35_1 || !mainCamera.r35_1 || !mainCamera.o37) {
|
||
throw new Error('相机字段缺失');
|
||
}
|
||
console.log('[自由视角] 主相机:', mainCamera.constructor.name,
|
||
'l35=(' + mainCamera.s35_1.f20_1.toFixed(2) + ',' + mainCamera.s35_1.g20_1.toFixed(2) + ',' + mainCamera.s35_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.s35_1.f20_1 = freePitch; // pitch (f)
|
||
mainCamera.s35_1.g20_1 = freeRoll; // roll (g) 保持初始值
|
||
mainCamera.s35_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.s35_1.f20_1;
|
||
freeRoll = mainCamera.s35_1.g20_1; // roll 保持初始值
|
||
freeYaw = mainCamera.s35_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;
|
||
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 是局部空间向量且可能未设置,不可靠
|
||
var hullComp = FindComponentByField(tank, 't14l_1') ||
|
||
FindComponentByField(tank, 'u14l_1') ||
|
||
FindComponentByField(tank, 'p14l_1');
|
||
if (!hullComp || !hullComp.t14l_1) return null;
|
||
var skin = hullComp.t14l_1;
|
||
if (!skin || !skin.s17b_1) return null;
|
||
try {
|
||
var partCount = skin.s17b_1.e1();
|
||
for (var p = 0; p < partCount; p++) {
|
||
var part = skin.s17b_1.g1(p);
|
||
if (!part || !part.v17b_1) continue;
|
||
var nodeCount = part.v17b_1.e1();
|
||
for (var n = 0; n < nodeCount; n++) {
|
||
var bpNode = part.v17b_1.g1(n);
|
||
if (!bpNode || !bpNode.n35_1) continue;
|
||
// 跳过幽灵节点(z34_1 < 0.9)
|
||
if (bpNode.g35_1 !== undefined && bpNode.g35_1 < 0.9) continue;
|
||
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.jyw().iyt_1;
|
||
ff = scene.fyo_1.d75_1;
|
||
rootNode = scene.eyp_1;
|
||
mainCamera = scene.hyp_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);
|
||
|
||
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();
|
||
|
||
// ===== 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() {});
|
||
|
||
// ===== 地图平移 =====
|
||
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.q100_1.r11l();
|
||
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;
|
||
frameCount++;
|
||
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.s35_1.f20_1;
|
||
var sRoll = mainCamera.s35_1.g20_1;
|
||
var sYaw = mainCamera.s35_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.s36_1, sM36 = mainCamera.t36_1;
|
||
var sS36 = mainCamera.z36_1, sQ36 = mainCamera.x36_1, sR36 = mainCamera.y36_1;
|
||
|
||
// 设置俯瞰相机参数
|
||
// pitch=-π/2 朝下看,roll=0 不翻转,yaw=0 北朝上
|
||
mainCamera.s35_1.f20_1 = -Math.PI / 2;
|
||
mainCamera.s35_1.g20_1 = 0;
|
||
mainCamera.s35_1.h20_1 = 0;
|
||
mainCamera.r35_1.f20_1 = viewCenterX;
|
||
mainCamera.r35_1.g20_1 = viewCenterY;
|
||
mainCamera.r35_1.h20_1 = camHeight;
|
||
mainCamera.s36_1 = FBO_W;
|
||
mainCamera.t36_1 = FBO_H;
|
||
mainCamera.z36_1 = Config.fov;
|
||
mainCamera.x36_1 = Config.near;
|
||
mainCamera.y36_1 = Config.far;
|
||
mainCamera.o37();
|
||
|
||
// 禁用阴影/后处理
|
||
var savedX3n = ff.w38_1.e3o_1;
|
||
var savedY3n = ff.w38_1.f3o_1;
|
||
var savedZ3n = ff.w38_1.g3o_1;
|
||
ff.w38_1.e3o_1 = false;
|
||
ff.w38_1.f3o_1 = false;
|
||
ff.w38_1.g3o_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.w38_1.e3o_1 = savedX3n;
|
||
ff.w38_1.f3o_1 = savedY3n;
|
||
ff.w38_1.g3o_1 = savedZ3n;
|
||
for (var ui2 = 0; ui2 < uiLayerIndices.length; ui2++) {
|
||
ff.e39_1[uiLayerIndices[ui2]] = savedUiQueues[uiLayerIndices[ui2]];
|
||
}
|
||
|
||
// 恢复主相机
|
||
mainCamera.s35_1.f20_1 = sPitch;
|
||
mainCamera.s35_1.g20_1 = sRoll;
|
||
mainCamera.s35_1.h20_1 = sYaw;
|
||
mainCamera.r35_1.f20_1 = sX;
|
||
mainCamera.r35_1.g20_1 = sY;
|
||
mainCamera.r35_1.h20_1 = sZ;
|
||
mainCamera.s36_1 = sL36; mainCamera.t36_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_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)
|
||
// 实现要点:Pointer Events + setPointerCapture 把指针"锁"在 handle 上,
|
||
// pointerup/pointercancel 必定回到 handle;并在 window 捕获阶段加兜底,
|
||
// 避免面板内 stopPropagation 或游戏画布抢事件导致"松手后仍粘着鼠标"。
|
||
// 返回:{ End, IsActive },供隐藏面板时强制结束拖动
|
||
function MakeDraggable(elem, handle, ignoreTags) {
|
||
handle = handle || elem;
|
||
ignoreTags = ignoreTags || ['BUTTON', 'INPUT', 'SELECT', 'TEXTAREA'];
|
||
handle.style.cursor = 'move';
|
||
|
||
var Active = false;
|
||
var PointerId = null;
|
||
var GrabX = 0;
|
||
var GrabY = 0;
|
||
|
||
function MoveTo(ClientX, ClientY) {
|
||
var MaxX = window.innerWidth - 40;
|
||
var MaxY = window.innerHeight - 24;
|
||
var NextX = Math.max(0, Math.min(MaxX, ClientX - GrabX));
|
||
var NextY = Math.max(0, Math.min(MaxY, ClientY - GrabY));
|
||
elem.style.left = NextX + 'px';
|
||
elem.style.top = NextY + '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';
|
||
}
|
||
|
||
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 }
|
||
];
|
||
}
|
||
|
||
// ===== HUD(左上角状态显示,可拖动)=====
|
||
var hud = document.createElement('div');
|
||
hud.id = '3dtank-hud';
|
||
hud.style.cssText =
|
||
'position:fixed;top:10px;left:10px;z-index:100000;pointer-events:auto;cursor:move;' +
|
||
'font:11px Consolas,monospace;color:#7df9ff;background:rgba(5,10,18,0.55);' +
|
||
'backdrop-filter:blur(10px);-webkit-backdrop-filter:blur(10px);' +
|
||
'padding:8px 12px;border-radius:6px;border:1px solid rgba(125,249,255,0.25);' +
|
||
'white-space:pre;line-height:1.6;text-shadow:0 0 3px rgba(125,249,255,0.4);' +
|
||
'user-select:none;-webkit-user-select:none;box-shadow:0 2px 12px rgba(0,0,0,0.4);';
|
||
document.body.appendChild(hud);
|
||
var HudDrag = MakeDraggable(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);
|
||
});
|
||
|
||
// ===== 后视镜设置 =====
|
||
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);
|
||
});
|
||
|
||
// 从工具读取当前设置(打开面板时同步)
|
||
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);
|
||
|
||
// ===== 阻止面板内事件冒泡到 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();
|
||
};
|
||
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 =====
|
||
function UpdateHud() {
|
||
requestAnimationFrame(UpdateHud);
|
||
var ght = window.__gameMode__;
|
||
var ghtStatus = ght ? '已捕获' : '未捕获';
|
||
var ghtColor = ght ? '#7df9ff' : '#f5a623';
|
||
var lines = [];
|
||
lines.push('3DTank 控制台 [拖动移动]');
|
||
lines.push('GHT: ' + ghtStatus);
|
||
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 = ghtColor;
|
||
}
|
||
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(); // 防止隐藏时拖动状态残留(粘手)
|
||
}
|
||
|
||
function TogglePanel() {
|
||
if (panel.style.display === 'block') {
|
||
HidePanel();
|
||
} else {
|
||
panel.style.display = 'block';
|
||
UpdateButtons();
|
||
SyncWfSettings();
|
||
}
|
||
}
|
||
|
||
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();
|
||
}
|
||
}
|
||
});
|
||
}
|
||
window.addEventListener('keydown', OnHotkey);
|
||
// 热键系统定义完成,更新显示
|
||
UpdateHotkeyDisplay();
|
||
|
||
// ===== 暴露接口 =====
|
||
window.__hornetPanel__ = {
|
||
Toggle: TogglePanel,
|
||
Show: function() { panel.style.display = 'block'; UpdateButtons(); SyncWfSettings(); },
|
||
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/面板/窗口可拖动,热键已加载');
|
||
})();
|
||
|