// ===== 3DTank 战争沙盘 - 真实3D俯瞰渲染 ===== // 在浏览器控制台运行(需先运行 hook_gamemode.js 和 hornet_lib.js) // 功能:80%x90% 可拖拽缩放的全景俯瞰图,使用游戏原生渲染器渲染 3D 场景 // 原理:Hook gl.bindFramebuffer 重定向到自定义 FBO,用俯瞰相机参数调用游戏渲染器 // 操作:拖拽标题移动窗口 | 拖拽地图平移 | 滚轮缩放 | 双击居中 | Alt+M 开关 (function() { // ===== 字段抽象层(语义名 → 匿名字段,总表在 hornet_schema.js)===== var Schema = window.__hornetSchema__ || null; function Fld(Key, Fallback) { var N = Schema ? Schema.FieldName(Key) : null; return N || Fallback; } function Mth(Key, Fallback) { var N = Schema ? Schema.MethodName(Key) : null; return N || Fallback; } // 向量 var FVecX = Fld('Vec.X', 'f20_1'); var FVecY = Fld('Vec.Y', 'g20_1'); var FVecZ = Fld('Vec.Z', 'h20_1'); // 战局 / 场景 var MScene = Mth('GameMode.Scene', 'lz4'); var FCameraSceneRef = Fld('Camera.SceneRef', 'jz1_1'); var FTankManager = Fld('GameMode.TankManager', 'm10b_1'); var MTankList = Mth('GameMode.TankList', 'j11y'); // 渲染器 var FRenderer = Fld('Renderer.Object', 'd75_1'); var FRenderDevice = Fld('Renderer.Device', 'w39_1'); var FRenderGl = Fld('Renderer.Gl', 'b74_1'); var FRenderFlags = Fld('Renderer.Flags', 'y38_1'); var FRenderShadow = Fld('Renderer.Shadow', 'e3o_1'); var FRenderPostFx = Fld('Renderer.PostFx', 'f3o_1'); var FRenderFlagAux = Fld('Renderer.FlagAux', 'k3o_1'); var FRenderQueueList = Fld('Renderer.QueueList', 'e39_1'); // 相机 var FCameraRotation = Fld('Camera.Rotation', 'u35_1'); var FNodeChildren = Fld('Node.Children', 'r35_1'); // 本文件里该字段是相机位置向量(与节点子列表同名复用) var FCameraRenderWidth = Fld('Camera.RenderWidth', 'u36_1'); var FCameraRenderHeight = Fld('Camera.RenderHeight', 'v36_1'); var FCameraFov = Fld('Camera.Fov', 'z36_1'); var FCameraNear = Fld('Camera.Near', 'x36_1'); var FCameraFar = Fld('Camera.Far', 'y36_1'); // 皮肤 / 节点 var FSkinSlotHull = Fld('Skin.SlotHull', 'w14w_1'); var FSkinSlotHullLegacy = Fld('Skin.SlotHullLegacy', 't14l_1'); var FSkinSlotHullAlt = Fld('Skin.SlotHullAlt', 'w14x_1'); var FSkinSlotHullOld = Fld('Skin.SlotHullOld', 'p14l_1'); var FSkinParts = Fld('Skin.Parts', 'g17r_1'); var FSkinPartNode = Fld('Skin.PartNode', 'j17r_1'); var FNodeMatrixOld = Fld('Node.MatrixOld', 'n35_1'); 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, FSkinSlotHull) || FindComponentByField(tank, FSkinSlotHullLegacy) || FindComponentByField(tank, FSkinSlotHullAlt) || FindComponentByField(tank, FSkinSlotHullOld); if (!hullComp) return null; var skin = (hullComp[FSkinSlotHull] && hullComp[FSkinSlotHull][FSkinParts]) ? hullComp[FSkinSlotHull] : (hullComp[FSkinSlotHullLegacy] && hullComp[FSkinSlotHullLegacy][FSkinParts]) ? hullComp[FSkinSlotHullLegacy] : (hullComp[FSkinSlotHullOld] && hullComp[FSkinSlotHullOld][FSkinParts]) ? hullComp[FSkinSlotHullOld] : null; if (!skin) return null; try { var partCount = skin[FSkinParts].e1(); for (var p = 0; p < partCount; p++) { var part = skin[FSkinParts].g1(p); if (!part || !part[FSkinPartNode]) continue; var nodeCount = part[FSkinPartNode].e1(); for (var n = 0; n < nodeCount; n++) { var bpNode = part[FSkinPartNode].g1(n); if (!bpNode || !bpNode[FNodeMatrixOld]) continue; // 不按 g35_1 过滤:它是引擎的细节/淡出权重(0~1),不是幽灵标志, // 过滤会漏掉低细节节点、导致取不到朝向 var m = bpNode[FNodeMatrixOld]; // 列主序世界矩阵: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[MScene]()[FCameraSceneRef]; ff = scene.gyw_1[FRenderer]; rootNode = scene.fyx_1; mainCamera = scene.iyx_1; gl = ff[FRenderDevice][FRenderGl]; 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 = '战争沙盘'; 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[FRenderDevice].o3c.bind(ff[FRenderDevice]); var origG3c = ff[FRenderDevice].n3c.bind(ff[FRenderDevice]); ff[FRenderDevice].o3c = function() { return sandboxRendering ? FBO_W : origH3c(); }; ff[FRenderDevice].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[FTankManager][MTankList](); 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[FCameraRotation][FVecX]; var sRoll = mainCamera[FCameraRotation][FVecY]; var sYaw = mainCamera[FCameraRotation][FVecZ]; var sX = mainCamera[FNodeChildren][FVecX]; var sY = mainCamera[FNodeChildren][FVecY]; var sZ = mainCamera[FNodeChildren][FVecZ]; var sL36 = mainCamera[FCameraRenderWidth], sM36 = mainCamera[FCameraRenderHeight]; var sS36 = mainCamera[FCameraFov], sQ36 = mainCamera[FCameraNear], sR36 = mainCamera[FCameraFar]; // 设置俯瞰相机参数 // pitch=-π/2 朝下看,roll=0 不翻转,yaw=0 北朝上 mainCamera[FCameraRotation][FVecX] = -Math.PI / 2; mainCamera[FCameraRotation][FVecY] = 0; mainCamera[FCameraRotation][FVecZ] = 0; mainCamera[FNodeChildren][FVecX] = viewCenterX; mainCamera[FNodeChildren][FVecY] = viewCenterY; mainCamera[FNodeChildren][FVecZ] = camHeight; mainCamera[FCameraRenderWidth] = FBO_W; mainCamera[FCameraRenderHeight] = FBO_H; mainCamera[FCameraFov] = Config.fov; mainCamera[FCameraNear] = Config.near; mainCamera[FCameraFar] = Config.far; mainCamera.o37(); // 禁用阴影/后处理 var savedX3n = ff[FRenderFlags][FRenderShadow]; var savedY3n = ff[FRenderFlags][FRenderPostFx]; var savedZ3n = ff[FRenderFlags][FRenderFlagAux]; ff[FRenderFlags][FRenderShadow] = false; ff[FRenderFlags][FRenderPostFx] = false; ff[FRenderFlags][FRenderFlagAux] = false; // 禁用 UI 层 var savedUiQueues = {}; for (var ui = 0; ui < uiLayerIndices.length; ui++) { var li = uiLayerIndices[ui]; savedUiQueues[li] = ff[FRenderQueueList][li]; ff[FRenderQueueList][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[FRenderFlags][FRenderShadow] = savedX3n; ff[FRenderFlags][FRenderPostFx] = savedY3n; ff[FRenderFlags][FRenderFlagAux] = savedZ3n; for (var ui2 = 0; ui2 < uiLayerIndices.length; ui2++) { ff[FRenderQueueList][uiLayerIndices[ui2]] = savedUiQueues[uiLayerIndices[ui2]]; } // 恢复主相机 mainCamera[FCameraRotation][FVecX] = sPitch; mainCamera[FCameraRotation][FVecY] = sRoll; mainCamera[FCameraRotation][FVecZ] = sYaw; mainCamera[FNodeChildren][FVecX] = sX; mainCamera[FNodeChildren][FVecY] = sY; mainCamera[FNodeChildren][FVecZ] = sZ; mainCamera[FCameraRenderWidth] = sL36; mainCamera[FCameraRenderHeight] = sM36; mainCamera[FCameraFov] = sS36; mainCamera[FCameraNear] = sQ36; mainCamera[FCameraFar] = 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[FRenderDevice].o3c = origH3c; ff[FRenderDevice].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; } }; })();