- 双向校验误判:q100_1 / r11l / jyw / t101_1 在新版同名但含义不同(普通数值 / 别的方法),被当作未变而跳过 - 强制替换:q100_1 到 m10b_1(坦克管理)、r11l 到 j11y(坦克列表)、jyw 到 lz4(取实体)、t101_1 到 p10c_1 - 覆盖 hook_gamemode.js(文件名不匹配 hornet_ 被漏掉) - 物理组件字段 gzh_1 改为 rzf_1(新版 gzh_1 已是 TerminatorCC 字段,非物理) - 移除已不存在的 xyu(GameMode),改用 gm.lz4()(等价:取 GameMode 实体) - 重新生成 hornet_bundle.js(10 模块 / 249229 字节)并同步 unpacked
852 lines
33 KiB
JavaScript
852 lines
33 KiB
JavaScript
// ===== 3DTank 战争沙盘 - 真实3D俯瞰渲染 =====
|
||
// 在浏览器控制台运行(需先运行 hook_gamemode.js 和 hornet_lib.js)
|
||
// 功能:80%x90% 可拖拽缩放的全景俯瞰图,使用游戏原生渲染器渲染 3D 场景
|
||
// 原理:Hook gl.bindFramebuffer 重定向到自定义 FBO,用俯瞰相机参数调用游戏渲染器
|
||
// 操作:拖拽标题移动窗口 | 拖拽地图平移 | 滚轮缩放 | 双击居中 | Alt+M 开关
|
||
|
||
(function() {
|
||
function _start() {
|
||
console.log('===== 3DTank 战争沙盘(3D俯瞰版)启动 =====');
|
||
|
||
if (!window.__gameMode__) {
|
||
console.error('请先运行 hook_gamemode.js');
|
||
return;
|
||
}
|
||
if (window.__hornetWarSandbox__) {
|
||
window.__hornetWarSandbox__.Stop();
|
||
console.log('已停止旧战争沙盘');
|
||
}
|
||
|
||
var gm = window.__gameMode__;
|
||
var lib = window.__hornetLib__;
|
||
if (!lib) {
|
||
console.error('[沙盘] 请先运行 hornet_lib.js');
|
||
return;
|
||
}
|
||
var teamFns = lib.LoadTeamFunctions();
|
||
var FindComponentByField = lib.FindComponentByField;
|
||
var GetTankPosition = lib.GetTankPosition;
|
||
var GetTankState = lib.GetTankState;
|
||
var IsTankAlive = lib.IsTankAlive;
|
||
var GetTankName = lib.GetTankName;
|
||
var GetTankHealth = lib.GetTankHealth;
|
||
function GetPlayerTank() { return lib.GetPlayerTank(gm); }
|
||
function IsFriendly(tank, playerTank) { return lib.IsFriendly(tank, playerTank, gm); }
|
||
|
||
function getTankDirection(tank) {
|
||
// 从车体皮肤组件(KMt)的Bp节点世界矩阵提取前向方向
|
||
// dzc_1 是局部空间向量且可能未设置,不可靠
|
||
// 皮肤字段:新 build 是 w14w_1;旧 build 是 t14l_1(新版 t14l_1 已改成悬停车体状态 Qmt)
|
||
var hullComp = FindComponentByField(tank, 'w14w_1') ||
|
||
FindComponentByField(tank, 't14l_1') ||
|
||
FindComponentByField(tank, 'w14x_1') ||
|
||
FindComponentByField(tank, 'p14l_1');
|
||
if (!hullComp) return null;
|
||
var skin = (hullComp.w14w_1 && hullComp.w14w_1.g17r_1) ? hullComp.w14w_1 :
|
||
(hullComp.t14l_1 && hullComp.t14l_1.g17r_1) ? hullComp.t14l_1 :
|
||
(hullComp.p14l_1 && hullComp.p14l_1.g17r_1) ? hullComp.p14l_1 : null;
|
||
if (!skin) return null;
|
||
try {
|
||
var partCount = skin.g17r_1.e1();
|
||
for (var p = 0; p < partCount; p++) {
|
||
var part = skin.g17r_1.g1(p);
|
||
if (!part || !part.j17r_1) continue;
|
||
var nodeCount = part.j17r_1.e1();
|
||
for (var n = 0; n < nodeCount; n++) {
|
||
var bpNode = part.j17r_1.g1(n);
|
||
if (!bpNode || !bpNode.n35_1) continue;
|
||
// 不按 g35_1 过滤:它是引擎的细节/淡出权重(0~1),不是幽灵标志,
|
||
// 过滤会漏掉低细节节点、导致取不到朝向
|
||
var m = bpNode.n35_1;
|
||
// 列主序世界矩阵:Col0=(m[0],m[1],m[2]) Col1=(m[4],m[5],m[6]) Col2=(m[8],m[9],m[10])
|
||
// 游戏坐标:x,y 水平面; z(h20_1)=高度。找"上"列(|z|最大),其余为水平方向
|
||
var az0 = Math.abs(m[2]), az1 = Math.abs(m[6]), az2 = Math.abs(m[10]);
|
||
// 尝试 Col1(Y前向约定,Z-up游戏常见)作为前向,若 Col1 是"上"则用 Col2
|
||
var fx, fy;
|
||
if (az1 < 0.7) {
|
||
// Col1 不是上方向,用作前向
|
||
fx = m[4]; fy = m[5];
|
||
} else if (az2 < 0.7) {
|
||
// Col1 是上,用 Col2 作前向
|
||
fx = m[8]; fy = m[9];
|
||
} else {
|
||
// Col0 不是上,用作前向
|
||
fx = m[0]; fy = m[1];
|
||
}
|
||
var len = Math.sqrt(fx*fx + fy*fy);
|
||
if (len > 0.01) {
|
||
return { x: fx/len, y: fy/len, z: 0 };
|
||
}
|
||
}
|
||
}
|
||
} catch(e) {}
|
||
return null;
|
||
}
|
||
|
||
// ===== 获取游戏渲染对象 =====
|
||
var scene = null, ff = null, rootNode = null, mainCamera = null, gl = null;
|
||
var initError = null;
|
||
try {
|
||
scene = gm.lz4().jz1_1;
|
||
ff = scene.gyw_1.d75_1;
|
||
rootNode = scene.fyx_1;
|
||
mainCamera = scene.iyx_1;
|
||
gl = ff.w39_1.b74_1;
|
||
if (!ff || !rootNode || !mainCamera || !gl) throw new Error('关键字段缺失');
|
||
} catch(e) {
|
||
initError = e;
|
||
console.error('[沙盘] 初始化失败:', e.message);
|
||
}
|
||
if (initError) return;
|
||
|
||
// ===== FBO 配置(固定内部分辨率,性能优先)=====
|
||
var FBO_W = 960, FBO_H = 720;
|
||
var Config = {
|
||
fov: 1.0, // 俯瞰 FOV
|
||
near: 100, far: 80000,
|
||
minHeight: 3000, // 最小相机高度(放大)
|
||
maxHeight: 60000, // 最大相机高度(缩小)
|
||
defaultHeight: 20000
|
||
};
|
||
// 设置参数(localStorage 持久化)
|
||
var ShowGrid = true;
|
||
try {
|
||
if (localStorage.getItem('hornet_ws_showgrid') === 'false') ShowGrid = false;
|
||
} catch(e) {}
|
||
var MarkerSize = 1.0;
|
||
try {
|
||
var __savedMS = localStorage.getItem('hornet_ws_marker_size');
|
||
if (__savedMS) {
|
||
var __ms = parseFloat(__savedMS);
|
||
if (!isNaN(__ms) && __ms >= 0.5 && __ms <= 3.0) MarkerSize = __ms;
|
||
}
|
||
} catch(e) {}
|
||
|
||
// ===== 创建 UI =====
|
||
var container = document.createElement('div');
|
||
container.id = 'hornetWarSandbox';
|
||
var winW = Math.floor(window.innerWidth * 0.8);
|
||
var winH = Math.floor(window.innerHeight * 0.9);
|
||
container.style.cssText =
|
||
'position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);z-index:100002;' +
|
||
'width:' + winW + 'px;height:' + winH + 'px;' +
|
||
'background:rgba(8,12,20,0.82);backdrop-filter:blur(16px);-webkit-backdrop-filter:blur(16px);' +
|
||
'border:1px solid rgba(125,249,255,0.25);border-radius:12px;' +
|
||
'box-shadow:0 12px 48px rgba(0,0,0,0.7);display:flex;flex-direction:column;overflow:hidden;' +
|
||
'user-select:none;-webkit-user-select:none;';
|
||
document.body.appendChild(container);
|
||
|
||
// ===== 窗口布局持久化(位置 + 尺寸)=====
|
||
function SaveWinLayout() {
|
||
try {
|
||
var r = container.getBoundingClientRect();
|
||
localStorage.setItem('hornet_layout_warsandbox', JSON.stringify({
|
||
left: Math.round(r.left), top: Math.round(r.top),
|
||
width: Math.round(r.width), height: Math.round(r.height)
|
||
}));
|
||
} catch(e) {}
|
||
}
|
||
try {
|
||
var __wsSaved = localStorage.getItem('hornet_layout_warsandbox');
|
||
if (__wsSaved) {
|
||
var __wsP = JSON.parse(__wsSaved);
|
||
if (__wsP && typeof __wsP.left === 'number' && typeof __wsP.top === 'number') {
|
||
container.style.transform = 'none';
|
||
container.style.left = Math.max(0, __wsP.left) + 'px';
|
||
container.style.top = Math.max(0, __wsP.top) + 'px';
|
||
if (typeof __wsP.width === 'number') container.style.width = Math.max(320, __wsP.width) + 'px';
|
||
if (typeof __wsP.height === 'number') container.style.height = Math.max(240, __wsP.height) + 'px';
|
||
}
|
||
}
|
||
} catch(e) {}
|
||
|
||
var titleBar = document.createElement('div');
|
||
titleBar.style.cssText =
|
||
'display:flex;justify-content:space-between;align-items:center;padding:10px 16px;' +
|
||
'background:rgba(125,249,255,0.06);border-bottom:1px solid rgba(125,249,255,0.15);cursor:move;' +
|
||
'font:14px Consolas,monospace;color:#7df9ff;flex-shrink:0;';
|
||
titleBar.innerHTML = '<span style="font-weight:bold;text-shadow:0 0 4px rgba(125,249,255,0.4);">战争沙盘</span>';
|
||
container.appendChild(titleBar);
|
||
|
||
var ctrlBox = document.createElement('div');
|
||
ctrlBox.style.cssText = 'display:flex;gap:10px;align-items:center;';
|
||
titleBar.appendChild(ctrlBox);
|
||
|
||
var infoLabel = document.createElement('span');
|
||
infoLabel.style.cssText = 'font-size:11px;color:#5a7a8a;';
|
||
ctrlBox.appendChild(infoLabel);
|
||
|
||
var closeBtn = document.createElement('button');
|
||
closeBtn.textContent = 'X';
|
||
closeBtn.style.cssText =
|
||
'padding:2px 8px;border:1px solid rgba(255,100,100,0.4);border-radius:4px;' +
|
||
'background:rgba(200,50,50,0.3);color:#ff8080;cursor:pointer;font:12px Consolas;';
|
||
closeBtn.onclick = function() { _stop(); };
|
||
ctrlBox.appendChild(closeBtn);
|
||
|
||
var canvas = document.createElement('canvas');
|
||
canvas.style.cssText = 'flex:1;cursor:grab;display:block;';
|
||
container.appendChild(canvas);
|
||
var ctx = canvas.getContext('2d');
|
||
|
||
function resizeCanvas() {
|
||
var rect = canvas.getBoundingClientRect();
|
||
canvas.width = Math.floor(rect.width);
|
||
canvas.height = Math.floor(rect.height);
|
||
}
|
||
resizeCanvas();
|
||
|
||
// ===== 右下角缩放手柄:允许自由缩放窗口 =====
|
||
var resizeGrip = document.createElement('div');
|
||
resizeGrip.style.cssText =
|
||
'position:absolute;right:0;bottom:0;width:18px;height:18px;cursor:nwse-resize;z-index:2;' +
|
||
'background:linear-gradient(135deg,transparent 45%,rgba(125,249,255,0.35) 45%);' +
|
||
'border-bottom-right-radius:12px;';
|
||
container.appendChild(resizeGrip);
|
||
|
||
var __rsx = 0, __rsy = 0, __rsw = 0, __rsh = 0;
|
||
BindPointerDrag(resizeGrip, function(e) {
|
||
var r = container.getBoundingClientRect();
|
||
__rsx = e.clientX; __rsy = e.clientY; __rsw = r.width; __rsh = r.height;
|
||
return true;
|
||
}, function(e) {
|
||
container.style.transform = 'none';
|
||
container.style.width = Math.max(320, __rsw + (e.clientX - __rsx)) + 'px';
|
||
container.style.height = Math.max(240, __rsh + (e.clientY - __rsy)) + 'px';
|
||
resizeCanvas();
|
||
}, function() {
|
||
resizeCanvas();
|
||
SaveWinLayout();
|
||
});
|
||
|
||
// ===== FBO 创建 =====
|
||
var sandboxFbo = null, sandboxTex = null, sandboxDepthRb = null;
|
||
var pixels = new Uint8Array(FBO_W * FBO_H * 4);
|
||
var pixels32 = new Uint32Array(pixels.buffer);
|
||
var imageData = ctx.createImageData(FBO_W, FBO_H);
|
||
var tempCanvas = document.createElement('canvas');
|
||
tempCanvas.width = FBO_W; tempCanvas.height = FBO_H;
|
||
var tempCtx = tempCanvas.getContext('2d');
|
||
var pbos = [null, null], pboIndex = 0, pboReady = false, pboSupported = false;
|
||
|
||
try {
|
||
sandboxFbo = gl.createFramebuffer();
|
||
sandboxTex = gl.createTexture();
|
||
sandboxDepthRb = gl.createRenderbuffer();
|
||
gl.bindTexture(gl.TEXTURE_2D, sandboxTex);
|
||
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, FBO_W, FBO_H, 0, gl.RGBA, gl.UNSIGNED_BYTE, null);
|
||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
|
||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
|
||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
|
||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
|
||
gl.bindRenderbuffer(gl.RENDERBUFFER, sandboxDepthRb);
|
||
gl.renderbufferStorage(gl.RENDERBUFFER, gl.DEPTH_COMPONENT16, FBO_W, FBO_H);
|
||
gl.bindFramebuffer(gl.FRAMEBUFFER, sandboxFbo);
|
||
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, sandboxTex, 0);
|
||
gl.framebufferRenderbuffer(gl.FRAMEBUFFER, gl.DEPTH_ATTACHMENT, gl.RENDERBUFFER, sandboxDepthRb);
|
||
var status = gl.checkFramebufferStatus(gl.FRAMEBUFFER);
|
||
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
|
||
gl.bindTexture(gl.TEXTURE_2D, null);
|
||
gl.bindRenderbuffer(gl.RENDERBUFFER, null);
|
||
if (status !== gl.FRAMEBUFFER_COMPLETE) throw new Error('FBO不完整: ' + status);
|
||
// PBO
|
||
try {
|
||
var byteSize = FBO_W * FBO_H * 4;
|
||
for (var i = 0; i < 2; i++) {
|
||
pbos[i] = gl.createBuffer();
|
||
gl.bindBuffer(gl.PIXEL_PACK_BUFFER, pbos[i]);
|
||
gl.bufferData(gl.PIXEL_PACK_BUFFER, byteSize, gl.STREAM_READ);
|
||
}
|
||
gl.bindBuffer(gl.PIXEL_PACK_BUFFER, null);
|
||
pboSupported = true;
|
||
} catch(e) { pboSupported = false; }
|
||
console.log('[沙盘] FBO ' + FBO_W + 'x' + FBO_H + ' 已创建' + (pboSupported ? ' (PBO)' : ' (同步)'));
|
||
} catch(e) {
|
||
initError = e;
|
||
console.error('[沙盘] FBO创建失败:', e.message);
|
||
}
|
||
if (initError) return;
|
||
|
||
// ===== Hook gl.bindFramebuffer + BS.h3c/g3c =====
|
||
var sandboxRendering = false;
|
||
var mainFbo = null;
|
||
var origBindFramebuffer = gl.bindFramebuffer.bind(gl);
|
||
gl.bindFramebuffer = function(target, fbo) {
|
||
if (!sandboxRendering && target === gl.FRAMEBUFFER) mainFbo = fbo;
|
||
if (sandboxRendering && target === gl.FRAMEBUFFER && fbo !== sandboxFbo) {
|
||
return origBindFramebuffer(target, sandboxFbo);
|
||
}
|
||
return origBindFramebuffer(target, fbo);
|
||
};
|
||
var origH3c = ff.w39_1.o3c.bind(ff.w39_1);
|
||
var origG3c = ff.w39_1.n3c.bind(ff.w39_1);
|
||
ff.w39_1.o3c = function() { return sandboxRendering ? FBO_W : origH3c(); };
|
||
ff.w39_1.n3c = function() { return sandboxRendering ? FBO_H : origG3c(); };
|
||
|
||
// UI 层禁用
|
||
var uiLayerIndices = [20, 21, 22, 23, 24];
|
||
var fakeQueue = {
|
||
e1: function() { return 0; }, g1: function(i) { return null; },
|
||
w2a: function(x) {}, d3: function() {}, ii: function(x) {}
|
||
};
|
||
|
||
// ===== 视图状态 =====
|
||
var viewCenterX = 0, viewCenterY = 0;
|
||
var camHeight = Config.defaultHeight;
|
||
var viewYaw = 0; // 地图旋转(0=北朝上)
|
||
var initialized = false;
|
||
|
||
// ===== 指针拖动绑定(Pointer Events + setPointerCapture,避免松手后粘手)=====
|
||
// HandleEl: 触发拖动的元素;OnStart/OnMove/OnEnd 分别处理开始/移动/结束
|
||
function BindPointerDrag(HandleEl, OnStart, OnMove, OnEnd) {
|
||
var Active = false;
|
||
var PointerId = null;
|
||
|
||
function Finish() {
|
||
if (!Active) return;
|
||
Active = false;
|
||
if (PointerId !== null && HandleEl.releasePointerCapture) {
|
||
try { HandleEl.releasePointerCapture(PointerId); } catch (Err) {}
|
||
}
|
||
PointerId = null;
|
||
OnEnd();
|
||
}
|
||
|
||
if (window.PointerEvent) {
|
||
HandleEl.addEventListener('pointerdown', function(e) {
|
||
if (e.button !== 0) return;
|
||
if (OnStart(e) === false) return;
|
||
Active = true;
|
||
PointerId = e.pointerId;
|
||
try { HandleEl.setPointerCapture(e.pointerId); } catch (Err) {}
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
}, true);
|
||
HandleEl.addEventListener('pointermove', function(e) {
|
||
if (!Active || (PointerId !== null && e.pointerId !== PointerId)) return;
|
||
if (e.buttons === 0) { Finish(); return; } // 兜底:按键已松开
|
||
OnMove(e);
|
||
e.preventDefault();
|
||
}, true);
|
||
HandleEl.addEventListener('pointerup', Finish, true);
|
||
HandleEl.addEventListener('pointercancel', Finish, true);
|
||
HandleEl.addEventListener('lostpointercapture', Finish, true);
|
||
window.addEventListener('pointerup', Finish, true);
|
||
} else {
|
||
HandleEl.addEventListener('mousedown', function(e) {
|
||
if (e.button !== 0) return;
|
||
if (OnStart(e) === false) return;
|
||
Active = true;
|
||
e.preventDefault();
|
||
}, true);
|
||
document.addEventListener('mousemove', function(e) {
|
||
if (!Active) return;
|
||
if (e.buttons === 0) { Finish(); return; }
|
||
OnMove(e);
|
||
}, true);
|
||
document.addEventListener('mouseup', Finish, true);
|
||
}
|
||
|
||
window.addEventListener('blur', Finish);
|
||
window.addEventListener('mouseleave', Finish);
|
||
return { End: Finish, IsActive: function() { return Active; } };
|
||
}
|
||
|
||
// ===== 窗口拖动 =====
|
||
var dsx = 0, dsy = 0, dwx = 0, dwy = 0;
|
||
BindPointerDrag(titleBar, function(e) {
|
||
if (e.target.tagName === 'BUTTON') return false;
|
||
dsx = e.clientX; dsy = e.clientY;
|
||
var rect = container.getBoundingClientRect();
|
||
dwx = rect.left; dwy = rect.top;
|
||
container.style.transform = 'none';
|
||
container.style.left = dwx + 'px'; container.style.top = dwy + 'px';
|
||
}, function(e) {
|
||
var nx = Math.max(0, Math.min(window.innerWidth - 100, dwx + e.clientX - dsx));
|
||
var ny = Math.max(0, Math.min(window.innerHeight - 40, dwy + e.clientY - dsy));
|
||
container.style.left = nx + 'px'; container.style.top = ny + 'px';
|
||
}, function() { SaveWinLayout(); });
|
||
|
||
// ===== 地图平移 =====
|
||
var psx = 0, psy = 0, pcx = 0, pcy = 0;
|
||
BindPointerDrag(canvas, function(e) {
|
||
psx = e.clientX; psy = e.clientY;
|
||
pcx = viewCenterX; pcy = viewCenterY;
|
||
canvas.style.cursor = 'grabbing';
|
||
}, function(e) {
|
||
// 俯瞰视图:屏幕像素→世界单位,X/Y 方向缩放不同(FBO 拉伸到 canvas)
|
||
var ext = getViewExtents();
|
||
var dx = (e.clientX - psx) * ext.wppX;
|
||
var dy = (e.clientY - psy) * ext.wppY;
|
||
viewCenterX = pcx - dx;
|
||
viewCenterY = pcy + dy; // y 反转
|
||
}, function() {
|
||
canvas.style.cursor = 'grab';
|
||
});
|
||
|
||
// 滚轮缩放(改变相机高度)
|
||
canvas.addEventListener('wheel', function(e) {
|
||
e.preventDefault();
|
||
var factor = e.deltaY < 0 ? 0.85 : 1.15;
|
||
var newHeight = camHeight * factor;
|
||
if (newHeight < Config.minHeight || newHeight > Config.maxHeight) return;
|
||
camHeight = newHeight;
|
||
}, { passive: false });
|
||
|
||
// 双击居中
|
||
canvas.addEventListener('dblclick', function() {
|
||
var player = GetPlayerTank();
|
||
if (player) {
|
||
var pos = GetTankPosition(player);
|
||
if (pos) { viewCenterX = pos.x; viewCenterY = pos.y; }
|
||
} else {
|
||
var fl = window.__hornetFreeLook__;
|
||
if (fl && fl.IsActive && fl.IsActive()) {
|
||
var p = fl.GetPosition();
|
||
if (p) { viewCenterX = p.x; viewCenterY = p.y; }
|
||
}
|
||
}
|
||
});
|
||
|
||
// ===== 世界→屏幕投影(俯瞰视图,用于 2D 叠加层)=====
|
||
// 注意:FBO(960x720) 被拉伸到 canvas(cw x ch),若两者比例不同则 X/Y 缩放不同
|
||
// 相机以 FBO_W/FBO_H 为 aspect 渲染,可见世界范围:
|
||
// visH = 2*camHeight*tan(fov/2) (世界单位,纵向)
|
||
// visW = visH * (FBO_W/FBO_H) (世界单位,横向)
|
||
// FBO 拉伸到 canvas 后:
|
||
// worldPerPixelX = visW / cw
|
||
// worldPerPixelY = visH / ch
|
||
function getViewExtents() {
|
||
var ch = canvas.height, cw = canvas.width;
|
||
var visH = 2 * camHeight * Math.tan(Config.fov / 2);
|
||
var visW = visH * (FBO_W / FBO_H);
|
||
return {
|
||
visW: visW, visH: visH,
|
||
wppX: visW / cw, // X方向 世界单位/像素
|
||
wppY: visH / ch // Y方向 世界单位/像素
|
||
};
|
||
}
|
||
function worldToScreen(wx, wy) {
|
||
var ext = getViewExtents();
|
||
var cw = canvas.width, ch = canvas.height;
|
||
return {
|
||
x: cw / 2 + (wx - viewCenterX) / ext.wppX,
|
||
y: ch / 2 - (wy - viewCenterY) / ext.wppY
|
||
};
|
||
}
|
||
|
||
// ===== 2D 叠加层绘制 =====
|
||
function drawOverlays() {
|
||
var cw = canvas.width, ch = canvas.height;
|
||
var playerTank = GetPlayerTank();
|
||
|
||
// 网格(X/Y 方向缩放不同,用 ext 计算)
|
||
if (ShowGrid) {
|
||
ctx.strokeStyle = 'rgba(125,249,255,0.08)';
|
||
ctx.lineWidth = 1;
|
||
var steps = [500, 1000, 2000, 5000, 10000, 20000, 50000];
|
||
var step = 5000;
|
||
var ext = getViewExtents();
|
||
// 用较小的 wpp 选择网格间距(保证密度合适)
|
||
var minWpp = Math.min(ext.wppX, ext.wppY);
|
||
for (var i = 0; i < steps.length; i++) {
|
||
if (steps[i] / minWpp > 60) { step = steps[i]; break; }
|
||
}
|
||
var left = viewCenterX - (cw/2) * ext.wppX;
|
||
var right = viewCenterX + (cw/2) * ext.wppX;
|
||
var top = viewCenterY + (ch/2) * ext.wppY;
|
||
var bottom = viewCenterY - (ch/2) * ext.wppY;
|
||
var startX = Math.floor(left/step)*step;
|
||
var startY = Math.floor(bottom/step)*step;
|
||
ctx.beginPath();
|
||
for (var wx = startX; wx <= right; wx += step) {
|
||
var p = worldToScreen(wx, 0);
|
||
ctx.moveTo(p.x, 0); ctx.lineTo(p.x, ch);
|
||
}
|
||
for (var wy = startY; wy <= top; wy += step) {
|
||
var p = worldToScreen(0, wy);
|
||
ctx.moveTo(0, p.y); ctx.lineTo(cw, p.y);
|
||
}
|
||
ctx.stroke();
|
||
// 距离标签
|
||
ctx.fillStyle = 'rgba(125,249,255,0.35)';
|
||
ctx.font = '10px Consolas';
|
||
ctx.textAlign = 'left'; ctx.textBaseline = 'top';
|
||
for (var wx2 = startX; wx2 <= right; wx2 += step) {
|
||
var p = worldToScreen(wx2, 0);
|
||
ctx.fillText((wx2/100).toFixed(0)+'m', p.x+2, 2);
|
||
}
|
||
}
|
||
|
||
// 坦克标记
|
||
var tanks = [];
|
||
try {
|
||
var list = gm.m10b_1.j11y();
|
||
var n = list.e1();
|
||
for (var i = 0; i < n; i++) { var t = list.g1(i); if (t) tanks.push(t); }
|
||
} catch(e) {}
|
||
var margin = 50;
|
||
|
||
tanks.forEach(function(tank) {
|
||
var pos = GetTankPosition(tank);
|
||
if (!pos) return;
|
||
var p = worldToScreen(pos.x, pos.y);
|
||
if (p.x < -margin || p.x > cw+margin || p.y < -margin || p.y > ch+margin) return;
|
||
|
||
var isPlayer = (tank === playerTank);
|
||
var friendly = IsFriendly(tank, playerTank);
|
||
var alive = IsTankAlive(tank);
|
||
var state = GetTankState(tank);
|
||
if (state !== 3 && !isPlayer) return;
|
||
|
||
var color, glow;
|
||
if (isPlayer) { color = '#ffdd44'; glow = 'rgba(255,221,68,0.7)'; }
|
||
else if (friendly) { color = '#44ff88'; glow = 'rgba(68,255,136,0.5)'; }
|
||
else { color = '#ff5566'; glow = 'rgba(255,85,102,0.5)'; }
|
||
|
||
var radius = (isPlayer ? 8 : 6) * MarkerSize;
|
||
if (!alive) { radius = 4 * MarkerSize; color = '#666'; glow = 'none'; }
|
||
|
||
// 方向指示(真实坦克朝向)
|
||
var dir = getTankDirection(tank);
|
||
if (dir && alive) {
|
||
var dirLen = Math.sqrt(dir.x*dir.x + dir.y*dir.y);
|
||
if (dirLen > 0.01) {
|
||
var dx = dir.x / dirLen, dy = dir.y / dirLen;
|
||
var arrowLen = 22;
|
||
ctx.strokeStyle = color;
|
||
ctx.lineWidth = 2.5;
|
||
ctx.beginPath();
|
||
ctx.moveTo(p.x, p.y);
|
||
ctx.lineTo(p.x + dx * arrowLen, p.y - dy * arrowLen);
|
||
ctx.stroke();
|
||
// 箭头头
|
||
var ax = p.x + dx * arrowLen, ay = p.y - dy * arrowLen;
|
||
var ang = Math.atan2(-dy, dx);
|
||
ctx.beginPath();
|
||
ctx.moveTo(ax, ay);
|
||
ctx.lineTo(ax - 7*Math.cos(ang-0.4), ay - 7*Math.sin(ang-0.4));
|
||
ctx.moveTo(ax, ay);
|
||
ctx.lineTo(ax - 7*Math.cos(ang+0.4), ay - 7*Math.sin(ang+0.4));
|
||
ctx.stroke();
|
||
}
|
||
}
|
||
|
||
// 光晕标记
|
||
if (alive) {
|
||
ctx.save();
|
||
ctx.shadowColor = glow;
|
||
ctx.shadowBlur = 12;
|
||
ctx.fillStyle = color;
|
||
ctx.beginPath();
|
||
ctx.arc(p.x, p.y, radius, 0, Math.PI*2);
|
||
ctx.fill();
|
||
ctx.restore();
|
||
} else {
|
||
ctx.fillStyle = color;
|
||
ctx.fillRect(p.x-3, p.y-3, 6, 6);
|
||
}
|
||
|
||
// 玩家外圈
|
||
if (isPlayer && alive) {
|
||
ctx.strokeStyle = 'rgba(255,221,68,0.6)';
|
||
ctx.lineWidth = 2;
|
||
ctx.beginPath();
|
||
ctx.arc(p.x, p.y, radius+6, 0, Math.PI*2);
|
||
ctx.stroke();
|
||
}
|
||
|
||
// 名称
|
||
var name = GetTankName(tank);
|
||
if (name) {
|
||
ctx.font = 'bold 11px Consolas';
|
||
ctx.textAlign = 'center';
|
||
ctx.textBaseline = 'bottom';
|
||
ctx.strokeStyle = 'rgba(0,0,0,0.85)';
|
||
ctx.lineWidth = 3;
|
||
ctx.strokeText(name, p.x, p.y - radius - 5);
|
||
ctx.fillStyle = alive ? '#fff' : '#666';
|
||
ctx.fillText(name, p.x, p.y - radius - 5);
|
||
}
|
||
|
||
// 血量条
|
||
if (alive) {
|
||
var health = GetTankHealth(tank);
|
||
if (health) {
|
||
var pct = health.cur / health.max;
|
||
if (pct > 1) pct = 1;
|
||
var barW = 34, barH = 5;
|
||
var bx = p.x - barW/2, by = p.y + radius + 4;
|
||
ctx.fillStyle = 'rgba(0,0,0,0.7)';
|
||
ctx.fillRect(bx, by, barW, barH);
|
||
var hpColor = pct > 0.6 ? '#2ecc71' : (pct > 0.3 ? '#f5a623' : '#e05555');
|
||
ctx.fillStyle = hpColor;
|
||
ctx.fillRect(bx, by, barW * pct, barH);
|
||
ctx.strokeStyle = 'rgba(255,255,255,0.3)';
|
||
ctx.lineWidth = 0.5;
|
||
ctx.strokeRect(bx, by, barW, barH);
|
||
}
|
||
}
|
||
});
|
||
|
||
// 罗盘
|
||
var ccx = cw - 50, ccy = 50, cr = 28;
|
||
ctx.save();
|
||
ctx.strokeStyle = 'rgba(125,249,255,0.4)';
|
||
ctx.lineWidth = 1.5;
|
||
ctx.beginPath(); ctx.arc(ccx, ccy, cr, 0, Math.PI*2); ctx.stroke();
|
||
ctx.fillStyle = 'rgba(8,12,20,0.6)';
|
||
ctx.beginPath(); ctx.arc(ccx, ccy, cr-1, 0, Math.PI*2); ctx.fill();
|
||
ctx.fillStyle = '#ff5566';
|
||
ctx.beginPath();
|
||
ctx.moveTo(ccx, ccy - cr + 4);
|
||
ctx.lineTo(ccx - 5, ccy);
|
||
ctx.lineTo(ccx + 5, ccy);
|
||
ctx.closePath(); ctx.fill();
|
||
ctx.fillStyle = 'rgba(200,210,220,0.6)';
|
||
ctx.beginPath();
|
||
ctx.moveTo(ccx, ccy + cr - 4);
|
||
ctx.lineTo(ccx - 5, ccy);
|
||
ctx.lineTo(ccx + 5, ccy);
|
||
ctx.closePath(); ctx.fill();
|
||
ctx.fillStyle = '#7df9ff';
|
||
ctx.font = 'bold 12px Consolas';
|
||
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
|
||
ctx.fillText('N', ccx, ccy - cr - 8);
|
||
ctx.restore();
|
||
|
||
// 中心十字准星
|
||
ctx.strokeStyle = 'rgba(125,249,255,0.2)';
|
||
ctx.lineWidth = 1;
|
||
ctx.beginPath();
|
||
ctx.moveTo(cw/2 - 15, ch/2); ctx.lineTo(cw/2 + 15, ch/2);
|
||
ctx.moveTo(cw/2, ch/2 - 15); ctx.lineTo(cw/2, ch/2 + 15);
|
||
ctx.stroke();
|
||
}
|
||
|
||
// ===== 主渲染循环 =====
|
||
var running = true;
|
||
var rafId = null;
|
||
var frameCount = 0;
|
||
|
||
function render() {
|
||
if (!running) return;
|
||
|
||
// 换图自刷新:gameMode 实例变化时整体重启,重新获取场景/相机/GL hook
|
||
var NewGm = window.__gameMode__;
|
||
if (NewGm && NewGm !== gm) {
|
||
console.log('[沙盘] 检测到新战斗,正在重启战争沙盘...');
|
||
self.Stop();
|
||
_start();
|
||
return;
|
||
}
|
||
|
||
frameCount++;
|
||
// 旧实例已失效、新实例尚未捕获:跳过本帧
|
||
if (!window.__gameMode__) { rafId = requestAnimationFrame(render); return; }
|
||
var cw = canvas.width, ch = canvas.height;
|
||
|
||
// 初始化视图中心
|
||
if (!initialized) {
|
||
var player = GetPlayerTank();
|
||
if (player) {
|
||
var pos = GetTankPosition(player);
|
||
if (pos) { viewCenterX = pos.x; viewCenterY = pos.y; initialized = true; }
|
||
}
|
||
}
|
||
|
||
// 暂停 FreeLook
|
||
var fl = window.__hornetFreeLook__;
|
||
if (fl && typeof fl.SetSuspended === 'function') fl.SetSuspended(true);
|
||
|
||
try {
|
||
// 保存主相机参数
|
||
var sPitch = mainCamera.u35_1.f20_1;
|
||
var sRoll = mainCamera.u35_1.g20_1;
|
||
var sYaw = mainCamera.u35_1.h20_1;
|
||
var sX = mainCamera.r35_1.f20_1;
|
||
var sY = mainCamera.r35_1.g20_1;
|
||
var sZ = mainCamera.r35_1.h20_1;
|
||
var sL36 = mainCamera.u36_1, sM36 = mainCamera.v36_1;
|
||
var sS36 = mainCamera.z36_1, sQ36 = mainCamera.x36_1, sR36 = mainCamera.y36_1;
|
||
|
||
// 设置俯瞰相机参数
|
||
// pitch=-π/2 朝下看,roll=0 不翻转,yaw=0 北朝上
|
||
mainCamera.u35_1.f20_1 = -Math.PI / 2;
|
||
mainCamera.u35_1.g20_1 = 0;
|
||
mainCamera.u35_1.h20_1 = 0;
|
||
mainCamera.r35_1.f20_1 = viewCenterX;
|
||
mainCamera.r35_1.g20_1 = viewCenterY;
|
||
mainCamera.r35_1.h20_1 = camHeight;
|
||
mainCamera.u36_1 = FBO_W;
|
||
mainCamera.v36_1 = FBO_H;
|
||
mainCamera.z36_1 = Config.fov;
|
||
mainCamera.x36_1 = Config.near;
|
||
mainCamera.y36_1 = Config.far;
|
||
mainCamera.o37();
|
||
|
||
// 禁用阴影/后处理
|
||
var savedX3n = ff.y38_1.e3o_1;
|
||
var savedY3n = ff.y38_1.f3o_1;
|
||
var savedZ3n = ff.y38_1.k3o_1;
|
||
ff.y38_1.e3o_1 = false;
|
||
ff.y38_1.f3o_1 = false;
|
||
ff.y38_1.k3o_1 = false;
|
||
|
||
// 禁用 UI 层
|
||
var savedUiQueues = {};
|
||
for (var ui = 0; ui < uiLayerIndices.length; ui++) {
|
||
var li = uiLayerIndices[ui];
|
||
savedUiQueues[li] = ff.e39_1[li];
|
||
ff.e39_1[li] = fakeQueue;
|
||
}
|
||
|
||
// 渲染到 FBO
|
||
sandboxRendering = true;
|
||
origBindFramebuffer(gl.FRAMEBUFFER, sandboxFbo);
|
||
gl.viewport(0, 0, FBO_W, FBO_H);
|
||
gl.clearColor(0.05, 0.08, 0.12, 1.0);
|
||
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
|
||
ff.f3t(rootNode, mainCamera);
|
||
sandboxRendering = false;
|
||
|
||
// Readback
|
||
origBindFramebuffer(gl.FRAMEBUFFER, sandboxFbo);
|
||
if (pboSupported) {
|
||
var curPbo = pboIndex, prevPbo = 1 - pboIndex;
|
||
gl.bindBuffer(gl.PIXEL_PACK_BUFFER, pbos[curPbo]);
|
||
gl.readPixels(0, 0, FBO_W, FBO_H, gl.RGBA, gl.UNSIGNED_BYTE, 0);
|
||
if (pboReady) {
|
||
gl.bindBuffer(gl.PIXEL_PACK_BUFFER, pbos[prevPbo]);
|
||
gl.getBufferSubData(gl.PIXEL_PACK_BUFFER, 0, pixels);
|
||
}
|
||
gl.bindBuffer(gl.PIXEL_PACK_BUFFER, null);
|
||
pboIndex = prevPbo;
|
||
pboReady = true;
|
||
} else {
|
||
gl.readPixels(0, 0, FBO_W, FBO_H, gl.RGBA, gl.UNSIGNED_BYTE, pixels);
|
||
}
|
||
|
||
// 强制 alpha=255
|
||
for (var ai = 0; ai < pixels32.length; ai++) pixels32[ai] = pixels32[ai] | 0xFF000000;
|
||
imageData.data.set(pixels);
|
||
|
||
// Y 翻转显示(roll=0 时 GL readback 产生上下颠倒图像,需翻转)
|
||
tempCtx.putImageData(imageData, 0, 0);
|
||
ctx.clearRect(0, 0, cw, ch);
|
||
ctx.save();
|
||
ctx.imageSmoothingEnabled = true;
|
||
ctx.scale(1, -1);
|
||
ctx.drawImage(tempCanvas, 0, -ch, cw, ch);
|
||
ctx.restore();
|
||
|
||
// 恢复主 FBO
|
||
origBindFramebuffer(gl.FRAMEBUFFER, mainFbo);
|
||
|
||
// 恢复渲染状态
|
||
ff.y38_1.e3o_1 = savedX3n;
|
||
ff.y38_1.f3o_1 = savedY3n;
|
||
ff.y38_1.k3o_1 = savedZ3n;
|
||
for (var ui2 = 0; ui2 < uiLayerIndices.length; ui2++) {
|
||
ff.e39_1[uiLayerIndices[ui2]] = savedUiQueues[uiLayerIndices[ui2]];
|
||
}
|
||
|
||
// 恢复主相机
|
||
mainCamera.u35_1.f20_1 = sPitch;
|
||
mainCamera.u35_1.g20_1 = sRoll;
|
||
mainCamera.u35_1.h20_1 = sYaw;
|
||
mainCamera.r35_1.f20_1 = sX;
|
||
mainCamera.r35_1.g20_1 = sY;
|
||
mainCamera.r35_1.h20_1 = sZ;
|
||
mainCamera.u36_1 = sL36; mainCamera.v36_1 = sM36;
|
||
mainCamera.z36_1 = sS36; mainCamera.x36_1 = sQ36; mainCamera.y36_1 = sR36;
|
||
mainCamera.o37();
|
||
|
||
// 绘制 2D 叠加层(坦克标记/网格/罗盘)
|
||
drawOverlays();
|
||
|
||
} catch(e) {
|
||
console.error('[沙盘] 渲染失败:', e.message);
|
||
sandboxRendering = false;
|
||
} finally {
|
||
// 确保 FreeLook 恢复
|
||
if (fl && typeof fl.SetSuspended === 'function') fl.SetSuspended(false);
|
||
}
|
||
|
||
// 信息更新
|
||
if (frameCount % 30 === 0) {
|
||
var ext2 = getViewExtents();
|
||
var viewRange = cw * ext2.wppX; // 横向可视范围
|
||
infoLabel.textContent = '高度:' + (camHeight/100).toFixed(0) + 'm | 范围:' +
|
||
(viewRange/100).toFixed(0) + 'm | 拖拽平移 滚轮缩放 双击居中';
|
||
}
|
||
|
||
rafId = requestAnimationFrame(render);
|
||
}
|
||
|
||
window.addEventListener('resize', resizeCanvas);
|
||
rafId = requestAnimationFrame(render);
|
||
console.log('[沙盘] 战争沙盘已就绪 — 拖拽地图平移 | 滚轮缩放 | 双击居中玩家 | Alt+M 或 X 关闭');
|
||
|
||
// ===== 对外接口 =====
|
||
var self = {
|
||
Stop: function() {
|
||
running = false;
|
||
if (rafId) cancelAnimationFrame(rafId);
|
||
// 恢复 hook
|
||
gl.bindFramebuffer = origBindFramebuffer;
|
||
ff.w39_1.o3c = origH3c;
|
||
ff.w39_1.n3c = origG3c;
|
||
window.removeEventListener('resize', resizeCanvas);
|
||
if (container.parentNode) container.parentNode.removeChild(container);
|
||
window.__hornetWarSandbox__ = null;
|
||
console.log('[沙盘] 战争沙盘已关闭');
|
||
},
|
||
Status: function() { return running ? '已开启' : '未运行'; },
|
||
GetRunning: function() { return running; },
|
||
SetShowGrid: function(on) {
|
||
ShowGrid = !!on;
|
||
try { localStorage.setItem('hornet_ws_showgrid', ShowGrid ? 'true' : 'false'); } catch(e) {}
|
||
console.log('[沙盘] 网格: ' + (ShowGrid ? '显示' : '隐藏'));
|
||
},
|
||
GetShowGrid: function() { return ShowGrid; },
|
||
SetMarkerSize: function(s) {
|
||
var n = parseFloat(s);
|
||
if (!isNaN(n) && n >= 0.5 && n <= 3.0) {
|
||
MarkerSize = n;
|
||
try { localStorage.setItem('hornet_ws_marker_size', String(n)); } catch(e) {}
|
||
console.log('[沙盘] 标记大小: ' + n);
|
||
} else {
|
||
console.warn('[沙盘] 标记大小应在 0.5~3.0');
|
||
}
|
||
},
|
||
GetMarkerSize: function() { return MarkerSize; }
|
||
};
|
||
window.__hornetWarSandbox__ = self;
|
||
window.__hornetTools__ = window.__hornetTools__ || {};
|
||
window.__hornetTools__.WarSandbox = self;
|
||
}
|
||
|
||
function _stop() {
|
||
if (window.__hornetWarSandbox__) window.__hornetWarSandbox__.Stop();
|
||
}
|
||
function _status() {
|
||
return window.__hornetWarSandbox__ ? window.__hornetWarSandbox__.Status() : '未运行';
|
||
}
|
||
function _getRunning() {
|
||
return !!(window.__hornetWarSandbox__ && window.__hornetWarSandbox__.GetRunning());
|
||
}
|
||
|
||
window.__hornetTools__ = window.__hornetTools__ || {};
|
||
window.__hornetTools__.WarSandbox = {
|
||
Start: _start,
|
||
Stop: _stop,
|
||
Status: _status,
|
||
GetRunning: _getRunning,
|
||
SetShowGrid: function(on) { if (window.__hornetWarSandbox__) window.__hornetWarSandbox__.SetShowGrid(on); },
|
||
GetShowGrid: function() { return window.__hornetWarSandbox__ ? window.__hornetWarSandbox__.GetShowGrid() : true; },
|
||
SetMarkerSize: function(s) { if (window.__hornetWarSandbox__) window.__hornetWarSandbox__.SetMarkerSize(s); },
|
||
GetMarkerSize: function() { return window.__hornetWarSandbox__ ? window.__hornetWarSandbox__.GetMarkerSize() : 1.0; }
|
||
};
|
||
})();
|