🎮ArcadeLab

钢铁前线 1944 · 诺曼底

by GoldenRider11
4846 lines205.1 KB🛠️ Three.js (3D graphics)
▶ Play
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>钢铁前线 1944 · 诺曼底</title>
<style>
* { margin:0; padding:0; box-sizing:border-box; }
html,body { width:100%; height:100%; overflow:hidden; background:#000; font-family:"Segoe UI","Microsoft YaHei",sans-serif; user-select:none; }
canvas.game { display:block; }
.hidden { display:none !important; }

/* ============ HUD ============ */
#ui { position:fixed; inset:0; pointer-events:none; z-index:10; overflow:hidden; }
#crosshair { position:absolute; left:50%; top:50%; width:0; height:0; --gap:8px; }
#crosshair .chl { position:absolute; background:rgba(255,255,255,.92); box-shadow:0 0 3px rgba(0,0,0,.9); }
#crosshair .chl.t { left:-1px; bottom:var(--gap); width:2px; height:9px; }
#crosshair .chl.b { left:-1px; top:var(--gap); width:2px; height:9px; }
#crosshair .chl.l { top:-1px; right:var(--gap); height:2px; width:9px; }
#crosshair .chl.r { top:-1px; left:var(--gap); height:2px; width:9px; }
#crosshair .chdot { position:absolute; left:-1px; top:-1px; width:2px; height:2px; background:rgba(255,255,255,.95); }
#hitmark { position:absolute; left:50%; top:50%; width:0; height:0; opacity:0; }
#hitmark span { position:absolute; width:2px; height:10px; background:#fff; box-shadow:0 0 4px rgba(0,0,0,.8); }
#hitmark span:nth-child(1){ transform:translate(-8px,-14px) rotate(45deg); }
#hitmark span:nth-child(2){ transform:translate(6px,-14px) rotate(-45deg); }
#hitmark span:nth-child(3){ transform:translate(-8px,4px) rotate(-45deg); }
#hitmark span:nth-child(4){ transform:translate(6px,4px) rotate(45deg); }
#hitmark.kill span { background:#ff5343; }

#ammoPanel { position:absolute; right:26px; bottom:24px; color:#eee; text-align:right; text-shadow:0 1px 3px rgba(0,0,0,.9); }
#ammoPanel #wname { font-size:15px; letter-spacing:2px; color:#d8c894; }
#ammoPanel #wmode { font-size:11px; color:#9aa; letter-spacing:1px; margin-left:8px; }
#ammoPanel .ammoRow { font-size:40px; font-weight:700; line-height:1; margin-top:2px; }
#ammoPanel #resN { font-size:20px; color:#9a9a8a; font-weight:400; }
#ammoPanel #nadeN { font-size:14px; color:#bba; margin-top:3px; }

#healthPanel { position:absolute; left:26px; bottom:24px; width:230px; }
#hpNum { color:#eee; font-size:30px; font-weight:700; text-shadow:0 1px 3px rgba(0,0,0,.9); display:inline-block; min-width:56px; }
#hpBarWrap { display:inline-block; width:160px; height:9px; background:rgba(0,0,0,.55); border:1px solid rgba(255,255,255,.25); vertical-align:6px; }
#hpBar { height:100%; width:100%; background:#cfe0c0; transition:width .15s; }
#stamBarWrap { margin-top:5px; margin-left:62px; width:160px; height:4px; background:rgba(0,0,0,.5); }
#stamBar { height:100%; width:100%; background:rgba(220,220,190,.65); }

#minimapWrap { position:absolute; left:18px; top:18px; width:190px; height:190px; border:2px solid rgba(210,200,160,.35); border-radius:4px; background:rgba(10,14,8,.72); overflow:hidden; }
#minimap { width:100%; height:100%; }

#topBar { position:absolute; top:14px; left:50%; transform:translateX(-50%); text-align:center; color:#ddd; text-shadow:0 1px 3px #000; }
#tickets { font-size:17px; font-weight:600; letter-spacing:1px; }
#tickets .tl { color:#8fc1ff; } #tickets .tr { color:#ff9c8a; }
#flagIcons { margin-top:3px; }
#flagIcons span { display:inline-block; width:26px; height:26px; line-height:24px; margin:0 5px; border:2px solid #888; border-radius:50%; font-weight:700; font-size:14px; color:#ccc; background:rgba(0,0,0,.45); transition:all .3s; }
#flagIcons span.f0 { border-color:#6da5e8; color:#aecdf5; }
#flagIcons span.f1 { border-color:#e87a68; color:#f5b3a8; }
#flagIcons span.fc { animation:flagBlink 1s infinite; }
@keyframes flagBlink { 50% { background:rgba(255,255,255,.25); } }
#timeTxt { font-size:12px; color:#aaa; margin-top:2px; }
#compass { display:block; margin:4px auto 0; width:460px; height:26px; }

#killfeed { position:absolute; right:22px; top:18px; text-align:right; font-size:13px; color:#ddd; text-shadow:0 1px 2px #000; }
#killfeed div { margin-bottom:4px; background:rgba(0,0,0,.4); padding:3px 9px; border-radius:3px; opacity:1; transition:opacity 1s; }
#killfeed .kf0 { color:#9cc4f7; } #killfeed .kf1 { color:#f7a99c; }
#killfeed .me { color:#ffd77a; font-weight:700; }

#msgC { position:absolute; left:50%; top:26%; transform:translateX(-50%); font-size:24px; letter-spacing:3px; color:#f0e6c8; text-shadow:0 2px 8px #000; opacity:0; transition:opacity .4s; text-align:center; }
#scorePop { position:absolute; left:50%; top:58%; transform:translateX(-50%); font-size:17px; color:#ffd77a; text-shadow:0 1px 4px #000; opacity:0; }

#capPanel { position:absolute; left:50%; bottom:110px; transform:translateX(-50%); width:240px; text-align:center; color:#eee; text-shadow:0 1px 3px #000; display:none; }
#capLetter { font-size:26px; font-weight:700; }
#capBarWrap { width:100%; height:7px; background:rgba(0,0,0,.6); border:1px solid rgba(255,255,255,.3); margin-top:4px; }
#capBarFill { height:100%; width:0; background:#8fc1ff; transition:width .2s linear; }
#capTxt { font-size:12px; color:#ccc; margin-top:3px; }

#interact { position:absolute; left:50%; top:60%; transform:translateX(-50%); color:#fff; font-size:16px; text-shadow:0 1px 4px #000; background:rgba(0,0,0,.45); padding:6px 16px; border-radius:4px; display:none; }
#interact b { color:#ffd77a; }
#nadeWarn { position:absolute; left:50%; top:42%; transform:translateX(-50%); color:#ff4a3a; font-size:22px; font-weight:700; text-shadow:0 0 8px rgba(255,0,0,.6); display:none; animation:flagBlink .35s infinite; }
#heatWrap { position:absolute; left:50%; bottom:80px; transform:translateX(-50%); width:200px; height:6px; background:rgba(0,0,0,.6); border:1px solid rgba(255,255,255,.3); display:none; }
#heatFill { height:100%; width:0; background:linear-gradient(90deg,#e8c56a,#e83a1a); }

#dirHits { position:absolute; left:50%; top:50%; }
.dirHit { position:absolute; left:-90px; top:-90px; width:180px; height:180px; opacity:.85; pointer-events:none; }
.dirHit::before { content:""; position:absolute; left:50%; top:-6px; transform:translateX(-50%); border-left:16px solid transparent; border-right:16px solid transparent; border-bottom:22px solid rgba(255,40,20,.75); filter:blur(1px); }

#dmgOv { position:absolute; inset:0; background:radial-gradient(ellipse at center, transparent 40%, rgba(160,0,0,.55) 100%); opacity:0; }
#lowOv { position:absolute; inset:0; background:radial-gradient(ellipse at center, transparent 30%, rgba(120,0,0,.7) 100%); opacity:0; transition:opacity .5s; }
#supOv { position:absolute; inset:0; background:radial-gradient(ellipse at center, transparent 45%, rgba(60,60,60,.6) 100%); opacity:0; }
#vig { position:absolute; inset:0; background:radial-gradient(ellipse at center, transparent 55%, rgba(0,0,0,.38) 100%); }
#blackOv { position:absolute; inset:0; background:#000; opacity:0; transition:opacity .8s; }
#scopeOv { position:absolute; inset:0; display:none; background:radial-gradient(circle at center, transparent 0, transparent 31vmin, rgba(0,0,0,.985) 33vmin); }
#scopeOv::before { content:""; position:absolute; left:50%; top:0; width:1.5px; height:100%; background:rgba(0,0,0,.85); transform:translateX(-50%); }
#scopeOv::after { content:""; position:absolute; top:50%; left:0; height:1.5px; width:100%; background:rgba(0,0,0,.85); transform:translateY(-50%); }

#fps { position:absolute; right:8px; bottom:4px; color:rgba(255,255,255,.4); font-size:11px; }
#hint { position:absolute; left:50%; bottom:4px; transform:translateX(-50%); color:rgba(255,255,255,.35); font-size:11px; letter-spacing:1px; }

#scoreboard { position:absolute; left:50%; top:50%; transform:translate(-50%,-50%); width:760px; background:rgba(8,10,6,.88); border:1px solid rgba(210,200,160,.3); border-radius:6px; padding:18px 22px; color:#ddd; display:none; }
#scoreboard h3 { text-align:center; letter-spacing:4px; color:#d8c894; font-weight:600; margin-bottom:10px; }
#sbCols { display:flex; gap:20px; }
#sbCols > div { flex:1; }
#scoreboard table { width:100%; border-collapse:collapse; font-size:13px; }
#scoreboard th { text-align:left; color:#998; font-weight:500; border-bottom:1px solid #444; padding:3px 6px; }
#scoreboard td { padding:3px 6px; border-bottom:1px solid rgba(255,255,255,.06); }
#scoreboard .meRow { color:#ffd77a; font-weight:700; }
#scoreboard .t0h { color:#8fc1ff; } #scoreboard .t1h { color:#ff9c8a; }

/* ============ 界面屏 ============ */
.screen { position:fixed; inset:0; z-index:20; display:flex; align-items:center; justify-content:center; background:radial-gradient(ellipse at 50% 30%, rgba(40,48,34,.94), rgba(8,10,6,.97)); color:#ddd; }
.panel { width:860px; max-width:94vw; max-height:92vh; overflow:auto; padding:30px 40px; }
h1.title { font-size:52px; letter-spacing:10px; color:#e8dcb0; text-shadow:0 2px 12px #000, 0 0 40px rgba(200,170,90,.25); font-weight:800; text-align:center; }
.subtitle { text-align:center; color:#9a9478; letter-spacing:5px; margin:6px 0 26px; font-size:15px; }
.secLabel { color:#b8ae88; letter-spacing:3px; font-size:13px; margin:18px 0 8px; border-bottom:1px solid rgba(200,180,130,.2); padding-bottom:4px; }
.rowFlex { display:flex; gap:14px; flex-wrap:wrap; }
.bigBtn { pointer-events:auto; cursor:pointer; background:linear-gradient(180deg,#5a5236,#3a3422); color:#f0e6c0; border:1px solid #8a7c50; padding:14px 42px; font-size:20px; letter-spacing:6px; border-radius:4px; transition:all .15s; font-family:inherit; }
.bigBtn:hover { background:linear-gradient(180deg,#6e6544,#4a422c); box-shadow:0 0 18px rgba(220,190,110,.3); }
.bigBtn:disabled { opacity:.4; cursor:default; }
.optBtn { pointer-events:auto; cursor:pointer; background:rgba(255,255,255,.06); color:#ccc; border:1px solid #555; padding:9px 20px; font-size:14px; border-radius:3px; transition:all .12s; font-family:inherit; }
.optBtn:hover { border-color:#b8a86a; color:#fff; }
.optBtn.sel { background:rgba(200,175,95,.22); border-color:#d8c080; color:#ffe9b0; }
.teamBtn { flex:1; padding:16px; font-size:18px; letter-spacing:3px; }
.classCard { pointer-events:auto; cursor:pointer; flex:1; min-width:170px; background:rgba(255,255,255,.05); border:1px solid #555; border-radius:5px; padding:13px 15px; transition:all .12s; }
.classCard:hover { border-color:#b8a86a; }
.classCard.sel { background:rgba(200,175,95,.18); border-color:#e0ca8a; box-shadow:0 0 14px rgba(210,180,100,.25); }
.classCard .cname { font-size:17px; color:#f0e2b0; letter-spacing:2px; }
.classCard .cwpn { font-size:12px; color:#aaa; margin-top:5px; line-height:1.6; }
.spawnBtn { pointer-events:auto; cursor:pointer; display:block; width:100%; text-align:left; background:rgba(255,255,255,.05); color:#ccc; border:1px solid #555; padding:9px 14px; margin-bottom:7px; font-size:14px; border-radius:3px; font-family:inherit; }
.spawnBtn:hover { border-color:#b8a86a; }
.spawnBtn.sel { background:rgba(200,175,95,.2); border-color:#e0ca8a; color:#ffe9b0; }
.sliderRow { display:flex; align-items:center; gap:12px; margin:6px 0; font-size:14px; color:#bbb; }
.sliderRow input { pointer-events:auto; width:200px; }
.sliderRow .val { color:#e8dcb0; min-width:36px; }
#ctrlHelp { font-size:12.5px; color:#8a8878; line-height:1.9; column-count:2; margin-top:6px; }
#ctrlHelp b { color:#c5ba90; font-weight:600; }
#deployFlex { display:flex; gap:26px; }
#deployLeft { flex:1.35; } #deployRight { flex:1; }
#deployMap { width:100%; border:1px solid rgba(210,200,160,.3); border-radius:4px; background:#141a10; }
#deathInfo { text-align:center; color:#e88; font-size:16px; margin-bottom:14px; min-height:22px; }
#respawnTxt { color:#d8c894; }
#endTitle { font-size:46px; text-align:center; letter-spacing:8px; margin-bottom:8px; }
#endTitle.win { color:#ffe08a; text-shadow:0 0 30px rgba(255,210,110,.5); }
#endTitle.lose { color:#e86a5a; }
#endStats { text-align:center; font-size:16px; color:#bbb; line-height:2.1; margin:16px 0 26px; }
#endStats b { color:#f0e2b0; font-size:20px; }
.center { text-align:center; }
#menuVer { text-align:center; color:#555; font-size:11px; margin-top:20px; letter-spacing:2px; }
</style>
</head>
<body>
<div id="ui">
  <div id="crosshair"><span class="chl t"></span><span class="chl b"></span><span class="chl l"></span><span class="chl r"></span><span class="chdot"></span></div>
  <div id="hitmark"><span></span><span></span><span></span><span></span></div>
  <div id="dmgOv"></div><div id="supOv"></div><div id="lowOv"></div><div id="vig"></div>
  <div id="scopeOv"></div>
  <div id="dirHits"></div>
  <div id="ammoPanel"><span id="wname">M1 加兰德</span><span id="wmode">半自动</span><div class="ammoRow"><span id="magN">8</span> <span id="resN">/ 72</span></div><div id="nadeN">手雷 ×3</div></div>
  <div id="healthPanel"><span id="hpNum">100</span><span id="hpBarWrap"><span id="hpBar" style="display:block"></span></span><div id="stamBarWrap"><div id="stamBar"></div></div></div>
  <div id="minimapWrap"><canvas id="minimap" width="190" height="190"></canvas></div>
  <div id="topBar">
    <div id="tickets"><span class="tl">盟军 280</span> &nbsp;·&nbsp; <span class="tr">280 德军</span></div>
    <div id="flagIcons"><span id="fiA">A</span><span id="fiB">B</span><span id="fiC">C</span></div>
    <div id="timeTxt">15:00</div>
    <canvas id="compass" width="460" height="26"></canvas>
  </div>
  <div id="killfeed"></div>
  <div id="msgC"></div><div id="scorePop"></div>
  <div id="capPanel"><span id="capLetter">B</span><div id="capBarWrap"><div id="capBarFill"></div></div><div id="capTxt"></div></div>
  <div id="interact"></div>
  <div id="nadeWarn">⚠ 手雷 ⚠</div>
  <div id="heatWrap"><div id="heatFill"></div></div>
  <div id="scoreboard"><h3>战 场 记 分 板</h3><div id="sbCols"><div><table><thead><tr><th class="t0h">盟军</th><th>击杀</th><th>阵亡</th><th>得分</th></tr></thead><tbody id="sbL"></tbody></table></div><div><table><thead><tr><th class="t1h">德军</th><th>击杀</th><th>阵亡</th><th>得分</th></tr></thead><tbody id="sbR"></tbody></table></div></div></div>
  <div id="fps"></div>
  <div id="hint">WASD 移动 · 左键射击 · 右键瞄准 · R 换弹 · G 手雷 · 3 反坦克雷 · V 近战 · C 蹲 · Shift 疾跑 · E 载具/机枪/火炮/补给 · Tab 记分板</div>
  <div id="blackOv"></div>
</div>

<div id="menu" class="screen">
  <div class="panel">
    <h1 class="title">钢 铁 前 线</h1>
    <div class="subtitle">—— 1944 · 诺曼底 · 征服模式 ——</div>
    <div class="secLabel">选择阵营</div>
    <div class="rowFlex">
      <button class="optBtn teamBtn sel" id="teamUS">★ 盟军 · 美国陆军</button>
      <button class="optBtn teamBtn" id="teamGER">✠ 德军 · 国防军</button>
    </div>
    <div class="secLabel">BOT 难度 / 画质</div>
    <div class="rowFlex">
      <button class="optBtn diffBtn" data-d="0">新兵</button>
      <button class="optBtn diffBtn sel" data-d="1">老兵</button>
      <button class="optBtn diffBtn" data-d="2">精英</button>
      <span style="width:30px"></span>
      <button class="optBtn qualBtn" data-q="0">低画质</button>
      <button class="optBtn qualBtn sel" data-q="1">中画质</button>
      <button class="optBtn qualBtn" data-q="2">高画质</button>
    </div>
    <div class="secLabel">设置</div>
    <div class="sliderRow">鼠标灵敏度 <input type="range" id="sensRange" min="20" max="200" value="100"><span class="val" id="sensVal">1.0</span></div>
    <div class="sliderRow">音量 <input type="range" id="volRange" min="0" max="100" value="80"><span class="val" id="volVal">80</span></div>
    <div class="secLabel">键位</div>
    <div id="ctrlHelp">
      <div><b>WASD</b> 移动 · <b>Shift</b> 疾跑/屏息</div>
      <div><b>鼠标左键</b> 射击 · <b>右键</b> 机瞄</div>
      <div><b>R</b> 装填 · <b>1/2</b> 切换武器</div>
      <div><b>G</b> 投掷手雷 · <b>3</b> 反坦克雷(突击兵)</div>
      <div><b>V</b> 近战 · <b>C</b> 蹲伏 · <b>空格</b> 跳跃</div>
      <div><b>E</b> 坦克 / MG42 / 反坦克炮 / 防空炮 / 补给</div>
      <div><b>Tab</b> 记分板 · <b>Esc</b> 暂停</div>
      <div><b>占领 A/B/C 三点</b> 消耗敌军兵力</div>
    </div>
    <div class="center" style="margin-top:26px"><button class="bigBtn" id="startBtn">进 入 战 场</button></div>
    <div id="menuVer">STEEL FRONT 1944 · CONQUEST</div>
  </div>
</div>

<div id="deploy" class="screen hidden">
  <div class="panel">
    <h1 class="title" style="font-size:30px">部 署</h1>
    <div id="deathInfo"></div>
    <div id="deployFlex">
      <div id="deployLeft">
        <div class="secLabel">兵种</div>
        <div class="rowFlex" id="classGrid"></div>
        <div class="secLabel">出生点</div>
        <div id="spawnList"></div>
      </div>
      <div id="deployRight">
        <div class="secLabel">战场态势</div>
        <canvas id="deployMap" width="300" height="300"></canvas>
      </div>
    </div>
    <div class="center" style="margin-top:20px"><button class="bigBtn" id="deployBtn">部 署 <span id="respawnTxt"></span></button>
    <button class="bigBtn" id="resumeBtn" style="display:none">返 回 战 斗</button></div>
  </div>
</div>

<div id="end" class="screen hidden">
  <div class="panel">
    <div id="endTitle">胜 利</div>
    <div id="endStats"></div>
    <div class="center"><button class="bigBtn" id="againBtn">再 来 一 局</button></div>
  </div>
</div>

<script src="https://cdn.jsdelivr.net/npm/three@0.152.2/build/three.min.js"></script>
<script>
/* ================================================================
   钢铁前线 1944 —— 单文件二战FPS
   模块A:配置 / 工具 / 程序化音频引擎
================================================================ */
'use strict';
const V3 = (x=0,y=0,z=0)=>new THREE.Vector3(x,y,z);
const clamp=(v,a,b)=>v<a?a:(v>b?b:v);
const lerp=(a,b,t)=>a+(b-a)*t;
const rand=(a,b)=>a+Math.random()*(b-a);
const randi=(a,b)=>Math.floor(rand(a,b+1));
const TAU=Math.PI*2, HPI=Math.PI/2;
const dampF=(cur,tgt,k,dt)=>lerp(tgt,cur,Math.exp(-k*dt));

const SETTINGS = { team:0, diff:1, quality:1, sens:1.0, vol:0.8 };
const DIFF_TABLE = [
  { react:0.85, spreadMul:1.7, dmgMul:0.7, visMul:0.8, name:'新兵' },
  { react:0.5,  spreadMul:1.0, dmgMul:1.0, visMul:1.0, name:'老兵' },
  { react:0.28, spreadMul:0.6, dmgMul:1.25, visMul:1.25, name:'精英' },
];
const TEAM_NAME=['盟军','德军'];
const TEAM_COL=[0x8fc1ff,0xff9c8a];
const MAP_SIZE=260;            // 战场边长
const BOTS_PER_TEAM=9;         // 每队bot数(玩家队8bot+玩家)
const START_TICKETS=280;

const US_NAMES=['米勒','雷本','杰克逊','霍瓦特','梅利什','厄本','韦德','卡帕佐','德尔安科','麦克','唐尼','史密斯','布鲁尔','泰勒'];
const GER_NAMES=['施泰纳','穆勒','克格勒','汉森','冯·克劳克','贝克','施密特','里希特','沃尔夫','凯撒','布劳恩','菲舍尔','霍夫曼','克鲁格'];

/* ---------------- 程序化音频引擎 ---------------- */
const AudioSys = (() => {
  let ctx=null, master=null, comp=null, noiseBuf=null;
  let inited=false;
  function init(){
    if(inited) return; inited=true;
    ctx = new (window.AudioContext||window.webkitAudioContext)();
    comp = ctx.createDynamicsCompressor();
    comp.threshold.value=-18; comp.ratio.value=6; comp.attack.value=0.002; comp.release.value=0.2;
    master = ctx.createGain(); master.gain.value = SETTINGS.vol;
    comp.connect(master); master.connect(ctx.destination);
    const len = ctx.sampleRate*2;
    noiseBuf = ctx.createBuffer(1,len,ctx.sampleRate);
    const d = noiseBuf.getChannelData(0);
    for(let i=0;i<len;i++) d[i]=Math.random()*2-1;
    startAmbient();
  }
  function setVol(v){ if(master) master.gain.value=v; }
  function resume(){ if(ctx&&ctx.state==='suspended') ctx.resume(); }
  function noiseSrc(){ const s=ctx.createBufferSource(); s.buffer=noiseBuf; s.loop=true; return s; }
  // 空间衰减:根据距离计算增益与低通
  function spatial(dist, maxD=180){
    const g = clamp(1-dist/maxD,0,1);
    return { gain: g*g, lp: lerp(600, 12000, g) };
  }
  function env(g, t0, peak, dec, sus=0.0001){
    g.gain.setValueAtTime(0.0001,t0);
    g.gain.linearRampToValueAtTime(peak, t0+0.004);
    g.gain.exponentialRampToValueAtTime(Math.max(sus,0.0001), t0+dec);
  }
  /* 枪声合成:body低频冲击 + crack高频 + 尾音混响 */
  function gunshot(type, dist=0, pan=0){
    if(!inited) return; resume();
    const t0=ctx.currentTime;
    const sp = spatial(dist, 260);
    if(sp.gain<0.02) return;
    const P = {
      rifle:   { bf:120, bd:0.22, cf:3000, cd:0.09, v:1.0, tail:0.5 },
      sniper:  { bf:90,  bd:0.3,  cf:2600, cd:0.12, v:1.2, tail:0.65 },
      smg:     { bf:160, bd:0.12, cf:3400, cd:0.05, v:0.72, tail:0.3 },
      pistol:  { bf:200, bd:0.1,  cf:3800, cd:0.05, v:0.6, tail:0.25 },
      mg:      { bf:110, bd:0.16, cf:2800, cd:0.07, v:1.0, tail:0.42 },
    }[type]||{bf:140,bd:0.15,cf:3200,cd:0.07,v:0.8,tail:0.35};
    const panN = ctx.createStereoPanner ? ctx.createStereoPanner() : null;
    const out = ctx.createGain(); out.gain.value=1;
    const lpO = ctx.createBiquadFilter(); lpO.type='lowpass'; lpO.frequency.value=sp.lp;
    out.connect(lpO);
    if(panN){ panN.pan.value=clamp(pan,-1,1); lpO.connect(panN); panN.connect(comp); } else lpO.connect(comp);
    // body
    const o=ctx.createOscillator(); o.type='triangle';
    o.frequency.setValueAtTime(P.bf,t0); o.frequency.exponentialRampToValueAtTime(40,t0+P.bd);
    const og=ctx.createGain(); env(og,t0,P.v*0.9*sp.gain,P.bd);
    o.connect(og); og.connect(out); o.start(t0); o.stop(t0+P.bd+0.05);
    // crack
    const n=noiseSrc(); const bp=ctx.createBiquadFilter(); bp.type='bandpass'; bp.frequency.value=P.cf; bp.Q.value=0.8;
    const ng=ctx.createGain(); env(ng,t0,P.v*0.75*sp.gain,P.cd);
    n.connect(bp); bp.connect(ng); ng.connect(out); n.start(t0); n.stop(t0+P.cd+0.05);
    // 远处回声尾音
    if(dist>25){
      const n2=noiseSrc(); const lp=ctx.createBiquadFilter(); lp.type='lowpass'; lp.frequency.value=700;
      const g2=ctx.createGain();
      g2.gain.setValueAtTime(0.0001,t0+0.03);
      g2.gain.linearRampToValueAtTime(P.v*0.3*sp.gain,t0+0.06);
      g2.gain.exponentialRampToValueAtTime(0.0001,t0+P.tail+dist/300);
      n2.connect(lp); lp.connect(g2); g2.connect(out); n2.start(t0); n2.stop(t0+P.tail+dist/300+0.1);
    }
  }
  function explosion(dist=0){
    if(!inited) return;
    const t0=ctx.currentTime; const sp=spatial(dist,400); if(sp.gain<0.02) return;
    const o=ctx.createOscillator(); o.type='sine';
    o.frequency.setValueAtTime(70,t0); o.frequency.exponentialRampToValueAtTime(24,t0+1.1);
    const og=ctx.createGain(); env(og,t0,1.5*sp.gain,1.1);
    o.connect(og); og.connect(comp); o.start(t0); o.stop(t0+1.2);
    const n=noiseSrc(); const lp=ctx.createBiquadFilter(); lp.type='lowpass';
    lp.frequency.setValueAtTime(Math.min(sp.lp,5000),t0); lp.frequency.exponentialRampToValueAtTime(120,t0+1.3);
    const ng=ctx.createGain(); env(ng,t0,1.3*sp.gain,1.3);
    n.connect(lp); lp.connect(ng); ng.connect(comp); n.start(t0); n.stop(t0+1.4);
  }
  /* 机械音:换弹咔哒/拉栓等 */
  function click(freq=2200, vol=0.25, dur=0.03){
    if(!inited) return;
    const t0=ctx.currentTime;
    const n=noiseSrc(); const bp=ctx.createBiquadFilter(); bp.type='bandpass'; bp.frequency.value=freq; bp.Q.value=2.5;
    const g=ctx.createGain(); env(g,t0,vol,dur+0.04);
    n.connect(bp); bp.connect(g); g.connect(comp); n.start(t0); n.stop(t0+dur+0.08);
  }
  function metalSlide(vol=0.2,dur=0.12,f0=900,f1=1600){
    if(!inited) return;
    const t0=ctx.currentTime;
    const n=noiseSrc(); const bp=ctx.createBiquadFilter(); bp.type='bandpass'; bp.Q.value=1.5;
    bp.frequency.setValueAtTime(f0,t0); bp.frequency.linearRampToValueAtTime(f1,t0+dur);
    const g=ctx.createGain();
    g.gain.setValueAtTime(0.0001,t0); g.gain.linearRampToValueAtTime(vol,t0+dur*0.3);
    g.gain.exponentialRampToValueAtTime(0.0001,t0+dur+0.03);
    n.connect(bp); bp.connect(g); g.connect(comp); n.start(t0); n.stop(t0+dur+0.06);
  }
  function ping(){ // 加兰德弹夹ping
    if(!inited) return;
    const t0=ctx.currentTime;
    [5200,7800].forEach((f,i)=>{
      const o=ctx.createOscillator(); o.type='sine'; o.frequency.value=f*rand(0.98,1.02);
      const g=ctx.createGain(); env(g,t0,0.16/(i+1),0.5);
      o.connect(g); g.connect(comp); o.start(t0); o.stop(t0+0.55);
    });
  }
  function footstep(run=false, dist=0){
    if(!inited) return;
    const sp=spatial(dist,40); if(sp.gain<0.03) return;
    const t0=ctx.currentTime;
    const n=noiseSrc(); const lp=ctx.createBiquadFilter(); lp.type='lowpass'; lp.frequency.value=run?500:380;
    const g=ctx.createGain(); env(g,t0,(run?0.2:0.11)*sp.gain, 0.07);
    n.connect(lp); lp.connect(g); g.connect(comp); n.start(t0); n.stop(t0+0.12);
  }
  function whizz(){ // 子弹掠过
    if(!inited) return;
    const t0=ctx.currentTime;
    const n=noiseSrc(); const bp=ctx.createBiquadFilter(); bp.type='bandpass'; bp.Q.value=4;
    bp.frequency.setValueAtTime(4200,t0); bp.frequency.exponentialRampToValueAtTime(900,t0+0.16);
    const g=ctx.createGain();
    g.gain.setValueAtTime(0.0001,t0); g.gain.linearRampToValueAtTime(0.22,t0+0.03);
    g.gain.exponentialRampToValueAtTime(0.0001,t0+0.17);
    n.connect(bp); bp.connect(g); g.connect(comp); n.start(t0); n.stop(t0+0.2);
  }
  function ricochet(dist){
    if(!inited) return;
    const sp=spatial(dist,80); if(sp.gain<0.03) return;
    const t0=ctx.currentTime;
    const o=ctx.createOscillator(); o.type='sine';
    o.frequency.setValueAtTime(rand(2400,3600),t0);
    o.frequency.exponentialRampToValueAtTime(rand(700,1100),t0+0.22);
    const g=ctx.createGain(); env(g,t0,0.1*sp.gain,0.22);
    o.connect(g); g.connect(comp); o.start(t0); o.stop(t0+0.25);
  }
  function hurt(){
    if(!inited) return;
    const t0=ctx.currentTime;
    const o=ctx.createOscillator(); o.type='sawtooth';
    o.frequency.setValueAtTime(220,t0); o.frequency.exponentialRampToValueAtTime(90,t0+0.15);
    const g=ctx.createGain(); env(g,t0,0.2,0.15);
    o.connect(g); g.connect(comp); o.start(t0); o.stop(t0+0.18);
  }
  function hitmarkSnd(kill){
    if(!inited) return;
    const t0=ctx.currentTime;
    const o=ctx.createOscillator(); o.type='square'; o.frequency.value=kill?520:820;
    const g=ctx.createGain(); env(g,t0,0.12,kill?0.14:0.05);
    o.connect(g); g.connect(comp); o.start(t0); o.stop(t0+0.16);
  }
  function nadeBounce(dist){
    if(!inited) return;
    const sp=spatial(dist,60); if(sp.gain<0.05) return;
    click(600,0.3*sp.gain,0.04);
  }
  /* 坦克主炮 */
  function cannon(dist=0,pan=0){
    if(!inited) return; resume();
    const t0=ctx.currentTime;
    const sp=spatial(dist,500); if(sp.gain<0.02) return;
    const o=ctx.createOscillator(); o.type='sine';
    o.frequency.setValueAtTime(85,t0); o.frequency.exponentialRampToValueAtTime(26,t0+0.7);
    const og=ctx.createGain(); env(og,t0,1.6*sp.gain,0.7);
    o.connect(og); og.connect(comp); o.start(t0); o.stop(t0+0.8);
    const n=noiseSrc(); const lp=ctx.createBiquadFilter(); lp.type='lowpass';
    lp.frequency.setValueAtTime(Math.min(sp.lp,3800),t0); lp.frequency.exponentialRampToValueAtTime(160,t0+0.9);
    const ng=ctx.createGain(); env(ng,t0,1.2*sp.gain,0.9);
    n.connect(lp); lp.connect(ng); ng.connect(comp); n.start(t0); n.stop(t0+1);
  }
  /* 高射炮爆炸(空中闷响) */
  function flakPop(dist=0){
    if(!inited) return;
    const t0=ctx.currentTime;
    const sp=spatial(dist,300); if(sp.gain<0.02) return;
    const n=noiseSrc(); const lp=ctx.createBiquadFilter(); lp.type='lowpass'; lp.frequency.value=Math.min(sp.lp,1500);
    const g=ctx.createGain(); env(g,t0,0.55*sp.gain,0.35);
    n.connect(lp); lp.connect(g); g.connect(comp); n.start(t0); n.stop(t0+0.4);
  }
  /* 载具引擎循环音 */
  function createEngine(kind){
    const e={ nodes:null, kind };
    e.update=(freq,vol)=>{
      if(!inited){ return; }
      if(!e.nodes){
        const osc=ctx.createOscillator();
        osc.type=kind==='plane'?'sawtooth':'triangle';
        const osc2=ctx.createOscillator();
        osc2.type='sawtooth'; osc2.detune.value=kind==='plane'?18:8;
        const lp=ctx.createBiquadFilter(); lp.type='lowpass'; lp.frequency.value=kind==='plane'?900:340;
        const g=ctx.createGain(); g.gain.value=0;
        osc.connect(lp); osc2.connect(lp); lp.connect(g); g.connect(comp);
        osc.start(); osc2.start();
        e.nodes={osc,osc2,g,lp};
      }
      const t0=ctx.currentTime;
      e.nodes.osc.frequency.setTargetAtTime(freq,t0,0.12);
      e.nodes.osc2.frequency.setTargetAtTime(freq*0.5,t0,0.12);
      e.nodes.g.gain.setTargetAtTime(clamp(vol,0,0.4),t0,0.12);
    };
    e.stop=()=>{ if(e.nodes){ try{ e.nodes.g.gain.setTargetAtTime(0,ctx.currentTime,0.1); }catch(err){} } };
    return e;
  }
  function startAmbient(){
    // 风声
    const n=noiseSrc(); const lp=ctx.createBiquadFilter(); lp.type='lowpass'; lp.frequency.value=280;
    const g=ctx.createGain(); g.gain.value=0.045;
    const lfo=ctx.createOscillator(); lfo.frequency.value=0.13;
    const lg=ctx.createGain(); lg.gain.value=90;
    lfo.connect(lg); lg.connect(lp.frequency);
    n.connect(lp); lp.connect(g); g.connect(comp); n.start(); lfo.start();
    // 远处炮火隆隆
    setInterval(()=>{ if(Math.random()<0.4) explosion(rand(240,380)); }, 4200);
  }
  return { init, gunshot, explosion, click, metalSlide, ping, footstep, whizz, ricochet, hurt, hitmarkSnd, nadeBounce, cannon, flakPop, createEngine, setVol, resume };
})();

/* ================================================================
   模块B:渲染器 / 程序化纹理 / 世界构建 / 碰撞 / 导航
================================================================ */
const renderer = new THREE.WebGLRenderer({ antialias:true, powerPreference:'high-performance' });
renderer.setSize(innerWidth, innerHeight);
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.05;
renderer.autoClear = false;
renderer.domElement.className='game';
document.body.appendChild(renderer.domElement);

const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(74, innerWidth/innerHeight, 0.08, 900);
const vmScene = new THREE.Scene();
const vmCamera = new THREE.PerspectiveCamera(56, innerWidth/innerHeight, 0.01, 10);

function applyQuality(){
  const q = SETTINGS.quality;
  renderer.setPixelRatio(q===0?Math.min(devicePixelRatio,1):(q===1?Math.min(devicePixelRatio,1.5):Math.min(devicePixelRatio,2)));
  sun.shadow.mapSize.setScalar(q===0?1024:(q===1?2048:4096));
  if(sun.shadow.map){ sun.shadow.map.dispose(); sun.shadow.map=null; }
  scene.fog.far = q===0?260:(q===1?340:420);
}
addEventListener('resize',()=>{
  camera.aspect=innerWidth/innerHeight; camera.updateProjectionMatrix();
  vmCamera.aspect=camera.aspect; vmCamera.updateProjectionMatrix();
  renderer.setSize(innerWidth,innerHeight);
});

/* ---------- 天空 / 光照 ---------- */
scene.fog = new THREE.Fog(0xb8c4bc, 40, 340);
{
  const c=document.createElement('canvas'); c.width=16; c.height=256;
  const g=c.getContext('2d');
  const gr=g.createLinearGradient(0,0,0,256);
  gr.addColorStop(0,'#5e7ca0'); gr.addColorStop(0.42,'#93aec0');
  gr.addColorStop(0.62,'#c3cdc2'); gr.addColorStop(0.75,'#d8d3b8'); gr.addColorStop(1,'#c9c3a4');
  g.fillStyle=gr; g.fillRect(0,0,16,256);
  const tex=new THREE.CanvasTexture(c); tex.colorSpace=THREE.SRGBColorSpace;
  const sky=new THREE.Mesh(new THREE.SphereGeometry(800,24,16), new THREE.MeshBasicMaterial({map:tex,side:THREE.BackSide,fog:false}));
  scene.add(sky);
}
const hemi = new THREE.HemisphereLight(0xcfd8e8, 0x5a5844, 0.75);
scene.add(hemi);
const sun = new THREE.DirectionalLight(0xffeed0, 2.0);
sun.position.set(-90, 120, 40);
sun.castShadow = true;
sun.shadow.camera.left=-150; sun.shadow.camera.right=150;
sun.shadow.camera.top=150; sun.shadow.camera.bottom=-150;
sun.shadow.camera.far=400; sun.shadow.bias=-0.0012; sun.shadow.normalBias=0.02;
scene.add(sun); scene.add(sun.target);
// 太阳光晕
{
  const c=document.createElement('canvas'); c.width=128; c.height=128;
  const g=c.getContext('2d');
  const gr=g.createRadialGradient(64,64,2,64,64,64);
  gr.addColorStop(0,'rgba(255,250,230,1)'); gr.addColorStop(0.25,'rgba(255,240,200,.55)'); gr.addColorStop(1,'rgba(255,240,200,0)');
  g.fillStyle=gr; g.fillRect(0,0,128,128);
  const sp=new THREE.Sprite(new THREE.SpriteMaterial({map:new THREE.CanvasTexture(c),fog:false,depthWrite:false}));
  sp.position.set(-380,480,170); sp.scale.setScalar(220); scene.add(sp);
}
// 云
{
  const c=document.createElement('canvas'); c.width=256; c.height=128;
  const g=c.getContext('2d');
  for(let i=0;i<46;i++){
    const x=rand(30,226),y=rand(40,90),r=rand(14,34);
    const gr=g.createRadialGradient(x,y,1,x,y,r);
    gr.addColorStop(0,'rgba(255,255,255,.22)'); gr.addColorStop(1,'rgba(255,255,255,0)');
    g.fillStyle=gr; g.fillRect(0,0,256,128);
  }
  const tex=new THREE.CanvasTexture(c);
  for(let i=0;i<9;i++){
    const sp=new THREE.Sprite(new THREE.SpriteMaterial({map:tex,transparent:true,opacity:rand(.5,.85),fog:false,depthWrite:false}));
    sp.position.set(rand(-600,600), rand(180,280), rand(-600,600));
    sp.scale.set(rand(180,340),rand(60,110),1);
    scene.add(sp);
  }
}
vmScene.add(new THREE.HemisphereLight(0xcfd8e8, 0x5a5844, 0.9));
{ const l=new THREE.DirectionalLight(0xffeed0,1.6); l.position.set(-1,1.6,0.6); vmScene.add(l); }

/* ---------- 程序化纹理工厂 ---------- */
function makeTex(w,h,fn,repX=1,repY=1){
  const c=document.createElement('canvas'); c.width=w; c.height=h;
  fn(c.getContext('2d'),w,h);
  const t=new THREE.CanvasTexture(c);
  t.wrapS=t.wrapT=THREE.RepeatWrapping; t.repeat.set(repX,repY);
  t.colorSpace=THREE.SRGBColorSpace; t.anisotropy=4;
  return t;
}
function speckle(g,w,h,n,c0,c1,smin,smax){
  for(let i=0;i<n;i++){
    g.fillStyle=Math.random()<.5?c0:c1;
    g.globalAlpha=rand(.08,.4);
    const s=rand(smin,smax);
    g.fillRect(rand(0,w),rand(0,h),s,s);
  }
  g.globalAlpha=1;
}
const TEX = {};
TEX.grass = makeTex(256,256,(g,w,h)=>{
  g.fillStyle='#6b7a48'; g.fillRect(0,0,w,h);
  speckle(g,w,h,2600,'#7d8c52','#55643a',1,3);
  speckle(g,w,h,500,'#8a9458','#4a5834',1,2);
  for(let i=0;i<160;i++){ g.strokeStyle='rgba(120,138,80,.35)'; g.beginPath(); const x=rand(0,w),y=rand(0,h); g.moveTo(x,y); g.lineTo(x+rand(-3,3),y-rand(2,6)); g.stroke(); }
},40,40);
TEX.dirt = makeTex(256,256,(g,w,h)=>{
  g.fillStyle='#7a6a4e'; g.fillRect(0,0,w,h);
  speckle(g,w,h,2200,'#8a7a5a','#655640',1,4);
  speckle(g,w,h,300,'#93835f','#54462f',2,5);
},8,8);
TEX.brick = makeTex(256,256,(g,w,h)=>{
  g.fillStyle='#8a5a44'; g.fillRect(0,0,w,h);
  const bh=20,bw=42;
  for(let y=0;y<h;y+=bh){
    const off=((y/bh)|0)%2? bw/2:0;
    for(let x=-bw;x<w;x+=bw){
      g.fillStyle=`rgb(${randi(120,155)},${randi(75,95)},${randi(55,70)})`;
      g.fillRect(x+off+1,y+1,bw-2,bh-2);
    }
  }
  speckle(g,w,h,900,'#6a4634','#9a7055',1,3);
},2,1.4);
TEX.plaster = makeTex(256,256,(g,w,h)=>{
  g.fillStyle='#b0a891'; g.fillRect(0,0,w,h);
  speckle(g,w,h,1800,'#bcb49c','#9a9078',1,4);
  // 破损露砖
  for(let i=0;i<5;i++){
    const x=rand(0,w),y=rand(h*0.5,h),r=rand(12,34);
    g.fillStyle='#7c5240'; g.globalAlpha=.55;
    g.beginPath(); g.ellipse(x,y,r,r*0.7,rand(0,3),0,TAU); g.fill(); g.globalAlpha=1;
  }
  for(let i=0;i<7;i++){ g.strokeStyle='rgba(70,60,50,.3)'; g.beginPath(); let x=rand(0,w),y=rand(0,h); g.moveTo(x,y); for(let k=0;k<4;k++){ x+=rand(-18,18); y+=rand(6,20); g.lineTo(x,y);} g.stroke(); }
},2,1.5);
TEX.stone = makeTex(256,256,(g,w,h)=>{
  g.fillStyle='#8d8a80'; g.fillRect(0,0,w,h);
  for(let y=0;y<h;y+=26){
    const off=((y/26)|0)%2?30:0;
    for(let x=-60;x<w;x+=60){
      g.fillStyle=`rgb(${randi(125,155)},${randi(122,150)},${randi(112,138)})`;
      g.fillRect(x+off+2,y+2,56,22);
    }
  }
  speckle(g,w,h,900,'#6d6a60','#a5a296',1,3);
},2,1.5);
TEX.wood = makeTex(256,256,(g,w,h)=>{
  g.fillStyle='#6e5638'; g.fillRect(0,0,w,h);
  for(let x=0;x<w;x+=32){
    g.fillStyle=`rgb(${randi(95,125)},${randi(72,92)},${randi(46,62)})`;
    g.fillRect(x+1,0,30,h);
    g.strokeStyle='rgba(50,36,20,.5)';
    for(let i=0;i<4;i++){ g.beginPath(); const yy=rand(0,h); g.moveTo(x,yy); g.bezierCurveTo(x+10,yy+rand(-6,6),x+20,yy+rand(-6,6),x+32,yy+rand(-8,8)); g.stroke(); }
  }
  speckle(g,w,h,350,'#54402a','#84684a',1,2);
},1.5,1.5);
TEX.woodDark = makeTex(128,128,(g,w,h)=>{
  g.fillStyle='#4a3a24'; g.fillRect(0,0,w,h);
  for(let i=0;i<26;i++){ g.strokeStyle=`rgba(${randi(30,60)},${randi(24,44)},${randi(12,26)},.6)`; g.beginPath(); g.moveTo(0,rand(0,h)); g.bezierCurveTo(w*.3,rand(0,h),w*.7,rand(0,h),w,rand(0,h)); g.stroke(); }
},1,1);
TEX.sandbag = makeTex(128,128,(g,w,h)=>{
  g.fillStyle='#9a8a62'; g.fillRect(0,0,w,h);
  for(let y=0;y<h;y+=22){
    const off=((y/22)|0)%2?22:0;
    for(let x=-44;x<w;x+=44){
      g.fillStyle=`rgb(${randi(140,165)},${randi(125,145)},${randi(88,105)})`;
      g.beginPath(); g.ellipse(x+off+22,y+11,20,9,0,0,TAU); g.fill();
      g.strokeStyle='rgba(80,70,45,.6)'; g.stroke();
    }
  }
},2,2);
TEX.metal = makeTex(128,128,(g,w,h)=>{
  g.fillStyle='#5a5f56'; g.fillRect(0,0,w,h);
  speckle(g,w,h,700,'#6a7062','#464b42',1,3);
  speckle(g,w,h,120,'#7d6a4a','#3d3428',1,4); // 锈迹
},1,1);
TEX.bark = makeTex(128,128,(g,w,h)=>{
  g.fillStyle='#5d4c36'; g.fillRect(0,0,w,h);
  for(let x=0;x<w;x+=8){ g.fillStyle=`rgba(${randi(60,90)},${randi(48,70)},${randi(32,48)},.8)`; g.fillRect(x,0,rand(3,6),h); }
  speckle(g,w,h,300,'#3f3222','#75604a',1,3);
},1,2);
TEX.leaves = makeTex(128,128,(g,w,h)=>{
  g.clearRect(0,0,w,h);
  for(let i=0;i<450;i++){
    g.fillStyle=`rgba(${randi(60,95)},${randi(85,120)},${randi(40,60)},${rand(.5,1)})`;
    const x=rand(0,w),y=rand(0,h);
    const d=Math.hypot(x-64,y-64); if(d>60) continue;
    g.beginPath(); g.ellipse(x,y,rand(3,7),rand(2,5),rand(0,3),0,TAU); g.fill();
  }
});
TEX.roof = makeTex(256,256,(g,w,h)=>{
  g.fillStyle='#6e4438'; g.fillRect(0,0,w,h);
  for(let y=0;y<h;y+=24){
    for(let x=0;x<w;x+=30){
      g.fillStyle=`rgb(${randi(100,130)},${randi(58,74)},${randi(46,60)})`;
      g.fillRect(x+1,y+1,28,22);
    }
  }
  speckle(g,w,h,500,'#4f2f26','#8a5a48',1,3);
},2,2);
TEX.hay = makeTex(128,128,(g,w,h)=>{
  g.fillStyle='#b09a58'; g.fillRect(0,0,w,h);
  for(let i=0;i<400;i++){ g.strokeStyle=`rgba(${randi(150,190)},${randi(130,160)},${randi(70,95)},.7)`; g.beginPath(); const x=rand(0,w),y=rand(0,h); g.moveTo(x,y); g.lineTo(x+rand(-9,9),y+rand(-3,3)); g.stroke(); }
},2,2);

const MAT = {
  grass: new THREE.MeshLambertMaterial({map:TEX.grass, vertexColors:true}),
  brick: new THREE.MeshLambertMaterial({map:TEX.brick}),
  plaster: new THREE.MeshLambertMaterial({map:TEX.plaster}),
  stone: new THREE.MeshLambertMaterial({map:TEX.stone}),
  wood: new THREE.MeshLambertMaterial({map:TEX.wood}),
  woodDark: new THREE.MeshLambertMaterial({map:TEX.woodDark}),
  sandbag: new THREE.MeshLambertMaterial({map:TEX.sandbag}),
  metal: new THREE.MeshLambertMaterial({map:TEX.metal}),
  metalDark: new THREE.MeshLambertMaterial({color:0x3a3d38}),
  bark: new THREE.MeshLambertMaterial({map:TEX.bark}),
  leaves: new THREE.MeshLambertMaterial({map:TEX.leaves, transparent:true, alphaTest:0.5, side:THREE.DoubleSide}),
  roof: new THREE.MeshLambertMaterial({map:TEX.roof}),
  hay: new THREE.MeshLambertMaterial({map:TEX.hay}),
  rubble: new THREE.MeshLambertMaterial({color:0x8a8378}),
};

/* ---------- 地形 ---------- */
const FLATS = [
  {x:0,z:0,r:34,h:0.4},      // B 村庄
  {x:-70,z:22,r:26,h:0.6},   // A 农场
  {x:72,z:-26,r:28,h:0.3},   // C 废墟
  {x:-118,z:0,r:20,h:0.5},   // 美军基地
  {x:118,z:0,r:20,h:0.5},    // 德军基地
];
function rawHeight(x,z){
  return 1.4*Math.sin(x*0.021)*Math.cos(z*0.024)
       + 0.9*Math.sin(x*0.052+1.7)*Math.sin(z*0.043+0.6)
       + 0.5*Math.sin(x*0.11+3.1)*Math.cos(z*0.09+1.2);
}
function heightAt(x,z){
  let h=rawHeight(x,z);
  for(const f of FLATS){
    const d=Math.hypot(x-f.x,z-f.z);
    if(d<f.r){
      const t=1-d/f.r;
      const s=t*t*(3-2*t);
      h=lerp(h,f.h,s);
    }
  }
  return h;
}
// 道路路径(用于地形着色): 主路沿x轴 z≈0, 支路到A和C
function roadDist(x,z){
  let d=Math.abs(z - 3*Math.sin(x*0.02));              // 主路
  const dA=segDist(x,z,-70,22,-52,8), dC=segDist(x,z,72,-26,52,-8);
  d=Math.min(d, dA, dC);
  return d;
}
function segDist(px,pz,ax,az,bx,bz){
  const abx=bx-ax,abz=bz-az;
  const t=clamp(((px-ax)*abx+(pz-az)*abz)/(abx*abx+abz*abz),0,1);
  return Math.hypot(px-(ax+abx*t), pz-(az+abz*t));
}
{
  const seg=130;
  const geo=new THREE.PlaneGeometry(MAP_SIZE+40,MAP_SIZE+40,seg,seg);
  geo.rotateX(-HPI);
  const pos=geo.attributes.position;
  const cols=new Float32Array(pos.count*3);
  for(let i=0;i<pos.count;i++){
    const x=pos.getX(i),z=pos.getZ(i);
    pos.setY(i, heightAt(x,z));
    const rd=roadDist(x,z);
    let r=1,g=1,b=1;
    if(rd<5){ const t=1-rd/5; r=lerp(1,1.16,t); g=lerp(1,0.94,t); b=lerp(1,0.72,t); } // 泥土路
    const n=Math.sin(x*0.4)*Math.cos(z*0.37);
    r*=1+n*0.05; g*=1+n*0.05; b*=1+n*0.04;
    cols[i*3]=r; cols[i*3+1]=g; cols[i*3+2]=b;
  }
  geo.setAttribute('color', new THREE.BufferAttribute(cols,3));
  geo.computeVertexNormals();
  const ground=new THREE.Mesh(geo, MAT.grass);
  ground.receiveShadow=true;
  scene.add(ground);
}

/* ---------- 碰撞系统 ---------- */
const BOXES=[];      // {minX,minY,minZ,maxX,maxY,maxZ}
const CYLS=[];       // {x,z,r,y0,y1}
const SHOOTABLES=[]; // 视觉命中用(含地形以外全部)
function addBoxCollider(cx,cy,cz,w,h,d){
  const b={minX:cx-w/2,minY:cy-h/2,minZ:cz-d/2,maxX:cx+w/2,maxY:cy+h/2,maxZ:cz+d/2};
  BOXES.push(b); return b;
}
function rayBox(o,dir,b,maxD){
  let tmin=0,tmax=maxD, n=[0,0,0];
  const oo=[o.x,o.y,o.z], dd=[dir.x,dir.y,dir.z];
  const mn=[b.minX,b.minY,b.minZ], mx=[b.maxX,b.maxY,b.maxZ];
  let axis=-1, sign=0;
  for(let i=0;i<3;i++){
    if(Math.abs(dd[i])<1e-9){
      if(oo[i]<mn[i]||oo[i]>mx[i]) return null;
    } else {
      const inv=1/dd[i];
      let t1=(mn[i]-oo[i])*inv, t2=(mx[i]-oo[i])*inv;
      let s=-1;
      if(t1>t2){ const tmp=t1; t1=t2; t2=tmp; s=1; }
      if(t1>tmin){ tmin=t1; axis=i; sign=s; }
      if(t2<tmax) tmax=t2;
      if(tmin>tmax) return null;
    }
  }
  if(tmin<=0.0001) return null;
  n[axis]=sign;
  return { t:tmin, nx:n[0], ny:n[1], nz:n[2] };
}
function rayCyl(o,dir,c,maxD){
  const ox=o.x-c.x, oz=o.z-c.z;
  const a=dir.x*dir.x+dir.z*dir.z;
  if(a<1e-9) return null;
  const b2=2*(ox*dir.x+oz*dir.z);
  const cc=ox*ox+oz*oz-c.r*c.r;
  const disc=b2*b2-4*a*cc;
  if(disc<0) return null;
  const t=(-b2-Math.sqrt(disc))/(2*a);
  if(t<0.0001||t>maxD) return null;
  const y=o.y+dir.y*t;
  if(y<c.y0||y>c.y1) return null;
  const px=o.x+dir.x*t, pz=o.z+dir.z*t;
  const nx=(px-c.x)/c.r, nz=(pz-c.z)/c.r;
  return { t, nx, ny:0, nz };
}
/* ---------- 静态占用栅格(供寻路/视线用, O(1)查询) ---------- */
const OCC={ res:1, N:0, off:(MAP_SIZE+40)/2, data:null };
function buildOccupancy(){
  OCC.N=Math.ceil((MAP_SIZE+40)/OCC.res)+1;
  const {res,N,off}=OCC;
  const d=new Uint8Array(N*N);
  const pad=0.6;
  const mark=(x0,z0,x1,z1,test)=>{
    const i0=clamp(Math.floor((x0+off)/res),0,N-1), i1=clamp(Math.ceil((x1+off)/res),0,N-1);
    const j0=clamp(Math.floor((z0+off)/res),0,N-1), j1=clamp(Math.ceil((z1+off)/res),0,N-1);
    for(let j=j0;j<=j1;j++)for(let i=i0;i<=i1;i++){
      if(test){ const x=i*res-off, z=j*res-off; if(!test(x,z)) continue; }
      d[j*N+i]=1;
    }
  };
  for(const b of BOXES){
    const gh=heightAt((b.minX+b.maxX)/2,(b.minZ+b.maxZ)/2);
    if(b.maxY<gh+0.5||b.minY>gh+1.75) continue;
    mark(b.minX-pad,b.minZ-pad,b.maxX+pad,b.maxZ+pad,null);
  }
  for(const c of CYLS){
    if(c.r<0.28) continue;
    const rr=c.r+pad;
    mark(c.x-rr,c.z-rr,c.x+rr,c.z+rr,(x,z)=>((x-c.x)*(x-c.x)+(z-c.z)*(z-c.z))<=rr*rr);
  }
  OCC.data=d;
}
function occBlocked(x,z){
  const i=Math.round((x+OCC.off)/OCC.res), j=Math.round((z+OCC.off)/OCC.res);
  if(i<0||j<0||i>=OCC.N||j>=OCC.N) return true;
  return OCC.data[j*OCC.N+i]===1;
}
/* ---------- 射线空间索引(DDA网格遍历) ---------- */
const RGRID={ cell:13, N:0, off:(MAP_SIZE+60)/2, boxCells:null, cylCells:null, stampB:null, stampC:null, gen:0 };
function buildSpatialIndex(){
  RGRID.N=Math.ceil((MAP_SIZE+60)/RGRID.cell);
  const {cell,N,off}=RGRID;
  RGRID.boxCells=Array.from({length:N*N},()=>[]);
  RGRID.cylCells=Array.from({length:N*N},()=>[]);
  BOXES.forEach((b,bi)=>{
    const i0=clamp(Math.floor((b.minX+off)/cell),0,N-1), i1=clamp(Math.floor((b.maxX+off)/cell),0,N-1);
    const j0=clamp(Math.floor((b.minZ+off)/cell),0,N-1), j1=clamp(Math.floor((b.maxZ+off)/cell),0,N-1);
    for(let j=j0;j<=j1;j++)for(let i=i0;i<=i1;i++) RGRID.boxCells[j*N+i].push(bi);
  });
  CYLS.forEach((c,ci)=>{
    const i0=clamp(Math.floor((c.x-c.r+off)/cell),0,N-1), i1=clamp(Math.floor((c.x+c.r+off)/cell),0,N-1);
    const j0=clamp(Math.floor((c.z-c.r+off)/cell),0,N-1), j1=clamp(Math.floor((c.z+c.r+off)/cell),0,N-1);
    for(let j=j0;j<=j1;j++)for(let i=i0;i<=i1;i++) RGRID.cylCells[j*N+i].push(ci);
  });
  RGRID.stampB=new Int32Array(BOXES.length);
  RGRID.stampC=new Int32Array(CYLS.length);
}
/* 世界射线: 返回 {dist, point, normal, kind} 或 null */
const _rcP=V3(), _rcN=V3();
function raycastWorld(o,dir,maxD){
  let best=maxD, bn=null, kind=null;
  if(RGRID.boxCells){
    // Amanatides-Woo 网格DDA: 只测试射线经过格子里的障碍物
    const {cell,N,off,boxCells,cylCells,stampB,stampC}=RGRID;
    const gen=++RGRID.gen;
    let ci=Math.floor((o.x+off)/cell), cj=Math.floor((o.z+off)/cell);
    const dx=dir.x, dz=dir.z;
    const stepI=dx>0?1:-1, stepJ=dz>0?1:-1;
    const tDX=Math.abs(dx)<1e-9?Infinity:cell/Math.abs(dx);
    const tDZ=Math.abs(dz)<1e-9?Infinity:cell/Math.abs(dz);
    let tMaxX=Math.abs(dx)<1e-9?Infinity:(dx>0?((ci+1)*cell-off-o.x):(o.x-(ci*cell-off)))/Math.abs(dx);
    let tMaxZ=Math.abs(dz)<1e-9?Infinity:(dz>0?((cj+1)*cell-off-o.z):(o.z-(cj*cell-off)))/Math.abs(dz);
    let t=0, guard=0;
    while(t<=best&&guard++<70){
      if(ci>=0&&cj>=0&&ci<N&&cj<N){
        const idx=cj*N+ci;
        const bl=boxCells[idx];
        for(let k=0;k<bl.length;k++){
          const bi=bl[k];
          if(stampB[bi]===gen) continue;
          stampB[bi]=gen;
          const r=rayBox(o,dir,BOXES[bi],best);
          if(r){ best=r.t; bn=r; kind='box'; }
        }
        const cl=cylCells[idx];
        for(let k=0;k<cl.length;k++){
          const cy2=cl[k];
          if(stampC[cy2]===gen) continue;
          stampC[cy2]=gen;
          const r=rayCyl(o,dir,CYLS[cy2],best);
          if(r){ best=r.t; bn=r; kind='cyl'; }
        }
      } else if(ci<-1||cj<-1||ci>N||cj>N) break;
      if(tMaxX<tMaxZ){ t=tMaxX; tMaxX+=tDX; ci+=stepI; }
      else { t=tMaxZ; tMaxZ+=tDZ; cj+=stepJ; }
    }
  } else {
    for(const b of BOXES){ const r=rayBox(o,dir,b,best); if(r){ best=r.t; bn=r; kind='box'; } }
    for(const c of CYLS){ const r=rayCyl(o,dir,c,best); if(r){ best=r.t; bn=r; kind='cyl'; } }
  }
  // 地形步进
  if(dir.y<0.35){
    let t=0.5;
    const step=1.8;
    while(t<best){
      const x=o.x+dir.x*t, y=o.y+dir.y*t, z=o.z+dir.z*t;
      const gh=heightAt(x,z);
      if(y<=gh){
        let lo=t-step, hi=t;
        for(let i=0;i<6;i++){
          const m=(lo+hi)/2;
          (o.y+dir.y*m<=heightAt(o.x+dir.x*m,o.z+dir.z*m))?hi=m:lo=m;
        }
        best=hi; bn={nx:0,ny:1,nz:0}; kind='ground';
        break;
      }
      t+=step;
    }
  }
  if(!bn) return null;
  _rcP.set(o.x+dir.x*best,o.y+dir.y*best,o.z+dir.z*best);
  _rcN.set(bn.nx,bn.ny,bn.nz);
  return { dist:best, point:_rcP, normal:_rcN, kind };
}
/* 角色水平推挤碰撞 */
function collideMove(pos, radius){
  for(const b of BOXES){
    if(pos.y+1.7<b.minY||pos.y+0.25>b.maxY) continue;
    if(b.maxY-pos.y<=0.56) continue; // 可自动踏上的台阶
    const cx=clamp(pos.x,b.minX,b.maxX), cz=clamp(pos.z,b.minZ,b.maxZ);
    let dx=pos.x-cx, dz=pos.z-cz;
    const d2=dx*dx+dz*dz;
    if(d2<radius*radius){
      if(d2<1e-8){
        const l=Math.min(pos.x-b.minX,b.maxX-pos.x), rgt=Math.min(pos.z-b.minZ,b.maxZ-pos.z);
        if(l<rgt) pos.x = (pos.x-b.minX<b.maxX-pos.x)? b.minX-radius : b.maxX+radius;
        else pos.z = (pos.z-b.minZ<b.maxZ-pos.z)? b.minZ-radius : b.maxZ+radius;
      } else {
        const d=Math.sqrt(d2), push=(radius-d)/d;
        pos.x+=dx*push; pos.z+=dz*push;
      }
    }
  }
  for(const c of CYLS){
    if(pos.y+1.7<c.y0||pos.y+0.2>c.y1) continue;
    let dx=pos.x-c.x, dz=pos.z-c.z;
    const rr=c.r+radius, d2=dx*dx+dz*dz;
    if(d2<rr*rr&&d2>1e-8){
      const d=Math.sqrt(d2), push=(rr-d)/d;
      pos.x+=dx*push; pos.z+=dz*push;
    }
  }
  const lim=MAP_SIZE/2+8;
  pos.x=clamp(pos.x,-lim,lim); pos.z=clamp(pos.z,-lim,lim);
}
/* 站立地面高度: 地形 + 可站立盒顶 */
function standHeight(x,z,curY){
  let h=heightAt(x,z);
  for(const b of BOXES){
    if(x>b.minX-0.05&&x<b.maxX+0.05&&z>b.minZ-0.05&&z<b.maxZ+0.05){
      if(b.maxY<=curY+0.55&&b.maxY>h) h=b.maxY;
    }
  }
  return h;
}

/* ---------- 世界搭建工具 ---------- */
const world = new THREE.Group(); scene.add(world);
function mesh(geo,mat,x,y,z,ry=0,cast=true,recv=true){
  const m=new THREE.Mesh(geo,mat);
  m.position.set(x,y,z); m.rotation.y=ry;
  m.castShadow=cast; m.receiveShadow=recv;
  world.add(m); return m;
}
function solidBox(mat,w,h,d,x,y,z,ry=0){
  const m=mesh(new THREE.BoxGeometry(w,h,d),mat,x,y,z,ry);
  const sn=Math.sin(ry), cs=Math.cos(ry);
  if(Math.abs(sn)<0.02||Math.abs(cs)<0.02){ // 0/90/180度: 精确AABB
    const swap=Math.abs(sn)>0.5;
    addBoxCollider(x,y,z, swap?d:w, h, swap?w:d);
  } else {
    // 斜置物体: 沿长轴分解为若干小方块碰撞体
    const long=Math.max(w,d), short=Math.min(w,d);
    const s=clamp(short,0.55,2.2);
    const steps=Math.max(1,Math.round(long/s));
    const dirX=w>=d?cs:sn, dirZ=w>=d?-sn:cs;
    for(let i=0;i<steps;i++){
      const t=steps===1?0:-long/2+s/2+i*(long-s)/(steps-1);
      addBoxCollider(x+dirX*t,y,z+dirZ*t,s,h,s);
    }
  }
  return m;
}
const coverPoints=[]; // AI掩体点 {x,z}
function addCoverAround(x,z,w,d,ry=0){
  const cs=Math.cos(ry),sn=Math.sin(ry);
  const off=[[0,d/2+0.9],[0,-d/2-0.9],[w/2+0.9,0],[-w/2-0.9,0]];
  for(const o of off){
    coverPoints.push({x:x+o[0]*cs+o[1]*sn, z:z-o[0]*sn+o[1]*cs});
  }
}

/* --- 建筑: 废墟房屋 --- */
function ruinedHouse(x,z,w,d,ry,mat,brokenLevel=0.5){
  const g0=heightAt(x,z);
  const wallH=3.2, t=0.34;
  const grp=[];
  // 四面墙, 部分墙留门窗或炸毁
  const sides=[
    {dx:0,dz:-d/2,w:w,rot:0},{dx:0,dz:d/2,w:w,rot:0},
    {dx:-w/2,dz:0,w:d,rot:HPI},{dx:w/2,dz:0,w:d,rot:HPI},
  ];
  const cs=Math.cos(ry),sn=Math.sin(ry);
  sides.forEach((s,idx)=>{
    const wx=x+s.dx*cs+s.dz*sn, wz=z-s.dx*sn+s.dz*cs;
    const wrot=ry+s.rot;
    if(Math.random()<brokenLevel*0.5&&idx>0){
      // 炸毁的墙:两个残段
      const segW=s.w*rand(0.2,0.32);
      const h1=wallH*rand(0.4,0.9), h2=wallH*rand(0.3,0.7);
      const o1=-s.w/2+segW/2, o2=s.w/2-segW/2;
      solidBox(mat,segW,h1,t, wx+Math.cos(wrot)*o1, g0+h1/2, wz-Math.sin(wrot)*o1, wrot);
      solidBox(mat,segW,h2,t, wx+Math.cos(wrot)*o2, g0+h2/2, wz-Math.sin(wrot)*o2, wrot);
      // 墙洞处堆瓦砾
      const rb=mesh(new THREE.BoxGeometry(s.w*0.4,0.5,1.6),MAT.rubble,wx,g0+0.25,wz,wrot);
      rb.rotation.z=rand(-0.1,0.1);
    } else if(idx===0){
      // 正面: 门 + 窗
      const doorW=1.2, seg=(s.w-doorW)/2;
      solidBox(mat,seg,wallH,t, wx+Math.cos(wrot)*(-(doorW+seg)/2), g0+wallH/2, wz-Math.sin(wrot)*(-(doorW+seg)/2), wrot);
      // 窗侧: 下墙+上梁
      solidBox(mat,seg,1.1,t, wx+Math.cos(wrot)*((doorW+seg)/2), g0+0.55, wz-Math.sin(wrot)*((doorW+seg)/2), wrot);
      solidBox(mat,seg,0.9,t, wx+Math.cos(wrot)*((doorW+seg)/2), g0+wallH-0.45, wz-Math.sin(wrot)*((doorW+seg)/2), wrot);
      solidBox(mat,doorW,wallH-2.2,t, wx, g0+wallH-(wallH-2.2)/2, wz, wrot); // 门上梁
    } else {
      // 带窗完整墙
      const winW=1.3, seg=(s.w-winW*1)/2;
      solidBox(mat,seg,wallH,t, wx+Math.cos(wrot)*(-(winW+seg)/2), g0+wallH/2, wz-Math.sin(wrot)*(-(winW+seg)/2), wrot);
      solidBox(mat,seg,wallH,t, wx+Math.cos(wrot)*((winW+seg)/2), g0+wallH/2, wz-Math.sin(wrot)*((winW+seg)/2), wrot);
      solidBox(mat,winW,1.1,t, wx, g0+0.55, wz, wrot);
      solidBox(mat,winW,0.9,t, wx, g0+wallH-0.45, wz, wrot);
    }
  });
  // 屋顶(半塌)
  if(Math.random()>brokenLevel*0.7){
    const roof=mesh(new THREE.BoxGeometry(w*0.55,0.16,d+0.6),MAT.roof, x-w*0.22*cs, g0+wallH+0.5, z-w*0.22*sn, ry);
    roof.rotation.z=0.42;
  }
  // 内部杂物
  const cx2=x+rand(-w*0.2,w*0.2), cz2=z+rand(-d*0.2,d*0.2);
  solidBox(MAT.woodDark,1.1,0.8,0.7,cx2,g0+0.4,cz2,rand(0,3));
  addCoverAround(x,z,w,d,ry);
  return grp;
}
/* --- 教堂 --- */
function buildChurch(x,z,ry){
  const g0=heightAt(x,z);
  const w=10,d=16,H=6;
  // 主殿墙
  solidBox(MAT.stone,0.5,H,d, x-w/2, g0+H/2, z, ry);
  solidBox(MAT.stone,0.5,H,d, x+w/2, g0+H/2, z, ry);
  // 后墙
  solidBox(MAT.stone,w,H,0.5, x, g0+H/2, z-d/2, ry);
  // 前墙(大门)
  const dw=2.2, seg=(w-dw)/2;
  solidBox(MAT.stone,seg,H,0.5, x-(dw+seg)/2, g0+H/2, z+d/2, ry);
  solidBox(MAT.stone,seg,H,0.5, x+(dw+seg)/2, g0+H/2, z+d/2, ry);
  solidBox(MAT.stone,dw,H-3,0.5, x, g0+H-(H-3)/2, z+d/2, ry);
  // 半塌屋顶
  const roof=mesh(new THREE.BoxGeometry(w*0.6,0.2,d*0.55),MAT.roof,x-w*0.2,g0+H+0.7,z-d*0.2,ry);
  roof.rotation.z=0.5;
  // 钟楼(可上) —— 简化为高塔+平台
  const tx=x, tz=z-d/2-3;
  const th=10;
  solidBox(MAT.stone,4,th,0.5,tx,g0+th/2,tz-2,0);
  solidBox(MAT.stone,4,th,0.5,tx,g0+th/2,tz+2,0);
  solidBox(MAT.stone,0.5,th,3.5,tx-2,g0+th/2,tz,0);
  solidBox(MAT.stone,0.5,4,3.5,tx+2,g0+2,tz,0); // 正面下半开口做入口上斜坡
  // 塔顶平台+护栏
  solidBox(MAT.stone,4,0.4,4.5,tx,g0+th-2.5,tz,0);
  solidBox(MAT.stone,4,0.9,0.3,tx,g0+th-1.6,tz-2.1,0);
  solidBox(MAT.stone,4,0.9,0.3,tx,g0+th-1.6,tz+2.1,0);
  solidBox(MAT.stone,0.3,0.9,4.5,tx-2.1,g0+th-1.6,tz,0);
  solidBox(MAT.stone,0.3,0.9,4.5,tx+2.1,g0+th-1.6,tz,0);
  // 上塔楼梯(台阶盒)
  for(let i=0;i<9;i++){
    solidBox(MAT.woodDark,1.4,0.24,1.0, tx+2+2.2-i*0.62, g0+0.4+i*0.82, tz, 0);
  }
  // 尖顶残骸
  const spire=mesh(new THREE.ConeGeometry(2.6,3,4),MAT.woodDark,tx,g0+th+0.6,tz,Math.PI/4);
  // 长椅
  for(let i=0;i<4;i++){
    solidBox(MAT.woodDark,3,0.5,0.5, x+rand(-1.5,1.5), g0+0.25, z-d/2+3+i*2.6, ry+rand(-0.2,0.2));
  }
  addCoverAround(x,z,w,d,ry);
  coverPoints.push({x:tx,z:tz});
}
/* --- 谷仓 --- */
function buildBarn(x,z,ry){
  const g0=heightAt(x,z);
  const w=9,d=13,H=4.2;
  const cs=Math.cos(ry),sn=Math.sin(ry);
  const L=(lx,lz)=>[x+lx*cs+lz*sn, z-lx*sn+lz*cs];
  let p;
  p=L(-w/2,0); solidBox(MAT.wood,0.35,H,d, p[0],g0+H/2,p[1], ry);      // 左墙
  p=L(w/2,0);  solidBox(MAT.wood,0.35,H,d, p[0],g0+H/2,p[1], ry);      // 右墙
  p=L(0,-d/2); solidBox(MAT.wood,w,H,0.35, p[0],g0+H/2,p[1], ry);      // 后墙
  // 前墙: 中间大开口
  const seg=w*0.28;
  p=L(-(w-seg)/2,d/2); solidBox(MAT.wood,seg,H,0.35, p[0],g0+H/2,p[1], ry);
  p=L((w-seg)/2,d/2);  solidBox(MAT.wood,seg,H,0.35, p[0],g0+H/2,p[1], ry);
  p=L(0,d/2); solidBox(MAT.wood,w-seg*2,1.0,0.35, p[0],g0+H-0.5,p[1], ry); // 门楣
  // 干草堆
  for(let i=0;i<3;i++){
    const hp=L(rand(-2.5,2.5),rand(-4,4));
    const hx=hp[0], hz=hp[1];
    mesh(new THREE.CylinderGeometry(1.1,1.3,1.6,10),MAT.hay,hx,g0+0.8,hz);
    CYLS.push({x:hx,z:hz,r:1.2,y0:g0,y1:g0+1.6});
  }
  // 阁楼平台 + 楼梯箱
  p=L(0,-d/2+2.2); solidBox(MAT.woodDark,w-0.8,0.25,4, p[0],g0+2.6,p[1], ry);
  for(let i=0;i<5;i++){
    p=L(w/2-1.4,-d/2+4.5+i*0.5);
    solidBox(MAT.woodDark,1.2,0.2,0.9, p[0],g0+0.5+i*0.55,p[1], ry);
  }
  // 屋顶
  const r1=mesh(new THREE.BoxGeometry(w*0.62,0.15,d+0.5),MAT.wood,...(()=>{const q=L(-w*0.23,0);return [q[0],g0+H+0.85,q[1]];})(),ry); r1.rotation.z=0.5;
  const r2=mesh(new THREE.BoxGeometry(w*0.62,0.15,d+0.5),MAT.wood,...(()=>{const q=L(w*0.23,0);return [q[0],g0+H+0.85,q[1]];})(),ry); r2.rotation.z=-0.5;
  addCoverAround(x,z,w,d,ry);
}
/* --- 沙袋墙 --- */
function sandbagWall(x,z,len,ry){
  const g0=heightAt(x,z);
  const m=solidBox(MAT.sandbag,len,1.0,0.75,x,g0+0.5,z,ry);
  coverPoints.push({x:x-Math.sin(ry)*1.4, z:z-Math.cos(ry)*1.4});
  coverPoints.push({x:x+Math.sin(ry)*1.4, z:z+Math.cos(ry)*1.4});
  return m;
}
/* --- 反坦克拒马 --- */
function hedgehog(x,z){
  const g0=heightAt(x,z);
  for(let i=0;i<3;i++){
    const b=mesh(new THREE.BoxGeometry(0.22,2.4,0.22),MAT.metalDark,x,g0+0.75,z);
    b.rotation.set(rand(-0.5,0.5)+ (i*1.1), 0, 0.7+i*0.5);
  }
  CYLS.push({x,z,r:0.85,y0:g0,y1:g0+1.5});
  coverPoints.push({x:x+rand(-1,1),z:z+rand(-1,1)});
}
/* --- 树 --- */
const treeTrunkGeo=new THREE.CylinderGeometry(0.22,0.34,4.4,7);
const treeLeafGeo=new THREE.PlaneGeometry(5,5);
function tree(x,z,s=1){
  const g0=heightAt(x,z);
  const tr=mesh(treeTrunkGeo,MAT.bark,x,g0+2.2*s,z); tr.scale.setScalar(s);
  for(let i=0;i<3;i++){
    const lf=mesh(treeLeafGeo,MAT.leaves,x,g0+(3.4+i*0.6)*s,z,rand(0,Math.PI),false,false);
    lf.rotation.x=rand(-0.25,0.25);
    lf.scale.setScalar(s*rand(0.8,1.15));
  }
  CYLS.push({x,z,r:0.35*s,y0:g0,y1:g0+4*s});
}
/* --- 枯树 --- */
function deadTree(x,z){
  const g0=heightAt(x,z);
  const t=mesh(new THREE.CylinderGeometry(0.14,0.3,3.6,6),MAT.woodDark,x,g0+1.8,z);
  t.rotation.z=rand(-0.15,0.15);
  const b1=mesh(new THREE.CylinderGeometry(0.05,0.1,1.6,5),MAT.woodDark,x+0.5,g0+3,z); b1.rotation.z=-0.9;
  CYLS.push({x,z,r:0.3,y0:g0,y1:g0+3.4});
}
/* --- 弹坑 --- */
const craterGeo=new THREE.CircleGeometry(1,20);
const craterMat=new THREE.MeshLambertMaterial({color:0x4a4238,transparent:true,opacity:0.85,depthWrite:false});
const runtimeCraters=[];
function crater(x,z,r,runtime=false){
  const m=mesh(craterGeo,craterMat,x,heightAt(x,z)+0.03,z,0,false,true);
  m.rotation.x=-HPI; m.scale.setScalar(r);
  m.renderOrder=1;
  if(runtime){
    runtimeCraters.push(m);
    if(runtimeCraters.length>36){ const old=runtimeCraters.shift(); world.remove(old); }
    return;
  }
  const rim=mesh(new THREE.TorusGeometry(r*0.8,r*0.14,6,14),new THREE.MeshLambertMaterial({color:0x5d5344}),x,heightAt(x,z)+0.05,z);
  rim.rotation.x=-HPI;
}
/* --- 板条箱/木桶 --- */
function crate(x,z,s=1,ry=0){
  const g0=heightAt(x,z);
  solidBox(MAT.wood,s,s,s,x,g0+s/2,z,ry);
  coverPoints.push({x:x+rand(-1.3,1.3),z:z+rand(-1.3,1.3)});
}
function barrel(x,z){
  const g0=heightAt(x,z);
  mesh(new THREE.CylinderGeometry(0.42,0.42,1.1,10),MAT.metal,x,g0+0.55,z);
  CYLS.push({x,z,r:0.45,y0:g0,y1:g0+1.1});
}
/* --- 毁坏卡车 --- */
function wreckTruck(x,z,ry){
  const g0=heightAt(x,z);
  solidBox(MAT.metalDark,2.2,1.5,5.4,x,g0+0.95,z,ry);
  const cab=mesh(new THREE.BoxGeometry(2.1,1.1,1.6),MAT.metalDark,x+Math.sin(ry)*1.6,g0+2.0,z+Math.cos(ry)*1.6,ry);
  cab.rotation.z=0.12;
  for(const s of [-1,1]){
    const w1=mesh(new THREE.CylinderGeometry(0.55,0.55,0.35,10),MAT.metalDark, x+Math.cos(ry)*s*1.15, g0+0.55, z-Math.sin(ry)*s*1.15+Math.cos(ry)*1.4, ry);
    w1.rotation.z=HPI;
  }
  addCoverAround(x,z,2.4,5.6,ry);
}
/* --- 石墙/树篱 --- */
function stoneWall(x1,z1,x2,z2,h=1.15){
  const dx=x2-x1,dz=z2-z1;
  const len=Math.hypot(dx,dz), ry=Math.atan2(dx,dz)+HPI;
  const steps=Math.ceil(len/6);
  for(let i=0;i<steps;i++){
    const t0=(i+0.5)/steps;
    const x=x1+dx*t0, z=z1+dz*t0;
    const g0=heightAt(x,z);
    solidBox(MAT.stone,Math.min(6.2,len/steps+0.3),h,0.55,x,g0+h/2,z,ry);
  }
  coverPoints.push({x:(x1+x2)/2+dz/len*1.4,z:(z1+z2)/2-dx/len*1.4});
  coverPoints.push({x:(x1+x2)/2-dz/len*1.4,z:(z1+z2)/2+dx/len*1.4});
}
function hedgerow(x1,z1,x2,z2){
  const dx=x2-x1,dz=z2-z1;
  const len=Math.hypot(dx,dz), ry=Math.atan2(dx,dz)+HPI;
  const steps=Math.ceil(len/4);
  for(let i=0;i<steps;i++){
    const t0=(i+0.5)/steps;
    const x=x1+dx*t0+rand(-0.4,0.4), z=z1+dz*t0+rand(-0.4,0.4);
    const g0=heightAt(x,z);
    const h=rand(2.2,3.0);
    const m=mesh(new THREE.BoxGeometry(4.4,h,2.2),new THREE.MeshLambertMaterial({map:TEX.leaves,transparent:true,alphaTest:0.4}),x,g0+h/2,z,ry);
    addBoxCollider(x,g0+h/2,z, Math.abs(Math.sin(ry))>0.5?2.2:4.4, h, Math.abs(Math.sin(ry))>0.5?4.4:2.2);
  }
}
/* --- 瓦砾堆 --- */
function rubblePile(x,z,r){
  const g0=heightAt(x,z);
  for(let i=0;i<6;i++){
    const bx=x+rand(-r,r)*0.7, bz=z+rand(-r,r)*0.7;
    const s=rand(0.5,1.4);
    const m=mesh(new THREE.BoxGeometry(s,s*0.7,s),Math.random()<0.5?MAT.rubble:MAT.brick,bx,g0+s*0.3,bz,rand(0,3));
    m.rotation.x=rand(-0.3,0.3);
  }
  const mound=mesh(new THREE.SphereGeometry(r*0.8,10,6),MAT.rubble,x,g0-r*0.35,z);
  CYLS.push({x,z,r:r*0.65,y0:g0,y1:g0+r*0.5});
  coverPoints.push({x:x+r,z}); coverPoints.push({x:x-r,z});
}
/* --- 帐篷 --- */
function tent(x,z,ry){
  const g0=heightAt(x,z);
  const m1=mesh(new THREE.BoxGeometry(3.4,0.12,4.4),new THREE.MeshLambertMaterial({color:0x5e6244}),x-0.85,g0+1.1,z,ry); m1.rotation.z=0.72;
  const m2=mesh(new THREE.BoxGeometry(3.4,0.12,4.4),new THREE.MeshLambertMaterial({color:0x565a3e}),x+0.85,g0+1.1,z,ry); m2.rotation.z=-0.72;
  addBoxCollider(x,g0+0.9,z,1.5,1.8,4);
}

/* ---------- 地图布置 ---------- */
function buildWorld(){
  // ==== B点: 中央村庄 ====
  buildChurch(0,-6,0);
  ruinedHouse(-14,4, 7,8, 0, MAT.plaster, 0.5);
  ruinedHouse(13,6, 8,7, HPI, MAT.brick, 0.7);
  ruinedHouse(-9,16, 7,7, 0, MAT.brick, 0.8);
  ruinedHouse(11,-14, 6,8, 0, MAT.plaster, 0.6);
  // 村中心水井
  { const g0=heightAt(0,10); mesh(new THREE.CylinderGeometry(1.1,1.2,0.9,10),MAT.stone,0,g0+0.45,10); CYLS.push({x:0,z:10,r:1.2,y0:g0,y1:g0+0.9}); }
  sandbagWall(-4,22,4,0); sandbagWall(6,20,4,0.6);
  sandbagWall(-18,-8,4,HPI);
  crate(4,14,1.2,0.4); crate(5.5,14.5,0.9,0.9); crate(-6,-16,1.3,0.2);
  barrel(-3,13); barrel(16,2); barrel(15,3.2);
  crater(-8,-2,2.4); crater(18,12,1.9); crater(-20,10,2.2);
  wreckTruck(-22,2,0.5);

  // ==== A点: 西侧农场 ====
  buildBarn(-72,26,0.2);
  ruinedHouse(-62,12, 7,7, 0.2, MAT.plaster, 0.4);
  stoneWall(-84,34,-58,38); stoneWall(-84,10,-84,34);
  sandbagWall(-64,20,4,1.2); sandbagWall(-76,14,4,0);
  crate(-66,32,1.2,0.7); barrel(-60,16); barrel(-59,17.4);
  crater(-55,24,2.0);
  { // 干草卷
    for(let i=0;i<3;i++){ const hx=-52+i*4,hz=30+rand(-2,2),g0=heightAt(hx,hz);
      const m=mesh(new THREE.CylinderGeometry(1.1,1.1,1.6,12),MAT.hay,hx,g0+1.1,hz); m.rotation.z=HPI;
      CYLS.push({x:hx,z:hz,r:1.15,y0:g0,y1:g0+2.2}); coverPoints.push({x:hx,z:hz-2}); }
  }

  // ==== C点: 东侧废墟 ====
  ruinedHouse(72,-20, 8,9, 0, MAT.brick, 0.9);
  ruinedHouse(62,-32, 7,7, HPI, MAT.brick, 0.85);
  ruinedHouse(82,-32, 6,7, 0, MAT.plaster, 0.9);
  ruinedHouse(76,-8, 7,6, HPI, MAT.plaster, 0.75);
  rubblePile(68,-26,2.2); rubblePile(78,-22,1.8); rubblePile(70,-14,2.0);
  crater(74,-28,2.6); crater(64,-18,2.2); crater(84,-16,1.8); crater(58,-24,2.0);
  wreckTruck(66,-38,1.2);
  hedgehog(60,-14); hedgehog(84,-26); hedgehog(74,-34);
  sandbagWall(72,-15,4,0.3);

  // ==== 基地 ====
  // 美军西
  tent(-116,6,0.3); tent(-118,-6,-0.2); tent(-122,0,0);
  sandbagWall(-108,6,5,HPI); sandbagWall(-108,-6,5,HPI); sandbagWall(-110,0,5,HPI);
  crate(-114,10,1.3,0); barrel(-113,-9);
  // 德军东
  tent(116,6,-0.3); tent(118,-6,0.2); tent(122,0,0);
  sandbagWall(108,6,5,HPI); sandbagWall(108,-6,5,HPI); sandbagWall(110,0,5,HPI);
  crate(114,-10,1.3,0.5); barrel(113,9);

  // ==== 中间地带 ====
  stoneWall(-40,-14,-24,-20); stoneWall(30,16,46,10);
  hedgerow(-46,-34,-16,-40); hedgerow(20,34,52,28);
  hedgerow(-30,44,10,46);
  sandbagWall(-36,8,4,0.9); sandbagWall(38,-6,4,-0.8); sandbagWall(-30,-28,4,0.2);
  sandbagWall(34,24,4,1.4);
  hedgehog(-44,4); hedgehog(-40,18); hedgehog(44,2); hedgehog(40,-18);
  crater(-38,-6,2.4); crater(32,6,2.2); crater(-28,26,2.0); crater(48,-14,2.4);
  crater(-10,-30,2.2); crater(14,28,2.0);
  crate(-34,-10,1.2,0.3); crate(30,12,1.3,0.8); crate(-24,30,1.1,0.5);
  wreckTruck(-45,-24,-0.4); wreckTruck(36,32,2.2);
  deadTree(-20,-24); deadTree(26,-10); deadTree(-6,34); deadTree(48,20);

  // ==== 树木散布 ====
  const treeSpots=[];
  for(let i=0;i<70;i++){
    const x=rand(-125,125), z=rand(-125,125);
    if(roadDist(x,z)<7) continue;
    let near=false;
    for(const f of FLATS){ if(Math.hypot(x-f.x,z-f.z)<f.r*0.85) near=true; }
    if(near) continue;
    treeSpots.push([x,z]);
  }
  treeSpots.forEach(([x,z])=>tree(x,z,rand(0.8,1.5)));

  // ==== 草丛 (InstancedMesh) ====
  const grassCount=SETTINGS.quality===0?400:(SETTINGS.quality===1?900:1600);
  const gGeo=new THREE.PlaneGeometry(0.9,0.55);
  gGeo.translate(0,0.27,0);
  const gMat=new THREE.MeshLambertMaterial({map:makeTex(64,64,(g,w,h)=>{
    g.clearRect(0,0,w,h);
    for(let i=0;i<30;i++){
      g.strokeStyle=`rgba(${randi(90,130)},${randi(115,150)},${randi(55,80)},.9)`;
      g.lineWidth=2;
      g.beginPath(); const x=rand(4,60); g.moveTo(x,h); g.quadraticCurveTo(x+rand(-6,6),h*0.5,x+rand(-10,10),rand(4,20)); g.stroke();
    }
  }),transparent:true,alphaTest:0.35,side:THREE.DoubleSide});
  const inst=new THREE.InstancedMesh(gGeo,gMat,grassCount);
  const dummy=new THREE.Object3D();
  for(let i=0;i<grassCount;i++){
    let x=rand(-125,125),z=rand(-125,125);
    if(roadDist(x,z)<4){ x+=8; }
    dummy.position.set(x,heightAt(x,z),z);
    dummy.rotation.y=rand(0,Math.PI);
    dummy.scale.setScalar(rand(0.7,1.4));
    dummy.updateMatrix();
    inst.setMatrixAt(i,dummy.matrix);
  }
  inst.receiveShadow=true;
  scene.add(inst);

  // 电线杆沿主路
  for(let x=-100;x<=100;x+=28){
    const z=3*Math.sin(x*0.02)+6.5;
    const g0=heightAt(x,z);
    mesh(new THREE.CylinderGeometry(0.12,0.16,6.5,6),MAT.woodDark,x,g0+3.25,z);
    mesh(new THREE.BoxGeometry(0.1,0.12,1.8),MAT.woodDark,x,g0+5.8,z);
    CYLS.push({x,z,r:0.2,y0:g0,y1:g0+6.5});
  }
}
buildWorld();

/* ---------- 旗点 / 出生 / 补给 / MG42 ---------- */
const FLAGS = [
  { id:'A', x:-70, z:22, owner:-1, cap:0, capTeam:-1, r:13 },
  { id:'B', x:0,   z:2,  owner:-1, cap:0, capTeam:-1, r:14 },
  { id:'C', x:72,  z:-24,owner:-1, cap:0, capTeam:-1, r:13 },
];
const BASES=[ {x:-118,z:0}, {x:118,z:0} ];
FLAGS.forEach(f=>{
  const g0=heightAt(f.x,f.z);
  const pole=mesh(new THREE.CylinderGeometry(0.07,0.09,7,8),MAT.metal,f.x,g0+3.5,f.z);
  CYLS.push({x:f.x,z:f.z,r:0.15,y0:g0,y1:g0+7});
  const flagC=document.createElement('canvas'); flagC.width=64; flagC.height=40;
  f.flagCtx=flagC.getContext('2d');
  f.flagTex=new THREE.CanvasTexture(flagC);
  const fm=new THREE.Mesh(new THREE.PlaneGeometry(2.4,1.5), new THREE.MeshLambertMaterial({map:f.flagTex,side:THREE.DoubleSide}));
  fm.position.set(f.x+1.25,g0+6,f.z);
  world.add(fm);
  f.flagMesh=fm; f.poleTop=g0+6.6; f.gy=g0;
  drawFlagTex(f);
});
function drawFlagTex(f){
  const g=f.flagCtx;
  if(f.owner===0){ g.fillStyle='#3a5f9e'; g.fillRect(0,0,64,40); g.fillStyle='#fff'; g.font='bold 26px sans-serif'; g.fillText('★',20,31); }
  else if(f.owner===1){ g.fillStyle='#8a2020'; g.fillRect(0,0,64,40); g.fillStyle='#eee'; g.font='bold 26px sans-serif'; g.fillText('✠',20,31); }
  else { g.fillStyle='#c9c9bb'; g.fillRect(0,0,64,40); g.fillStyle='#555'; g.font='bold 22px sans-serif'; g.fillText(f.id,24,29); }
  f.flagTex.needsUpdate=true;
}
const AMMO_CRATES=[];
function ammoCrate(x,z){
  const g0=heightAt(x,z);
  const m=solidBox(MAT.woodDark,1.1,0.7,0.8,x,g0+0.35,z,rand(0,3));
  const lid=mesh(new THREE.BoxGeometry(1.15,0.08,0.85),MAT.wood,x,g0+0.74,z,m.rotation.y);
  AMMO_CRATES.push({x,z,y:g0});
}
ammoCrate(-69,18); ammoCrate(2,8); ammoCrate(70,-20);
ammoCrate(-115,3); ammoCrate(115,-3);

const MG42S=[];
function mg42(x,z,face){
  const g0=heightAt(x,z);
  sandbagWall(x+Math.sin(face)*1.1, z+Math.cos(face)*1.1, 3, face+HPI);
  const grp=new THREE.Group();
  grp.position.set(x,g0+1.0,z);
  grp.rotation.y=face;
  const yaw=new THREE.Group(); grp.add(yaw);
  const pitch=new THREE.Group(); yaw.add(pitch);
  const barrel=new THREE.Mesh(new THREE.CylinderGeometry(0.05,0.06,1.3,8),MAT.metalDark);
  barrel.rotation.x=HPI; barrel.position.z=0.65; pitch.add(barrel);
  const body=new THREE.Mesh(new THREE.BoxGeometry(0.14,0.2,0.75),MAT.metalDark);
  body.position.z=0.0; pitch.add(body);
  const shield=new THREE.Mesh(new THREE.BoxGeometry(0.9,0.55,0.05),MAT.metal);
  shield.position.set(0,0.05,0.35); pitch.add(shield);
  const tripod1=new THREE.Mesh(new THREE.CylinderGeometry(0.03,0.03,1.0,6),MAT.metalDark);
  tripod1.position.set(0,-0.5,0); grp.add(tripod1);
  grp.traverse(o=>{ o.castShadow=true; });
  world.add(grp);
  MG42S.push({x,z,y:g0+1.0,face,grp,yaw,pitch,user:null,heat:0,muzzle:V3()});
}
mg42(-8,24,Math.PI);      // B北向
mg42(8,-22,0);            // B南向
mg42(-62,24,-HPI);        // A东向
mg42(64,-22,HPI);         // C西向

/* ---------- 导航网格 (二叉堆A* + 占用栅格) ---------- */
buildOccupancy();
buildSpatialIndex();
const NAV = (()=> {
  const SP=5, N=Math.floor(MAP_SIZE/SP)+1, OFF=MAP_SIZE/2, NN=N*N;
  const open=new Uint8Array(NN);
  const idx=(i,j)=>j*N+i;
  const toW=(i,j)=>[i*SP-OFF, j*SP-OFF];
  const toG=(x,z)=>[clamp(Math.round((x+OFF)/SP),0,N-1), clamp(Math.round((z+OFF)/SP),0,N-1)];
  for(let j=0;j<N;j++)for(let i=0;i<N;i++){
    const [x,z]=toW(i,j);
    open[idx(i,j)]=occBlocked(x,z)?0:1;
  }
  function lineClear(x1,z1,x2,z2){
    const d=Math.hypot(x2-x1,z2-z1), steps=Math.ceil(d/0.9);
    for(let s=1;s<steps;s++){
      const t=s/steps;
      if(occBlocked(x1+(x2-x1)*t, z1+(z2-z1)*t)) return false;
    }
    return true;
  }
  // A* 复用缓冲区(避免GC) + 二叉堆
  const gScore=new Float32Array(NN), came=new Int32Array(NN), visitGen=new Int32Array(NN);
  const heapIdx=new Int32Array(NN*4), heapKey=new Float32Array(NN*4);
  let gen=0;
  const DI=[1,-1,0,0,1,1,-1,-1], DJ=[0,0,1,-1,1,-1,1,-1], DC=[1,1,1,1,1.414,1.414,1.414,1.414];
  function findPath(sx,sz,tx,tz){
    let [si,sj]=toG(sx,sz), [ti,tj]=toG(tx,tz);
    const fix=(i,j)=>{
      if(open[idx(i,j)]) return [i,j];
      for(let r2=1;r2<5;r2++)for(let dj=-r2;dj<=r2;dj++)for(let di=-r2;di<=r2;di++){
        const ii=i+di,jj=j+dj;
        if(ii>=0&&jj>=0&&ii<N&&jj<N&&open[idx(ii,jj)]) return [ii,jj];
      }
      return [i,j];
    };
    [si,sj]=fix(si,sj); [ti,tj]=fix(ti,tj);
    const startI=idx(si,sj), goalI=idx(ti,tj);
    if(startI===goalI) return [[tx,tz]];
    gen++;
    let hLen=0;
    const push=(node,key)=>{
      let k=hLen++;
      heapIdx[k]=node; heapKey[k]=key;
      while(k>0){
        const par=(k-1)>>1;
        if(heapKey[par]<=heapKey[k]) break;
        const tI=heapIdx[par],tK=heapKey[par];
        heapIdx[par]=heapIdx[k]; heapKey[par]=heapKey[k];
        heapIdx[k]=tI; heapKey[k]=tK;
        k=par;
      }
    };
    const pop=()=>{
      const top=heapIdx[0];
      hLen--;
      if(hLen>0){
        heapIdx[0]=heapIdx[hLen]; heapKey[0]=heapKey[hLen];
        let k=0;
        for(;;){
          const l=k*2+1,r=l+1;
          let s=k;
          if(l<hLen&&heapKey[l]<heapKey[s])s=l;
          if(r<hLen&&heapKey[r]<heapKey[s])s=r;
          if(s===k)break;
          const tI=heapIdx[s],tK=heapKey[s];
          heapIdx[s]=heapIdx[k]; heapKey[s]=heapKey[k];
          heapIdx[k]=tI; heapKey[k]=tK;
          k=s;
        }
      }
      return top;
    };
    gScore[startI]=0; visitGen[startI]=gen; came[startI]=-1;
    push(startI, Math.hypot(si-ti,sj-tj)*SP);
    let found=false, guard=0;
    while(hLen>0&&guard++<6000){
      const cur=pop();
      if(cur===goalI){ found=true; break; }
      const cI=cur%N, cJ=(cur/N)|0, cg=gScore[cur];
      for(let d=0;d<8;d++){
        const ni=cI+DI[d], nj=cJ+DJ[d];
        if(ni<0||nj<0||ni>=N||nj>=N) continue;
        const nIdx=nj*N+ni;
        if(!open[nIdx]) continue;
        if(d>=4&&(!open[idx(cI+DI[d],cJ)]||!open[idx(cI,cJ+DJ[d])])) continue;
        const ng=cg+DC[d]*SP;
        if(visitGen[nIdx]!==gen||ng<gScore[nIdx]){
          visitGen[nIdx]=gen; gScore[nIdx]=ng; came[nIdx]=cur;
          push(nIdx, ng+Math.hypot(ni-ti,nj-tj)*SP);
        }
      }
    }
    if(!found) return null;
    const path=[];
    let cur=goalI;
    while(cur!==startI&&cur>=0){ const i=cur%N,j=(cur/N)|0; const [x,z]=toW(i,j); path.push([x,z]); cur=came[cur]; }
    path.reverse();
    path.push([tx,tz]);
    // 平滑: 视线跳点
    const sm=[];
    let ax=sx, az=sz, k=0;
    while(k<path.length){
      let far=k;
      for(let m2=k;m2<Math.min(path.length,k+8);m2++){
        if(lineClear(ax,az,path[m2][0],path[m2][1])) far=m2;
      }
      sm.push(path[far]);
      ax=path[far][0]; az=path[far][1];
      k=far+1;
    }
    return sm;
  }
  return { findPath, blocked2D:(x,z)=>occBlocked(x,z), lineClear, budget:3,
    refresh(){
      buildOccupancy(); buildSpatialIndex();
      for(let j=0;j<N;j++)for(let i=0;i<N;i++){
        const [x,z]=toW(i,j);
        open[idx(i,j)]=occBlocked(x,z)?0:1;
      }
    }
  };
})();

/* ================================================================
   模块C:特效系统 / 武器数据 / 视角模型与动画
================================================================ */
/* ---------- 粒子/特效 ---------- */
function radialTex(inner,outer,stops){
  const c=document.createElement('canvas'); c.width=64; c.height=64;
  const g=c.getContext('2d');
  const gr=g.createRadialGradient(32,32,inner,32,32,outer);
  stops.forEach(s=>gr.addColorStop(s[0],s[1]));
  g.fillStyle=gr; g.fillRect(0,0,64,64);
  return new THREE.CanvasTexture(c);
}
const PT = {
  smoke: radialTex(2,30,[[0,'rgba(200,195,185,.85)'],[0.5,'rgba(160,155,145,.4)'],[1,'rgba(150,150,140,0)']]),
  dark:  radialTex(2,30,[[0,'rgba(60,55,48,.9)'],[0.5,'rgba(70,64,55,.5)'],[1,'rgba(60,60,55,0)']]),
  flash: radialTex(1,30,[[0,'rgba(255,250,220,1)'],[0.25,'rgba(255,190,90,.9)'],[1,'rgba(255,120,30,0)']]),
  blood: radialTex(2,26,[[0,'rgba(140,20,12,.95)'],[0.6,'rgba(110,14,8,.5)'],[1,'rgba(90,10,6,0)']]),
  dirt:  radialTex(2,28,[[0,'rgba(120,100,70,.95)'],[0.6,'rgba(100,84,58,.5)'],[1,'rgba(90,76,52,0)']]),
  spark: radialTex(1,20,[[0,'rgba(255,240,180,1)'],[1,'rgba(255,160,40,0)']]),
};
const particles=[];
const PARTICLE_MAX=340;
const pPools=new Map(); // 按 纹理+混合模式 分池复用, 避免GC卡顿
function poolKey(tex,additive){ return tex.uuid+(additive?'A':'N'); }
function recycleP(p){
  p.sp.visible=false;
  pPools.get(p.key).push(p);
}
function spawnP(tex,x,y,z,vx,vy,vz,size,grow,life,opa=1,grav=0,additive=false,spin=0){
  if(particles.length>=PARTICLE_MAX){ recycleP(particles.shift()); }
  const key=poolKey(tex,additive);
  let pool=pPools.get(key);
  if(!pool){ pool=[]; pPools.set(key,pool); }
  let p=pool.pop();
  if(!p){
    const sp=new THREE.Sprite(new THREE.SpriteMaterial({map:tex,transparent:true,depthWrite:false,blending:additive?THREE.AdditiveBlending:THREE.NormalBlending}));
    scene.add(sp);
    p={sp,key};
  }
  p.sp.visible=true;
  p.sp.material.opacity=opa;
  p.sp.material.rotation=rand(0,TAU);
  p.sp.position.set(x,y,z);
  p.sp.scale.setScalar(size);
  p.vx=vx; p.vy=vy; p.vz=vz; p.grow=grow; p.life=life; p.age=0; p.opa=opa; p.grav=grav; p.spin=spin;
  particles.push(p);
}
function updateParticles(dt){
  for(let i=particles.length-1;i>=0;i--){
    const p=particles[i];
    p.age+=dt;
    if(p.age>=p.life){ recycleP(p); particles.splice(i,1); continue; }
    p.vy-=p.grav*dt;
    p.sp.position.x+=p.vx*dt; p.sp.position.y+=p.vy*dt; p.sp.position.z+=p.vz*dt;
    const s=p.sp.scale.x+p.grow*dt;
    p.sp.scale.setScalar(s);
    p.sp.material.rotation+=p.spin*dt;
    p.sp.material.opacity=p.opa*(1-p.age/p.life);
  }
}
/* 曳光弹 */
const tracers=[], tracerPool=[];
const tracerGeo=new THREE.BoxGeometry(0.035,0.035,1.6);
const tracerMat=new THREE.MeshBasicMaterial({color:0xffd890,transparent:true,opacity:0.9,blending:THREE.AdditiveBlending,depthWrite:false,fog:false});
function spawnTracer(o,dir,dist){
  let m=tracerPool.pop();
  if(!m){ m=new THREE.Mesh(tracerGeo,tracerMat); scene.add(m); }
  m.visible=true;
  m.position.copy(o);
  m.lookAt(o.x+dir.x,o.y+dir.y,o.z+dir.z);
  tracers.push({m,dir:dir.clone(),traveled:0,max:dist});
}
function updateTracers(dt){
  const spd=340;
  for(let i=tracers.length-1;i>=0;i--){
    const t=tracers[i];
    const step=spd*dt;
    t.traveled+=step;
    if(t.traveled>=t.max){ t.m.visible=false; tracerPool.push(t.m); tracers.splice(i,1); continue; }
    t.m.position.addScaledVector(t.dir,step);
  }
}
/* 弹壳 */
const casings=[];
const casingGeo=new THREE.CylinderGeometry(0.011,0.011,0.05,5);
const casingMat=new THREE.MeshBasicMaterial({color:0xc8a850});
function spawnCasing(pos,right,up){
  if(casings.length>26){ const c=casings.shift(); scene.remove(c.m); }
  const m=new THREE.Mesh(casingGeo,casingMat);
  m.position.copy(pos);
  m.rotation.set(rand(0,3),rand(0,3),rand(0,3));
  scene.add(m);
  casings.push({m, vx:right.x*rand(1,2.4)+up.x*rand(1.4,2.4), vy:rand(1.6,2.6), vz:right.z*rand(1,2.4)+up.z*rand(1.4,2.4), rx:rand(-12,12), rz:rand(-12,12), life:0, bounced:false});
}
function updateCasings(dt){
  for(let i=casings.length-1;i>=0;i--){
    const c=casings[i];
    c.life+=dt;
    if(c.life>4){ scene.remove(c.m); casings.splice(i,1); continue; }
    c.vy-=9.8*dt;
    c.m.position.x+=c.vx*dt; c.m.position.y+=c.vy*dt; c.m.position.z+=c.vz*dt;
    c.m.rotation.x+=c.rx*dt; c.m.rotation.z+=c.rz*dt;
    const gh=heightAt(c.m.position.x,c.m.position.z);
    if(c.m.position.y<gh+0.02&&c.vy<0){
      c.m.position.y=gh+0.02;
      c.vy*=-0.3; c.vx*=0.5; c.vz*=0.5; c.rx*=0.4; c.rz*=0.4;
      if(!c.bounced){ c.bounced=true; if(c.m.position.distanceTo(camera.position)<8) AudioSys.click(3200,0.08,0.02); }
    }
  }
}
/* 命中特效 */
function impactFX(point,normal,kind){
  const n=normal, p=point;
  if(kind==='ground'||kind==='cyl'){
    for(let i=0;i<4;i++) spawnP(PT.dirt,p.x,p.y+0.05,p.z, n.x*rand(0.5,1.5)+rand(-0.8,0.8), rand(0.8,2.2), n.z*rand(0.5,1.5)+rand(-0.8,0.8), rand(0.2,0.45),0.9,rand(0.4,0.7),0.9,3);
  } else {
    for(let i=0;i<3;i++) spawnP(PT.smoke,p.x+n.x*0.06,p.y+n.y*0.06,p.z+n.z*0.06, n.x*rand(0.4,1.2)+rand(-0.5,0.5), rand(0.3,1.2), n.z*rand(0.4,1.2)+rand(-0.5,0.5), rand(0.15,0.3),0.8,rand(0.3,0.55),0.8,1.5);
    for(let i=0;i<3;i++) spawnP(PT.spark,p.x+n.x*0.05,p.y+n.y*0.05,p.z+n.z*0.05, n.x*rand(1,4)+rand(-2,2), rand(0.5,3), n.z*rand(1,4)+rand(-2,2), rand(0.05,0.12),-0.05,rand(0.15,0.3),1,6,true);
    if(Math.random()<0.25) AudioSys.ricochet(p.distanceTo(camera.position));
  }
}
function bloodFX(p){
  for(let i=0;i<5;i++) spawnP(PT.blood,p.x,p.y,p.z, rand(-1.4,1.4), rand(-0.5,1.4), rand(-1.4,1.4), rand(0.14,0.3),0.5,rand(0.25,0.5),0.95,4);
}
function muzzleFXWorld(pos,dir){
  spawnP(PT.flash,pos.x,pos.y,pos.z, dir.x*2,dir.y*2,dir.z*2, rand(0.3,0.5),1.2,0.06,1,0,true);
  spawnP(PT.smoke,pos.x,pos.y,pos.z, dir.x*1.2+rand(-0.2,0.2),0.6,dir.z*1.2+rand(-0.2,0.2), 0.22,1.1,rand(0.5,0.9),0.4,0.3);
}
/* 爆炸 */
const flashLight=new THREE.PointLight(0xffb060,0,26);
scene.add(flashLight);
let flashTimer=0;
function explosionFX(p){
  spawnP(PT.flash,p.x,p.y+0.6,p.z,0,1.5,0, 2.6,14,0.16,1,0,true);
  spawnP(PT.flash,p.x,p.y+0.9,p.z,0,2,0, 1.6,10,0.22,1,0,true);
  for(let i=0;i<10;i++) spawnP(PT.dark,p.x+rand(-1,1),p.y+rand(0.2,1.6),p.z+rand(-1,1), rand(-2,2),rand(1.5,4.5),rand(-2,2), rand(0.8,1.6),2.2,rand(1.2,2.4),0.85,0.8,false,rand(-1,1));
  for(let i=0;i<12;i++) spawnP(PT.dirt,p.x,p.y+0.3,p.z, rand(-5,5),rand(3,9),rand(-5,5), rand(0.3,0.7),0.6,rand(0.6,1.2),0.95,9);
  for(let i=0;i<8;i++) spawnP(PT.spark,p.x,p.y+0.5,p.z, rand(-8,8),rand(2,9),rand(-8,8), rand(0.1,0.2),-0.05,rand(0.2,0.5),1,10,true);
  flashLight.position.set(p.x,p.y+1.2,p.z);
  flashLight.intensity=60; flashTimer=0.14;
  crater(p.x,p.z,rand(1.2,1.7),true);
}

/* ---------- 武器定义 ---------- */
const WPN_DEFS = {
  garand:   { name:'M1 加兰德', mode:'半自动', type:'semi', snd:'rifle', dmg:36, headMul:2.4, rpm:300, mag:8, reserve:80, reload:2.4, spreadHip:2.2, spreadAds:0.14, recoil:1.35, recSide:0.35, adsFov:52, kick:0.075, enbloc:true, tracer:3 },
  thompson: { name:'汤普森 M1A1', mode:'全自动', type:'auto', snd:'smg', dmg:21, headMul:1.8, rpm:680, mag:30, reserve:150, reload:2.5, spreadHip:3.4, spreadAds:0.85, recoil:0.55, recSide:0.4, adsFov:56, kick:0.045, tracer:4 },
  bar:      { name:'勃朗宁 BAR', mode:'全自动', type:'auto', snd:'mg', dmg:31, headMul:1.9, rpm:480, mag:20, reserve:100, reload:3.0, spreadHip:3.8, spreadAds:0.55, recoil:0.95, recSide:0.55, adsFov:54, kick:0.06, tracer:3 },
  springfield:{ name:'春田 M1903A4', mode:'栓动 · 4x', type:'bolt', snd:'sniper', dmg:96, headMul:2.5, rpm:50, mag:5, reserve:40, reload:3.4, spreadHip:5, spreadAds:0.02, recoil:2.6, recSide:0.5, adsFov:20, kick:0.12, scoped:true, boltT:1.05, tracer:1 },
  m1911:    { name:'柯尔特 M1911', mode:'半自动', type:'semi', snd:'pistol', dmg:26, headMul:2.0, rpm:420, mag:7, reserve:35, reload:1.9, spreadHip:2.6, spreadAds:0.5, recoil:0.8, recSide:0.4, adsFov:60, kick:0.05, pistol:true, tracer:2 },
  kar98:    { name:'毛瑟 Kar98k', mode:'栓动', type:'bolt', snd:'sniper', dmg:88, headMul:2.5, rpm:52, mag:5, reserve:50, reload:3.2, spreadHip:4.5, spreadAds:0.08, recoil:2.4, recSide:0.45, adsFov:48, kick:0.11, boltT:1.0, tracer:1 },
  kar98zf:  { name:'Kar98k ZF41', mode:'栓动 · 4x', type:'bolt', snd:'sniper', dmg:96, headMul:2.5, rpm:50, mag:5, reserve:40, reload:3.4, spreadHip:5, spreadAds:0.02, recoil:2.6, recSide:0.5, adsFov:20, kick:0.12, scoped:true, boltT:1.05, tracer:1 },
  mp40:     { name:'MP40', mode:'全自动', type:'auto', snd:'smg', dmg:20, headMul:1.8, rpm:560, mag:32, reserve:160, reload:2.6, spreadHip:3.0, spreadAds:0.75, recoil:0.45, recSide:0.35, adsFov:56, kick:0.04, tracer:4 },
  stg44:    { name:'STG 44', mode:'全自动', type:'auto', snd:'rifle', dmg:27, headMul:1.9, rpm:550, mag:30, reserve:120, reload:2.8, spreadHip:3.2, spreadAds:0.4, recoil:0.7, recSide:0.45, adsFov:54, kick:0.05, tracer:3 },
  p38:      { name:'瓦尔特 P38', mode:'半自动', type:'semi', snd:'pistol', dmg:24, headMul:2.0, rpm:430, mag:8, reserve:40, reload:1.9, spreadHip:2.6, spreadAds:0.5, recoil:0.75, recSide:0.4, adsFov:60, kick:0.05, pistol:true, tracer:2 },
};
const CLASSES=[
  { name:'步枪兵', us:['garand','m1911'], ger:['kar98','p38'], nades:2, desc0:'M1加兰德 · M1911', desc1:'Kar98k · P38' },
  { name:'冲锋枪手', us:['thompson','m1911'], ger:['mp40','p38'], nades:3, desc0:'汤普森 · M1911', desc1:'MP40 · P38' },
  { name:'突击兵', us:['bar','m1911'], ger:['stg44','p38'], nades:2, desc0:'BAR · M1911 · 反坦克雷×2', desc1:'STG44 · P38 · 反坦克雷×2' },
  { name:'狙击手', us:['springfield','m1911'], ger:['kar98zf','p38'], nades:1, desc0:'春田(4x) · M1911', desc1:'Kar98k ZF(4x) · P38' },
];

/* ---------- 视角模型构建 ---------- */
const vmMats = {
  wood: new THREE.MeshLambertMaterial({map:TEX.wood}),
  woodD: new THREE.MeshLambertMaterial({map:TEX.woodDark}),
  gun: new THREE.MeshLambertMaterial({color:0x2e3030}),
  gunL: new THREE.MeshLambertMaterial({color:0x44484a}),
  brass: new THREE.MeshLambertMaterial({color:0xb89440}),
  sleeve0: new THREE.MeshLambertMaterial({color:0x4d5240}),  // 美军橄榄
  sleeve1: new THREE.MeshLambertMaterial({color:0x4a4d52}),  // 德军灰
  skin: new THREE.MeshLambertMaterial({color:0xc09878}),
  nade: new THREE.MeshLambertMaterial({color:0x3a4232}),
};
function bx(mat,w,h,d,x,y,z,g){
  const m=new THREE.Mesh(new THREE.BoxGeometry(w,h,d),mat);
  m.position.set(x,y,z); if(g) g.add(m); return m;
}
function cyl(mat,r1,r2,h,x,y,z,g,rx=HPI){
  const m=new THREE.Mesh(new THREE.CylinderGeometry(r1,r2,h,10),mat);
  m.position.set(x,y,z); m.rotation.x=rx; if(g) g.add(m); return m;
}
/* 构建各枪 (指向 -z), 返回 {gun,bolt,mag,clip,muzzle,boltHandle} */
function buildGunModel(key){
  const G=new THREE.Group();
  const parts={gun:G, muzzle:V3(0,0.032,-0.62)};
  const wd=vmMats.wood, gm=vmMats.gun, gl=vmMats.gunL;
  if(key==='garand'){
    bx(wd,0.052,0.075,0.62, 0,-0.012,-0.09,G);            // 枪身木托
    bx(wd,0.05,0.1,0.24, 0,-0.06,0.2,G).rotation.x=-0.22; // 枪托
    cyl(gm,0.012,0.012,0.46, 0,0.032,-0.42,G);            // 枪管
    bx(gm,0.05,0.045,0.3, 0,0.03,-0.05,G);                // 机匣
    bx(gm,0.012,0.03,0.012, 0,0.062,-0.635,G);            // 准星
    bx(gm,0.032,0.01,0.02, 0,0.045,0.03,G);               // 照门底座
    bx(gm,0.007,0.026,0.02, -0.0125,0.062,0.03,G);        // 左护翼
    bx(gm,0.007,0.026,0.02, 0.0125,0.062,0.03,G);         // 右护翼
    parts.bolt=bx(gl,0.022,0.02,0.09, 0.03,0.04,-0.02,G);
    parts.clip=bx(vmMats.brass,0.022,0.05,0.06, 0,0.05,-0.04); // 供动画用,平时隐藏
    parts.clip.visible=false; G.add(parts.clip);
    parts.muzzle.set(0,0.032,-0.66);
  } else if(key==='thompson'){
    bx(gm,0.05,0.06,0.34, 0,0.01,-0.1,G);
    cyl(gm,0.013,0.013,0.3, 0,0.028,-0.4,G);
    cyl(gl,0.02,0.02,0.09, 0,0.028,-0.52,G);              // 散热套
    bx(wd,0.045,0.09,0.2, 0,-0.055,0.18,G).rotation.x=-0.2;
    bx(wd,0.04,0.08,0.06, 0,-0.06,0.02,G);                // 握把
    bx(wd,0.04,0.07,0.09, 0,-0.045,-0.33,G);              // 前握把
    parts.mag=bx(gm,0.025,0.16,0.06, 0,-0.11,-0.14,G);
    bx(gm,0.012,0.026,0.012, 0,0.058,-0.53,G);
    bx(gm,0.03,0.008,0.02, 0,0.042,0.0,G);
    bx(gm,0.006,0.026,0.02, -0.012,0.056,0.0,G);
    bx(gm,0.006,0.026,0.02, 0.012,0.056,0.0,G);
    parts.bolt=bx(gl,0.018,0.018,0.05, 0.032,0.035,-0.06,G);
    parts.muzzle.set(0,0.028,-0.58);
  } else if(key==='bar'){
    bx(wd,0.055,0.075,0.3, 0,-0.01,0.06,G);
    bx(gm,0.055,0.06,0.34, 0,0.012,-0.2,G);
    cyl(gm,0.014,0.014,0.4, 0,0.03,-0.55,G);
    bx(wd,0.05,0.1,0.22, 0,-0.055,0.24,G).rotation.x=-0.2;
    parts.mag=bx(gm,0.028,0.14,0.07, 0,-0.1,-0.16,G);
    bx(gm,0.013,0.032,0.013, 0,0.062,-0.72,G);
    bx(gm,0.032,0.008,0.02, 0,0.046,-0.02,G);
    bx(gm,0.007,0.028,0.02, -0.013,0.061,-0.02,G);
    bx(gm,0.007,0.028,0.02, 0.013,0.061,-0.02,G);
    cyl(gl,0.008,0.008,0.24, 0.03,-0.05,-0.66,G,0.3);     // 两脚架(收起)
    cyl(gl,0.008,0.008,0.24, -0.03,-0.05,-0.66,G,0.3);
    parts.bolt=bx(gl,0.02,0.02,0.06, 0.036,0.03,-0.12,G);
    parts.muzzle.set(0,0.03,-0.76);
  } else if(key==='springfield'||key==='kar98'||key==='kar98zf'){
    bx(wd,0.05,0.07,0.7, 0,-0.01,-0.12,G);
    bx(wd,0.05,0.1,0.26, 0,-0.055,0.24,G).rotation.x=-0.2;
    cyl(gm,0.011,0.011,0.42, 0,0.03,-0.55,G);
    bx(gm,0.045,0.04,0.22, 0,0.03,0.0,G);
    bx(gm,0.012,0.03,0.012, 0,0.06,-0.745,G);
    if(key==='kar98'){
      // V型照门(开口)
      const vl=bx(gm,0.013,0.017,0.026, -0.011,0.054,-0.1,G); vl.rotation.z=0.45;
      const vr=bx(gm,0.013,0.017,0.026, 0.011,0.054,-0.1,G); vr.rotation.z=-0.45;
      bx(gm,0.03,0.007,0.026, 0,0.043,-0.1,G);
    } else {
      // 瞄准镜
      cyl(gl,0.021,0.021,0.14, 0,0.085,-0.02,G);
      cyl(gl,0.026,0.021,0.03, 0,0.085,-0.1,G);
      cyl(gl,0.026,0.021,0.03, 0,0.085,0.06,G);
      bx(gm,0.012,0.03,0.03, 0,0.062,-0.02,G);
    }
    // 栓
    const boltG=new THREE.Group(); boltG.position.set(0.024,0.038,0.05); G.add(boltG);
    cyl(gl,0.012,0.012,0.1, 0,0,0,boltG);
    const handle=new THREE.Group(); boltG.add(handle);
    cyl(gl,0.007,0.007,0.05, 0.025,-0.01,0.02,handle,0);
    handle.children[0].rotation.z=1.2;
    const knob=new THREE.Mesh(new THREE.SphereGeometry(0.013,8,6),gl);
    knob.position.set(0.048,-0.02,0.02); handle.add(knob);
    parts.bolt=boltG; parts.boltHandle=handle;
    parts.clip=bx(vmMats.brass,0.02,0.055,0.05, 0,0.05,0.0);
    parts.clip.visible=false; G.add(parts.clip);
    parts.muzzle.set(0,0.03,-0.77);
  } else if(key==='mp40'){
    bx(gm,0.045,0.055,0.3, 0,0.01,-0.08,G);
    cyl(gm,0.012,0.012,0.24, 0,0.024,-0.35,G);
    bx(gm,0.04,0.05,0.1, 0,-0.03,0.06,G);                 // 握把座
    bx(vmMats.woodD,0.035,0.07,0.05, 0,-0.07,0.09,G);     // 胶木握把
    parts.mag=bx(gm,0.022,0.17,0.055, 0,-0.115,-0.14,G);
    cyl(gl,0.006,0.006,0.2, 0.02,-0.005,0.22,G);          // 折叠托架
    cyl(gl,0.006,0.006,0.2, -0.02,-0.005,0.22,G);
    bx(gl,0.04,0.014,0.04, 0,-0.06,0.3,G);
    bx(gm,0.011,0.024,0.011, 0,0.05,-0.46,G);
    bx(gm,0.028,0.007,0.02, 0,0.038,-0.02,G);
    bx(gm,0.006,0.022,0.02, -0.011,0.05,-0.02,G);
    bx(gm,0.006,0.022,0.02, 0.011,0.05,-0.02,G);
    parts.bolt=bx(gl,0.016,0.016,0.05, -0.03,0.028,-0.1,G);
    parts.muzzle.set(0,0.024,-0.5);
  } else if(key==='stg44'){
    bx(gm,0.05,0.065,0.36, 0,0.005,-0.12,G);
    cyl(gm,0.013,0.013,0.26, 0,0.028,-0.42,G);
    bx(wd,0.045,0.085,0.2, 0,-0.045,0.22,G).rotation.x=-0.12;
    bx(vmMats.woodD,0.038,0.075,0.055, 0,-0.07,0.05,G);
    parts.mag=bx(gm,0.026,0.19,0.065, 0,-0.11,-0.15,G);
    parts.mag.rotation.x=0.25;                             // 弧形弹匣
    bx(gm,0.012,0.032,0.012, 0,0.06,-0.53,G);
    bx(gm,0.03,0.008,0.02, 0,0.043,-0.06,G);
    bx(gm,0.006,0.026,0.02, -0.012,0.057,-0.06,G);
    bx(gm,0.006,0.026,0.02, 0.012,0.057,-0.06,G);
    parts.bolt=bx(gl,0.018,0.018,0.05, 0.033,0.02,-0.16,G);
    parts.muzzle.set(0,0.028,-0.56);
  } else { // 手枪
    bx(gm,0.032,0.05,0.19, 0,0.02,-0.02,G);
    parts.slide=bx(gl,0.034,0.032,0.2, 0,0.045,-0.02,G);
    bx(gm,0.03,0.09,0.05, 0,-0.04,0.05,G).rotation.x=-0.25;
    parts.mag=bx(gm,0.024,0.07,0.036, 0,-0.055,0.048,G);
    parts.mag.rotation.x=-0.25;
    bx(gm,0.008,0.018,0.008, 0,0.068,-0.115,G);
    bx(gm,0.005,0.013,0.01, -0.009,0.067,0.07,G);
    bx(gm,0.005,0.013,0.01, 0.009,0.067,0.07,G);
    cyl(gm,0.006,0.01,0.03, 0,-0.005,-0.065,G,0.5);       // 扳机护圈近似
    parts.muzzle.set(0,0.045,-0.14);
  }
  G.traverse(o=>{ if(o.isMesh){ o.castShadow=false; o.receiveShadow=false; } });
  return parts;
}
/* 手臂 */
function buildArms(team){
  const sleeve=team===0?vmMats.sleeve0:vmMats.sleeve1;
  const mk=()=>{
    const g=new THREE.Group();
    bx(sleeve,0.07,0.07,0.3, 0,0,0.1,g);
    bx(vmMats.skin,0.055,0.05,0.1, 0,0,-0.09,g);
    return g;
  };
  return { L:mk(), R:mk() };
}
/* 手雷模型 */
function buildNadeModel(team){
  const g=new THREE.Group();
  if(team===0){
    const b=new THREE.Mesh(new THREE.SphereGeometry(0.032,10,8),vmMats.nade);
    b.scale.y=1.25; g.add(b);
    bx(vmMats.gunL,0.012,0.03,0.012, 0.012,0.04,0,g);
  } else {
    cyl(vmMats.nade,0.02,0.02,0.07, 0,0.01,0,g,0);
    cyl(vmMats.woodD,0.011,0.011,0.11, 0,-0.07,0,g,0);
  }
  return g;
}
/* 刀 */
function buildKnife(){
  const g=new THREE.Group();
  bx(vmMats.woodD,0.02,0.025,0.09, 0,0,0.045,g);
  const blade=bx(new THREE.MeshLambertMaterial({color:0x9aa2a8}),0.006,0.028,0.15, 0,0.004,-0.075,g);
  return g;
}

/* ---------- 视角模型控制器 ---------- */
const VM = {
  root:new THREE.Group(), sway:new THREE.Group(), inner:new THREE.Group(),
  gunParts:null, arms:null, nadeM:null, knifeM:null,
  key:null, state:'idle', stateT:0, stateDur:0,
  recoilP:0, recoilV:0, kickZ:0, kickV:0,
  swayX:0, swayY:0, bobT:0, lastStepSide:1,
  adsBlend:0, sprintBlend:0, crouchO:0,
  muzzleFlash:null, muzzleLight:null,
};
vmScene.add(VM.root); VM.root.add(VM.sway); VM.sway.add(VM.inner);
{
  const mat=new THREE.SpriteMaterial({map:PT.flash,transparent:true,opacity:0,blending:THREE.AdditiveBlending,depthWrite:false});
  VM.muzzleFlash=new THREE.Sprite(mat); VM.muzzleFlash.scale.setScalar(0.16);
  VM.inner.add(VM.muzzleFlash);
  VM.muzzleLight=new THREE.PointLight(0xffc070,0,3);
  VM.inner.add(VM.muzzleLight);
}
const HIP={ pos:V3(0.17,-0.185,-0.38), rot:V3(0.02,0.03,0) };
const HIP_PISTOL={ pos:V3(0.15,-0.16,-0.32), rot:V3(0.02,0.02,0) };
function adsPoseFor(key){
  const d=WPN_DEFS[key];
  if(d.scoped) return { pos:V3(0,-0.085,-0.22), rot:V3(0,0,0) };
  if(d.pistol) return { pos:V3(0,-0.066,-0.24), rot:V3(0,0,0) };
  if(key==='mp40') return { pos:V3(0,-0.05,-0.26), rot:V3(0,0,0) };
  if(key==='stg44') return { pos:V3(0,-0.058,-0.26), rot:V3(0,0,0) };
  if(key==='thompson') return { pos:V3(0,-0.057,-0.26), rot:V3(0,0,0) };
  if(key==='bar') return { pos:V3(0,-0.062,-0.26), rot:V3(0,0,0) };
  if(key==='kar98') return { pos:V3(0,-0.059,-0.3), rot:V3(0,0,0) };
  return { pos:V3(0,-0.062,-0.3), rot:V3(0,0,0) }; // garand
}
function vmEquip(key,team){
  VM.key=key;
  VM.inner.clear();
  VM.inner.add(VM.muzzleFlash); VM.inner.add(VM.muzzleLight);
  VM.gunParts=buildGunModel(key);
  VM.inner.add(VM.gunParts.gun);
  VM.arms=buildArms(team);
  VM.inner.add(VM.arms.L); VM.inner.add(VM.arms.R);
  VM.nadeM=buildNadeModel(team); VM.nadeM.visible=false; VM.inner.add(VM.nadeM);
  VM.knifeM=buildKnife(); VM.knifeM.visible=false; VM.inner.add(VM.knifeM);
  VM.ads=adsPoseFor(key);
  VM.state='draw'; VM.stateT=0; VM.stateDur=0.45;
  positionHands(key);
  AudioSys.metalSlide(0.18,0.1,700,1400);
}
function positionHands(key){
  const d=WPN_DEFS[key];
  if(d.pistol){
    VM.arms.R.position.set(0.015,-0.06,0.13); VM.arms.R.rotation.set(0.25,0,0);
    VM.arms.L.position.set(-0.03,-0.08,0.1); VM.arms.L.rotation.set(0.3,0.25,0);
  } else {
    VM.arms.R.position.set(0.02,-0.07,0.2); VM.arms.R.rotation.set(0.32,0,0);
    VM.arms.L.position.set(-0.015,-0.075,-0.2); VM.arms.L.rotation.set(0.28,-0.1,0);
  }
}
/* smoothstep分段工具: 在[a,b]内返回0→1 */
function ss(t,a,b){ const x=clamp((t-a)/(b-a),0,1); return x*x*(3-2*x); }
/* 三角脉冲: a→b 升, b→c 降 */
function pulse(t,a,b,c){ return t<b? ss(t,a,b) : 1-ss(t,b,c); }

let vmSndFlags={};
function vmPlayOnce(id,fn){ if(!vmSndFlags[id]){ vmSndFlags[id]=true; fn(); } }

function updateViewModel(dt,player){
  const d=WPN_DEFS[VM.key]||{};
  const w=player.curW;
  VM.stateT+=dt;
  const t=VM.stateT, T=VM.stateDur, nt=T>0?clamp(t/T,0,1):1;
  // ---- 基础姿态混合 (hip/ads/sprint) ----
  const adsTgt=(player.ads&&(VM.state==='idle'||VM.state==='fire'||VM.state==='bolt'))?1:0;
  VM.adsBlend=dampF(VM.adsBlend,adsTgt,14,dt);
  const sprTgt=(player.sprinting&&VM.state==='idle')?1:0;
  VM.sprintBlend=dampF(VM.sprintBlend,sprTgt,8,dt);
  const hip=d.pistol?HIP_PISTOL:HIP;
  const px=lerp(hip.pos.x,VM.ads.pos.x,VM.adsBlend);
  const py=lerp(hip.pos.y,VM.ads.pos.y,VM.adsBlend);
  const pz=lerp(hip.pos.z,VM.ads.pos.z,VM.adsBlend);
  let rx=lerp(hip.rot.x,VM.ads.rot.x,VM.adsBlend);
  let ry=lerp(hip.rot.y,VM.ads.rot.y,VM.adsBlend);
  let rz=0;
  // 疾跑姿态
  rx+=VM.sprintBlend*0.5; ry+=VM.sprintBlend*0.55; rz-=VM.sprintBlend*0.25;
  let ox=VM.sprintBlend*-0.04, oy=VM.sprintBlend*-0.05, oz=VM.sprintBlend*0.06;

  // ---- 呼吸/走路摆动 ----
  const speed2d=Math.hypot(player.vel.x,player.vel.z);
  const moving=speed2d>0.5&&player.onGround;
  const bobRate=player.sprinting?11:(player.crouch?5:7.4);
  if(moving) VM.bobT+=dt*bobRate*clamp(speed2d/4.2,0.4,1.6);
  const bobA=(moving?1:0)*(player.sprinting?1.7:1)*(1-VM.adsBlend*0.82);
  ox+=Math.sin(VM.bobT)*0.011*bobA;
  oy+=(-Math.abs(Math.cos(VM.bobT))*0.012+Math.sin(VM.bobT*2)*0.003)*bobA;
  rz+=Math.sin(VM.bobT)*0.012*bobA;
  // 呼吸
  const brT=performance.now()*0.001;
  const breathMul=(player.holdBreath&&player.ads)?0.12:1;
  oy+=Math.sin(brT*1.4)*0.0022*(1-VM.adsBlend*0.5)*breathMul;
  rx+=Math.sin(brT*1.4+0.5)*0.0018*breathMul;

  // ---- 鼠标摆动 sway ----
  VM.swayX=dampF(VM.swayX,clamp(player.mouseDX,-40,40),10,dt);
  VM.swayY=dampF(VM.swayY,clamp(player.mouseDY,-40,40),10,dt);
  const swAmt=0.0011*(1-VM.adsBlend*0.75);
  VM.sway.position.x=-VM.swayX*swAmt;
  VM.sway.position.y=VM.swayY*swAmt*0.8;
  VM.sway.rotation.z=-VM.swayX*0.0006*(1-VM.adsBlend*0.6);
  VM.sway.rotation.x=VM.swayY*0.0007*(1-VM.adsBlend*0.6);
  // 落地下沉
  oy-=player.landDip*0.05; rx+=player.landDip*0.12;

  // ---- 后座弹簧 ----
  VM.recoilV+=(-VM.recoilP*260-VM.recoilV*18)*dt;
  VM.recoilP+=VM.recoilV*dt;
  VM.kickV+=(-VM.kickZ*220-VM.kickV*16)*dt;
  VM.kickZ+=VM.kickV*dt;
  rx+=VM.recoilP; oz+=VM.kickZ;

  // ---- 状态动画 ----
  const g=VM.gunParts;
  let handLOverride=null, handROverride=null;
  if(g.bolt&&!g.boltHandle) g.bolt.position.z=g.bolt.userData.z0??(g.bolt.userData.z0=g.bolt.position.z);
  if(g.mag){ g.mag.userData.p0??(g.mag.userData.p0=g.mag.position.clone()); }
  if(VM.state==='draw'){
    const k=1-ss(nt,0,1);
    oy-=k*0.25; rx+=k*0.9; rz+=k*0.3;
    if(nt>=1) VM.state='idle';
  }
  else if(VM.state==='melee'){
    const k=pulse(nt,0,0.35,1);
    VM.knifeM.visible=true; g.gun.visible=false;
    VM.arms.L.visible=false;
    VM.knifeM.position.set(0.1-k*0.22,-0.12+k*0.06,-0.25-k*0.28);
    VM.knifeM.rotation.set(-k*1.1,0.3-k*0.5,k*0.4);
    handROverride={pos:V3(0.1-k*0.2,-0.16+k*0.05,-0.1-k*0.2),rot:V3(0.4-k*1.0,0,0)};
    if(nt>0.3) vmPlayOnce('meleeHit',()=>player.doMeleeHit());
    if(nt>=1){ VM.state='idle'; VM.knifeM.visible=false; g.gun.visible=true; VM.arms.L.visible=true; vmSndFlags={}; }
  }
  else if(VM.state==='nade'){
    VM.nadeM.visible=true; g.gun.visible=false;
    if(player.nadeHeld){
      // 持雷待掷
      const k=ss(Math.min(t,0.3),0,0.3);
      VM.nadeM.position.set(0.14,-0.12+k*0.04,-0.3+k*0.05);
      VM.nadeM.rotation.set(0,0,0);
      handROverride={pos:V3(0.13,-0.15,-0.22),rot:V3(0.5,0,0)};
      handLOverride={pos:V3(-0.1,-0.2,-0.2),rot:V3(0.3,0,0)};
    } else {
      // 投掷动作
      const k=nt;
      const back=pulse(k,0,0.3,0.55), fwd=ss(k,0.35,0.6);
      VM.nadeM.position.set(0.14+back*0.1,-0.08+back*0.16-fwd*0.1,-0.3+back*0.25-fwd*0.55);
      VM.nadeM.visible=k<0.55;
      handROverride={pos:V3(0.13+back*0.08,-0.12+back*0.14-fwd*0.18,-0.18+back*0.2-fwd*0.35),rot:V3(0.5+back*1.2-fwd*2.2,0,0)};
      if(k>0.5) vmPlayOnce('nadeThrow',()=>player.releaseNade());
      if(nt>=1){ VM.state='idle'; g.gun.visible=true; VM.nadeM.visible=false; vmSndFlags={}; }
    }
  }
  else if(VM.state==='reload'){
    const k=nt;
    // 通用低头姿态
    const dip=pulse(k,0,0.15,0.92);
    rx+=dip*0.32; ry+=dip*0.18; oy-=dip*0.05; rz+=dip*0.1;
    if(d.enbloc){
      // 加兰德: 弹夹从上压入
      if(g.bolt){ g.bolt.position.z=(g.bolt.userData.z0)+pulse(k,0.05,0.2,0.85)*0.08; }
      vmPlayOnce('r1',()=>{}); 
      if(k>0.1) vmPlayOnce('r2',()=>AudioSys.metalSlide(0.25,0.09,1000,1800));
      if(g.clip){
        g.clip.visible=k>0.25&&k<0.62;
        const ins=ss(k,0.3,0.6);
        g.clip.position.set(0,0.05+(1-ins)*0.1,-0.04);
      }
      if(k>0.28&&k<0.62) handROverride={pos:V3(0.03,0.02+(1-ss(k,0.3,0.6))*0.1,-0.03),rot:V3(-0.5,0,0)};
      if(k>0.58) vmPlayOnce('r3',()=>AudioSys.click(1500,0.3,0.04));
      if(k>0.8) vmPlayOnce('r4',()=>AudioSys.metalSlide(0.3,0.07,1400,900));
    } else if(d.type==='bolt'){
      // 栓动: 开栓→压弹→闭栓
      const bo=pulse(k,0.05,0.22,0.78);
      if(g.boltHandle) g.boltHandle.rotation.z=bo*-1.1;
      if(g.bolt) g.bolt.position.z=(g.bolt.userData.z0??0.05)+pulse(k,0.12,0.3,0.72)*0.09;
      if(k>0.08) vmPlayOnce('r1',()=>AudioSys.metalSlide(0.22,0.1,800,1500));
      if(g.clip){
        g.clip.visible=k>0.3&&k<0.62;
        const ins=ss(k,0.34,0.58);
        g.clip.position.set(0,0.05+(1-ins)*0.09,0.0);
      }
      if(k>0.32&&k<0.64) handROverride={pos:V3(0.03,0.04+(1-ss(k,0.34,0.58))*0.09,0.02),rot:V3(-0.5,0,0)};
      if(k>0.56) vmPlayOnce('r2',()=>AudioSys.click(1300,0.28,0.04));
      if(k>0.78) vmPlayOnce('r3',()=>AudioSys.metalSlide(0.25,0.09,1500,800));
    } else {
      // 弹匣武器
      const p0=g.mag?g.mag.userData.p0:null;
      const out=pulse(k,0.08,0.3,0.34), inn=ss(k,0.42,0.66);
      if(g.mag&&p0){
        g.mag.position.set(p0.x, p0.y-out*0.18-(1-inn)*(k>0.36?0.18:0), p0.z+out*0.03);
        g.mag.visible=!(k>0.34&&k<0.4);
      }
      if(k>0.1) vmPlayOnce('r1',()=>AudioSys.click(900,0.22,0.04));
      if(k>0.3) vmPlayOnce('r2',()=>AudioSys.metalSlide(0.15,0.08,600,300));
      if(k>0.62) vmPlayOnce('r3',()=>AudioSys.click(1400,0.3,0.05));
      // 拉机柄
      if(k>0.74){
        const ch=pulse(k,0.74,0.84,0.96);
        if(g.bolt) g.bolt.position.z=(g.bolt.userData.z0??0)+ch*0.07;
        if(g.slide) g.slide.position.z=-0.02+ch*0.055;
        vmPlayOnce('r4',()=>AudioSys.metalSlide(0.3,0.1,1200,700));
      }
      // 左手跟随弹匣
      if(k>0.06&&k<0.7) handLOverride={pos:V3(-0.01,-0.1-out*0.15-(1-inn)*(k>0.36?0.15:0),-0.13),rot:V3(0.5,0,0)};
      else if(k>=0.7&&k<0.97) handLOverride={pos:V3(0.02,-0.02,-0.05),rot:V3(0.3,0,0)};
    }
    if(nt>=1){ VM.state='idle'; vmSndFlags={}; player.finishReload(); }
  }
  else if(VM.state==='bolt'){
    // 栓动循环: 上抬→后拉(抛壳)→前推→下压
    const k=nt;
    rx+=pulse(k,0,0.3,1)*0.14; ry+=pulse(k,0,0.3,1)*0.1;
    oy-=pulse(k,0,0.3,1)*0.02;
    if(g.boltHandle) g.boltHandle.rotation.z=pulse(k,0.02,0.2,0.85)*-1.1;
    if(g.bolt) g.bolt.position.z=(g.bolt.userData.z0??0.05)+pulse(k,0.18,0.4,0.72)*0.09;
    handROverride={pos:V3(0.05,-0.01+pulse(k,0.02,0.25,0.9)*0.05,0.08),rot:V3(-0.3,0,0)};
    if(k>0.05) vmPlayOnce('b1',()=>AudioSys.click(1100,0.2,0.03));
    if(k>0.3) vmPlayOnce('b2',()=>{
      AudioSys.metalSlide(0.22,0.08,900,1600);
      const cp=camera.position.clone().add(camForward().multiplyScalar(0.4)).add(camRight().multiplyScalar(0.15));
      spawnCasing(cp,camRight(),V3(0,1,0));
    });
    if(k>0.65) vmPlayOnce('b3',()=>AudioSys.metalSlide(0.22,0.08,1600,900));
    if(k>0.88) vmPlayOnce('b4',()=>AudioSys.click(1300,0.22,0.03));
    if(nt>=1){ VM.state='idle'; vmSndFlags={}; }
  }
  // ---- 应用 ----
  VM.root.position.set(px+ox,py+oy,pz+oz);
  VM.root.rotation.set(rx,ry,rz);
  // 手位置
  positionHands(VM.key);
  if(handLOverride){ VM.arms.L.position.copy(handLOverride.pos); VM.arms.L.rotation.set(handLOverride.rot.x,handLOverride.rot.y,handLOverride.rot.z); }
  if(handROverride){ VM.arms.R.position.copy(handROverride.pos); VM.arms.R.rotation.set(handROverride.rot.x,handROverride.rot.y,handROverride.rot.z); }
  // 枪口闪光衰减
  VM.muzzleFlash.material.opacity*=Math.pow(0.0001,dt);
  VM.muzzleLight.intensity*=Math.pow(0.0001,dt);
  // ADS时武器隐藏(狙击镜)
  const scopedHide=d.scoped&&VM.adsBlend>0.85;
  VM.inner.visible=!scopedHide;
  document.getElementById('scopeOv').style.display=scopedHide?'block':'none';
  // FOV
  const tgtFov=lerp(74, d.adsFov||60, VM.adsBlend)+(player.sprinting?4*VM.sprintBlend:0);
  camera.fov=dampF(camera.fov,tgtFov,16,dt);
  camera.updateProjectionMatrix();
}
function vmFireKick(){
  const d=WPN_DEFS[VM.key];
  VM.recoilV+=d.recoil*(2.2+rand(-0.3,0.3));
  VM.kickV+=d.kick*22;
  VM.muzzleFlash.position.copy(VM.gunParts.muzzle);
  VM.muzzleFlash.material.opacity=1;
  VM.muzzleFlash.material.rotation=rand(0,TAU);
  VM.muzzleFlash.scale.setScalar(rand(0.12,0.2)*(d.snd==='sniper'?1.5:1));
  VM.muzzleLight.position.copy(VM.gunParts.muzzle);
  VM.muzzleLight.intensity=2.2;
}
const _fwd=V3(), _rgt=V3(), _upv=V3(0,1,0);
function camForward(){ camera.getWorldDirection(_fwd); return _fwd.clone(); }
function camRight(){ camera.getWorldDirection(_fwd); _rgt.crossVectors(_fwd,_upv).normalize(); return _rgt.clone(); }

/* ================================================================
   模块D:士兵模型 / 命中检测 / 手雷 / BOT AI
================================================================ */
const soldiers=[];      // 所有bot
const combatants=[];    // bot + 玩家 (统一接口)
let gunEvents=[];       // 枪声事件 {x,z,team,t}
function angleLerpTo(cur,tgt,maxStep){
  let d=tgt-cur;
  while(d>Math.PI)d-=TAU; while(d<-Math.PI)d+=TAU;
  return cur+clamp(d,-maxStep,maxStep);
}
function angDiff(a,b){ let d=b-a; while(d>Math.PI)d-=TAU; while(d<-Math.PI)d+=TAU; return d; }

/* ---------- 士兵模型 ---------- */
const uniformMats=[
  { coat:new THREE.MeshLambertMaterial({color:0x57604a}), pants:new THREE.MeshLambertMaterial({color:0x8a7f5e}),
    helm:new THREE.MeshLambertMaterial({color:0x4a5240}), skin:new THREE.MeshLambertMaterial({color:0xc09878}) },
  { coat:new THREE.MeshLambertMaterial({color:0x555a60}), pants:new THREE.MeshLambertMaterial({color:0x46484e}),
    helm:new THREE.MeshLambertMaterial({color:0x3e4246}), skin:new THREE.MeshLambertMaterial({color:0xc8a080}) },
];
const worldGunGeoCache={};
function worldGunModel(kind){
  const g=new THREE.Group();
  const gm=vmMats.gun, wd=vmMats.wood;
  if(kind==='smg'){
    bx(gm,0.05,0.06,0.5,0,0,-0.1,g);
    bx(gm,0.024,0.14,0.05,0,-0.09,-0.12,g);
  } else if(kind==='mg'){
    bx(gm,0.06,0.07,0.8,0,0,-0.15,g);
    bx(wd,0.05,0.09,0.18,0,-0.04,0.28,g);
  } else {
    bx(wd,0.05,0.07,0.75,0,0,-0.05,g);
    cyl(gm,0.012,0.012,0.3,0,0.02,-0.55,g);
    bx(wd,0.05,0.09,0.2,0,-0.05,0.32,g);
  }
  g.traverse(o=>{ if(o.isMesh) o.castShadow=true; });
  return g;
}
function gunKindOf(key){
  const d=WPN_DEFS[key];
  if(d.pistol) return 'smg';
  if(key==='thompson'||key==='mp40') return 'smg';
  if(key==='bar'||key==='stg44') return 'mg';
  return 'rifle';
}
function buildSoldierMesh(team,name){
  const U=uniformMats[team];
  const root=new THREE.Group();
  const pelvis=new THREE.Group(); pelvis.position.y=0.94; root.add(pelvis);
  const torso=bx(U.coat,0.44,0.55,0.26, 0,0.28,0, pelvis);
  const headG=new THREE.Group(); headG.position.set(0,0.62,0); pelvis.add(headG);
  const head=bx(U.skin,0.2,0.22,0.2, 0,0.1,0, headG);
  let helm;
  if(team===0){
    helm=new THREE.Mesh(new THREE.SphereGeometry(0.15,10,8,0,TAU,0,HPI*1.2),U.helm);
    helm.position.set(0,0.16,0);
  } else {
    helm=new THREE.Mesh(new THREE.SphereGeometry(0.155,10,8,0,TAU,0,HPI*1.35),U.helm);
    helm.position.set(0,0.15,0); helm.scale.z=1.15;
  }
  headG.add(helm);
  // 四肢
  const mkLimb=(mat,w,len)=>{
    const g=new THREE.Group();
    const seg=bx(mat,w,len,w, 0,-len/2,0, g);
    return g;
  };
  const armL=mkLimb(U.coat,0.13,0.62); armL.position.set(-0.28,0.5,0); pelvis.add(armL);
  const armR=mkLimb(U.coat,0.13,0.62); armR.position.set(0.28,0.5,0); pelvis.add(armR);
  const legL=mkLimb(U.pants,0.16,0.92); legL.position.set(-0.12,0.0,0); pelvis.add(legL);
  const legR=mkLimb(U.pants,0.16,0.92); legR.position.set(0.12,0.0,0); pelvis.add(legR);
  // 背包/装具
  bx(team===0?U.pants:U.coat,0.3,0.34,0.14, 0,0.3,0.18, pelvis);
  bx(new THREE.MeshLambertMaterial({color:0x3a3428}),0.4,0.08,0.28, 0,0.02,0, pelvis);
  root.traverse(o=>{ if(o.isMesh){ o.castShadow=true; } });
  // 名牌
  const c=document.createElement('canvas'); c.width=128; c.height=32;
  const g2=c.getContext('2d');
  g2.font='bold 20px sans-serif'; g2.textAlign='center';
  g2.fillStyle=team===0?'#a8ccff':'#ffb3a6';
  g2.strokeStyle='rgba(0,0,0,.8)'; g2.lineWidth=4;
  g2.strokeText(name,64,22); g2.fillText(name,64,22);
  const tag=new THREE.Sprite(new THREE.SpriteMaterial({map:new THREE.CanvasTexture(c),transparent:true,depthTest:false}));
  tag.scale.set(0.9,0.22,1); tag.position.y=2.05; tag.renderOrder=5;
  root.add(tag);
  scene.add(root);
  return {root,pelvis,headG,armL,armR,legL,legR,tag,torso};
}

/* ---------- 命中检测 ---------- */
function raySoldiers(o,dir,maxD,shooter){
  let best=null,bestT=maxD;
  for(const s of combatants){
    if(!s.alive||s===shooter||s.onVehicle) continue;
    if(shooter&&s.team===shooter.team) continue;
    // 头
    const eyeY=s.pos.y+(s.crouch?1.15:1.62);
    const hx=s.pos.x-o.x, hy=eyeY+0.06-o.y, hz=s.pos.z-o.z;
    const tProj=hx*dir.x+hy*dir.y+hz*dir.z;
    if(tProj>0.2&&tProj<bestT){
      const px=o.x+dir.x*tProj-s.pos.x, py=o.y+dir.y*tProj-(eyeY+0.06), pz=o.z+dir.z*tProj-s.pos.z;
      if(px*px+py*py+pz*pz<0.26*0.26){ best={sol:s,t:tProj,head:true}; bestT=tProj; continue; }
    }
    // 躯干圆柱
    const c={x:s.pos.x,z:s.pos.z,r:0.38,y0:s.pos.y,y1:s.pos.y+(s.crouch?1.25:1.55)};
    const r=rayCyl(o,dir,c,bestT);
    if(r){ best={sol:s,t:r.t,head:false}; bestT=r.t; }
  }
  return best;
}
/* ---------- 开火(通用) ---------- */
function fireBullet(shooter,origin,dir,def,visMuzzle){
  const maxD=400;
  const wr=raycastWorld(origin,dir,maxD);
  const wallD=wr?wr.dist:maxD;
  const sr=raySoldiers(origin,dir,wallD,shooter);
  let endD;
  if(sr){
    endD=sr.t;
    const hitP=V3(origin.x+dir.x*sr.t,origin.y+dir.y*sr.t,origin.z+dir.z*sr.t);
    bloodFX(hitP);
    let dmg=def.dmg*(sr.head?def.headMul:1);
    if(!shooter.isPlayer) dmg*=DIFF_TABLE[SETTINGS.diff].dmgMul;
    // 距离衰减
    dmg*=clamp(1-Math.max(0,sr.t-40)/220,0.45,1);
    sr.sol.damage(dmg,shooter,sr.head);
    if(shooter.isPlayer) onPlayerHit(sr.sol,sr.head);
  } else if(wr){
    endD=wr.dist;
    impactFX(wr.point,wr.normal,wr.kind);
  } else endD=maxD;
  // 曳光
  if(Math.random()<1/(def.tracer||3)) spawnTracer(origin.clone().addScaledVector(dir,1.2),dir,Math.max(1,endD-1.5));
  // 枪口特效(世界)
  if(visMuzzle) muzzleFXWorld(visMuzzle,dir);
  // 音效
  const dCam=origin.distanceTo(camera.position);
  const rv=camRight();
  const pan=clamp(rv.dot(V3(origin.x-camera.position.x,0,origin.z-camera.position.z).normalize()),-1,1);
  if(!shooter.isPlayer) AudioSys.gunshot(def.snd,dCam,pan*0.7);
  // 子弹掠过玩家
  if(!shooter.isPlayer&&shooter.team!==player.team&&player.alive){
    const relX=player.pos.x-origin.x, relY=player.pos.y+1.5-origin.y, relZ=player.pos.z-origin.z;
    const tp=relX*dir.x+relY*dir.y+relZ*dir.z;
    if(tp>2&&tp<endD){
      const cx=origin.x+dir.x*tp-player.pos.x, cy=origin.y+dir.y*tp-(player.pos.y+1.5), cz=origin.z+dir.z*tp-player.pos.z;
      const d2=cx*cx+cy*cy+cz*cz;
      if(d2<4){ AudioSys.whizz(); player.suppress(0.35); }
    }
  }
  // 压制bot
  for(const s of soldiers){
    if(!s.alive||s.team===shooter.team) continue;
    const relX=s.pos.x-origin.x, relY=s.pos.y+1.4-origin.y, relZ=s.pos.z-origin.z;
    const tp=relX*dir.x+relY*dir.y+relZ*dir.z;
    if(tp>2&&tp<endD+1){
      const cx=origin.x+dir.x*tp-s.pos.x, cy=origin.y+dir.y*tp-(s.pos.y+1.4), cz=origin.z+dir.z*tp-s.pos.z;
      if(cx*cx+cy*cy+cz*cz<6){ s.suppression=Math.min(1.6,s.suppression+0.35); s.noticeThreat(shooter); }
    }
  }
  gunEvents.push({x:origin.x,z:origin.z,team:shooter.team,t:nowT,shooter});
  shooter.lastFiredT=nowT;
}

/* ---------- 手雷 ---------- */
const nades=[];
const nadeGeoUS=new THREE.SphereGeometry(0.06,8,6);
const nadeGeoGER=new THREE.CylinderGeometry(0.035,0.035,0.24,8);
const nadeGeoAT=new THREE.CylinderGeometry(0.06,0.075,0.3,8);
function throwNade(thrower,origin,vel,fuse,at){
  const m=new THREE.Mesh(at?nadeGeoAT:(thrower.team===0?nadeGeoUS:nadeGeoGER),at?vmMats.gunL:vmMats.nade);
  m.castShadow=true;
  m.position.copy(origin);
  scene.add(m);
  nades.push({m,pos:origin.clone(),vel:vel.clone(),fuse:fuse!==undefined?fuse:3.4,thrower,team:thrower.team,spin:V3(rand(-6,6),rand(-6,6),rand(-6,6)),bounces:0,at:!!at});
}
function updateNades(dt){
  for(let i=nades.length-1;i>=0;i--){
    const n=nades[i];
    n.fuse-=dt;
    if(n.fuse<=0){
      scene.remove(n.m);
      nades.splice(i,1);
      nadeExplode(n);
      continue;
    }
    n.vel.y-=9.8*dt;
    const nx=n.pos.x+n.vel.x*dt, ny=n.pos.y+n.vel.y*dt, nz=n.pos.z+n.vel.z*dt;
    // 碰撞: 盒
    let bounced=false;
    for(const b of BOXES){
      if(nx>b.minX-0.06&&nx<b.maxX+0.06&&ny>b.minY-0.06&&ny<b.maxY+0.06&&nz>b.minZ-0.06&&nz<b.maxZ+0.06){
        const dl=Math.min(nx-b.minX,b.maxX-nx), dv=Math.min(ny-b.minY,b.maxY-ny), dd=Math.min(nz-b.minZ,b.maxZ-nz);
        if(dv<=dl&&dv<=dd){ n.vel.y*=-0.4; n.vel.x*=0.7; n.vel.z*=0.7; }
        else if(dl<=dd){ n.vel.x*=-0.5; } else { n.vel.z*=-0.5; }
        bounced=true;
        break;
      }
    }
    // AT雷碰坦克即炸
    if(n.at||n.bomb){
      for(const t of tanks){
        if(!t.alive||t.team===n.team) continue;
        if(Math.hypot(nx-t.pos.x,nz-t.pos.z)<2.6&&ny<t.pos.y+2.6){ n.fuse=0; bounced=false; break; }
      }
    }
    const gh=heightAt(nx,nz);
    if(ny<=gh+0.05){
      n.pos.y=gh+0.05;
      if(n.vel.y<-0.5) bounced=true;
      n.vel.y*=-0.38;
      n.vel.x*=0.68; n.vel.z*=0.68;
      n.spin.multiplyScalar(0.6);
    }
    n.pos.set(nx,Math.max(ny,gh+0.05),nz);
    if(bounced&&(n.at||n.bomb)){ n.fuse=Math.min(n.fuse,0.02); } // 触发引信
    else if(bounced&&n.bounces++<4) AudioSys.nadeBounce(n.pos.distanceTo(camera.position));
    n.m.position.copy(n.pos);
    n.m.rotation.x+=n.spin.x*dt; n.m.rotation.y+=n.spin.y*dt; n.m.rotation.z+=n.spin.z*dt;
    // 玩家警告
    if(n.team!==player.team&&player.alive&&n.pos.distanceTo(player.pos)<7) nadeWarnT=0.2;
  }
}
function nadeExplode(n){
  if(n.at){
    // 反坦克雷: 对装甲高伤
    spawnP(PT.flash,n.pos.x,n.pos.y+0.4,n.pos.z,0,2,0,2.4,15,0.14,1,0,true);
    for(let i=0;i<7;i++) spawnP(PT.dark,n.pos.x,n.pos.y+0.4,n.pos.z,rand(-2,2),rand(1,4),rand(-2,2),1.0,2.4,rand(0.8,1.4),0.85,0.6);
    AudioSys.explosion(n.pos.distanceTo(camera.position));
    splashDamage(n.pos,n.thrower,n.team,5,95,340);
    crater(n.pos.x,n.pos.z,1.1,true);
  } else if(n.bomb){
    explosionFX(n.pos);
    AudioSys.explosion(n.pos.distanceTo(camera.position)*0.7);
    splashDamage(n.pos,n.thrower,n.team,9.5,170,260);
    explodeAt(n.pos,n.thrower); // 附带常规爆炸对周边
  } else {
    explodeAt(n.pos,n.thrower);
  }
}
function explodeAt(p,attacker){
  explosionFX(p);
  AudioSys.explosion(p.distanceTo(camera.position));
  for(const s of combatants){
    if(!s.alive) continue;
    const d=Math.hypot(s.pos.x-p.x,(s.pos.y+0.9)-p.y,s.pos.z-p.z);
    if(d<9){
      // LOS衰减
      const dir=V3(s.pos.x-p.x,s.pos.y+0.9-p.y,s.pos.z-p.z).normalize();
      const block=raycastWorld(V3(p.x,p.y+0.25,p.z),dir,d);
      let dmg=(1-d/9)*135;
      if(block&&block.dist<d-0.5) dmg*=0.25;
      if(s.team===attacker.team&&s!==attacker) dmg*=0.0; // 无友伤
      if(dmg>2){
        s.damage(dmg,attacker,false);
        if(s.isPlayer){ addTrauma(clamp(dmg/60,0.3,1)); }
      }
    }
    if(s.isPlayer){
      const d2=p.distanceTo(camera.position);
      if(d2<20) addTrauma(clamp(1.2-d2/22,0.1,0.9));
    }
  }
  // bot听觉
  for(const s of soldiers){
    if(!s.alive) continue;
    if(Math.hypot(s.pos.x-p.x,s.pos.z-p.z)<40&&attacker.team!==s.team) s.noticeThreat(attacker);
  }
}

/* ---------- BOT ---------- */
class Bot {
  constructor(team,cls,name){
    this.team=team; this.cls=cls; this.name=name;
    this.isPlayer=false;
    const wset=CLASSES[cls][team===0?'us':'ger'];
    this.wpnKey=wset[0];
    this.def=WPN_DEFS[this.wpnKey];
    this.pos=V3(); this.vel=V3();
    this.yaw=0; this.pitch=0; this.visYaw=0;
    this.hp=100; this.alive=false; this.crouch=false;
    this.mesh=buildSoldierMesh(team,name);
    this.gunKind=gunKindOf(this.wpnKey);
    this.gun=worldGunModel(this.gunKind);
    this.mesh.armR.add(this.gun);
    this.gun.position.set(0,-0.55,-0.12);
    this.gun.rotation.x=1.05;
    this.state='idle';
    this.path=null; this.pathI=0; this.repathT=0;
    this.objective=null; this.defendPt=null;
    this.target=null; this.lastSeenPos=V3(); this.lastSeenT=-99;
    this.aimSettle=0; this.reactT=0;
    this.mag=this.def.mag; this.reloadT=0; this.boltT=0;
    this.fireT=0; this.burstLeft=0; this.burstPause=0;
    this.nades=CLASSES[cls].nades; this.nadeCd=rand(4,10); this.nadeAnimT=0; this.pendingNade=null;
    this.suppression=0; this.coverPt=null; this.coverT=0; this.peekT=0;
    this.strafeDir=1; this.strafeT=0;
    this.decisionT=rand(0,0.4); this.perceptT=rand(0,0.15);
    this.kills=0; this.deaths=0; this.score=0;
    this.respawnT=0; this.deathAnimT=0;
    this.stuckT=0; this.lastPos=V3(); this.moveIntent=false;
    this.unstickT=0; this.unstickDir=1;
    this.atn=cls===2?2:0; this.empUse=null; this.empT=0;
    this.lastFiredT=-99; this.animT=rand(0,9);
    this.mgUse=null; this.mgT=0;
    this.meleeT=0;
    this.speedMul=rand(0.92,1.08);
    combatants.push(this); soldiers.push(this);
  }
  spawn(){
    const sp=pickSpawnFor(this.team);
    this.pos.set(sp.x+rand(-3,3), 0, sp.z+rand(-3,3));
    this.pos.y=standHeight(this.pos.x,this.pos.z,10);
    this.hp=100; this.alive=true; this.crouch=false;
    this.mag=this.def.mag; this.reloadT=0; this.suppression=0;
    this.target=null; this.lastSeenT=-99; this.path=null;
    this.state='idle'; this.mgUse=null; this.empUse=null; this.unstickT=0;
    this.nades=CLASSES[this.cls].nades;
    this.atn=this.cls===2?2:0;
    this.mesh.root.visible=true;
    this.mesh.root.rotation.set(0,0,0);
    this.mesh.pelvis.position.y=0.94;
    this.pickObjective();
  }
  eyePos(){ return V3(this.pos.x,this.pos.y+(this.crouch?1.15:1.62),this.pos.z); }
  noticeThreat(enemy){
    if(!enemy||!enemy.alive) return;
    if(!this.target||nowT-this.lastSeenT>2.5){
      this.lastSeenPos.copy(enemy.pos);
      if(nowT-this.lastSeenT>4) this.lastSeenT=nowT-2.8; // 知道大概位置但没锁定
    }
  }
  damage(amt,attacker,isHead){
    if(!this.alive) return;
    this.hp-=amt;
    this.suppression=Math.min(1.6,this.suppression+0.45);
    this.aimSettle=Math.max(0.1,this.aimSettle-0.4); // 中弹打断瞄准
    if(attacker&&attacker.team!==this.team){
      this.noticeThreat(attacker);
      if(!this.target&&!attacker.isCrew&&Math.random()<0.8){
        this.target=attacker;
        this.lastSeenPos.copy(attacker.pos);
        this.lastSeenT=nowT-1.2;
        this.reactT=DIFF_TABLE[SETTINGS.diff].react*rand(0.5,1);
      }
    }
    if(this.hp<=0) this.die(attacker,isHead);
  }
  die(attacker,isHead){
    this.alive=false; this.deaths++;
    this.deathAnimT=0.6;
    this.respawnT=rand(7,11);
    if(this.mgUse){ this.mgUse.user=null; this.mgUse=null; }
    if(this.empUse){ this.empUse.user=null; this.empUse=null; }
    tickets[this.team]=Math.max(0,tickets[this.team]-1);
    if(attacker){
      attacker.kills=(attacker.kills||0)+1;
      attacker.score=(attacker.score||0)+100;
      addKillfeed(attacker,this,isHead);
      if(attacker.isPlayer){ onPlayerKill(this,isHead); }
    }
    if(this.pos.distanceTo(camera.position)<30&&Math.random()<0.5) AudioSys.hurt();
  }
  pickObjective(){
    // 统计各旗需求
    let best=null,bestScore=-1e9;
    for(const f of FLAGS){
      let sc=0;
      const d=Math.hypot(this.pos.x-f.x,this.pos.z-f.z);
      sc-=d*0.6;
      if(f.owner!==this.team) sc+=90;                 // 进攻非我方旗
      if(f.owner===this.team&&f.capTeam!==-1&&f.capTeam!==this.team) sc+=140; // 被夺回防
      if(f.capTeam===this.team&&f.cap>0.05&&d<55) sc+=130; // 支援正在占领的队友
      // 兵力均衡: 已有太多队友前往的旗降权
      let cnt=0;
      for(const s of soldiers) if(s.alive&&s.team===this.team&&s.objective===f) cnt++;
      sc-=cnt*26;
      sc+=rand(0,30);
      if(sc>bestScore){ bestScore=sc; best=f; }
    }
    this.objective=best;
    this.defendPt=null;
    this.repathT=0;
  }
  /* 感知 */
  perceive(){
    const diff=DIFF_TABLE[SETTINGS.diff];
    const viewD=95*diff.visMul;
    let best=null,bestD=1e9;
    const eye=this.eyePos();
    for(const e of combatants){
      if(!e.alive||e.team===this.team||e.onVehicle) continue;
      const dx=e.pos.x-this.pos.x, dz=e.pos.z-this.pos.z;
      const d=Math.hypot(dx,dz);
      if(d>viewD) continue;
      // FOV(正在交战的目标不受FOV限制)
      const ang=Math.atan2(dx,dz);
      const isCur=(e===this.target&&nowT-this.lastSeenT<3);
      if(!isCur&&Math.abs(angDiff(this.yaw,ang))>1.15) continue;
      // 近距离听觉
      const heard=d<9||(e.lastFiredT&&nowT-e.lastFiredT<1.5&&d<70);
      if(!isCur&&!heard&&d>viewD*0.85&&e.crouch) continue;
      // LOS
      const tp=V3(e.pos.x,e.pos.y+(e.crouch?1.0:1.4),e.pos.z);
      const dir=tp.clone().sub(eye); const dist=dir.length(); dir.normalize();
      const blockHit=raycastWorld(eye,dir,dist);
      if(blockHit&&blockHit.dist<dist-0.6) continue;
      if(d<bestD){ best=e; bestD=d; }
    }
    if(best){
      if(this.target!==best||nowT-this.lastSeenT>2){
        if(this.target!==best) this.reactT=DIFF_TABLE[SETTINGS.diff].react*rand(0.7,1.4);
        this.target=best;
      }
      this.lastSeenPos.copy(best.pos);
      this.lastSeenT=nowT;
      this.aimSettle=Math.min(1,this.aimSettle+0.16);
    } else {
      this.aimSettle=Math.max(0.2,this.aimSettle-0.1);
      if(this.target&&(!this.target.alive||nowT-this.lastSeenT>8)) this.target=null;
    }
    // 听到附近枪声
    if(!this.target){
      for(const ev of gunEvents){
        if(ev.team===this.team) continue;
        const d=Math.hypot(ev.x-this.pos.x,ev.z-this.pos.z);
        if(d<65&&nowT-ev.t<0.4&&ev.shooter&&ev.shooter.alive){
          this.lastSeenPos.set(ev.x,0,ev.z);
          if(nowT-this.lastSeenT>4) this.lastSeenT=nowT-3.5;
        }
      }
    }
  }
  /* 决策 */
  decide(){
    const engaged=this.target&&nowT-this.lastSeenT<4;
    const distT=engaged?Math.hypot(this.target.pos.x-this.pos.x,this.target.pos.z-this.pos.z):0;
    // 使用炮位中
    if(this.empUse){
      const g=this.empUse;
      let done=false;
      if(g.kind==='at'){ const t=enemyTankNear(this,150); if(!t) done=true; }
      else { if(!enemyPlaneAlive(this.team)) done=true; }
      this.empT-=0.5;
      if(done||this.empT<=0||(engaged&&distT<8)){ g.user=null; this.empUse=null; }
      else return;
    }
    // MG42使用中
    if(this.mgUse){
      this.mgT-=0.5;
      const stillThreat=engaged&&distT>6&&Math.abs(angDiff(this.mgUse.face+Math.PI,Math.atan2(this.target.pos.x-this.pos.x,this.target.pos.z-this.pos.z)))<1.0;
      if(this.mgT<=0||(engaged&&distT<7)||(!stillThreat&&this.mgT<12)){ this.mgUse.user=null; this.mgUse=null; }
      else return;
    }
    // ---- 载具威胁反应 ----
    const eTank=enemyTankNear(this,120);
    if(eTank){
      const dTank=Math.hypot(eTank.pos.x-this.pos.x,eTank.pos.z-this.pos.z);
      // 突击兵: 反坦克雷
      if(this.atn>0&&this.nadeCd<=0&&dTank<26){
        this.state='atnade'; this.nadeAnimT=0.8; this.atTankTarget=eTank;
        return;
      }
      // 上反坦克炮
      if(dTank<130){
        for(const g of ATGUNS){
          if(g.user) continue;
          if(Math.hypot(g.x-this.pos.x,g.z-this.pos.z)<16){
            const angToTank=Math.atan2(eTank.pos.x-g.x,eTank.pos.z-g.z);
            if(Math.abs(angDiff(g.face,angToTank))<1.05){
              this.empUse=g; g.user=this; this.empT=rand(18,30);
              this.pos.x=g.x-Math.sin(g.face)*1.2;
              this.pos.z=g.z-Math.cos(g.face)*1.2;
              this.path=null;
              return;
            }
          }
        }
      }
      // 突击兵主动接近坦克
      if(this.cls===2&&this.atn>0&&dTank<55&&!engaged){
        this.state='hunt';
        if(!this.path||this.repathT<=0){
          this.setPathTo(eTank.pos.x+rand(-8,8),eTank.pos.z+rand(-8,8));
          this.repathT=2.5;
        }
        return;
      }
      // 其他兵种: 躲坦克
      if(dTank<32&&this.cls!==2&&this.state!=='cover'&&Math.random()<0.5){
        const cp=this.findCoverFrom(eTank.pos);
        if(cp){ this.state='cover'; this.coverPt=cp; this.coverT=rand(2.5,5); this.setPathTo(cp.x,cp.z); return; }
      }
    }
    // 上防空炮
    const ePlane=enemyPlaneAlive(this.team);
    if(ePlane&&!engaged&&Math.random()<0.4){
      for(const g of AAGUNS){
        if(g.user) continue;
        if(Math.hypot(g.x-this.pos.x,g.z-this.pos.z)<15){
          this.empUse=g; g.user=this; this.empT=rand(14,24);
          this.pos.x=g.x; this.pos.z=g.z-0.9;
          this.path=null;
          return;
        }
      }
    }
    if(engaged){
      // 近战
      if(distT<2.6){ this.state='melee'; return; }
      // 换弹或重压制 → 掩体
      if((this.reloadT>0.3||this.suppression>1.0||this.hp<32)&&Math.random()<0.75){
        if(this.state!=='cover'||!this.coverPt){
          const cp=this.findCover();
          if(cp){ this.state='cover'; this.coverPt=cp; this.coverT=rand(1.2,2.6)+(this.reloadT>0?this.def.reload:0); this.setPathTo(cp.x,cp.z); return; }
        } else return;
      }
      // 投雷: 目标躲起来了或距离合适
      if(this.nades>0&&this.nadeCd<=0&&distT>9&&distT<30){
        const hidden=nowT-this.lastSeenT>1.2;
        let cluster=0;
        for(const e of combatants) if(e.alive&&e.team!==this.team&&e.pos.distanceTo(this.target.pos)<7) cluster++;
        if((hidden&&Math.random()<0.5)||(cluster>=2&&Math.random()<0.4)){
          this.state='nade'; this.nadeAnimT=0.8;
          return;
        }
      }
      this.state='combat';
      // 交战移动模式
      if(distT>34){ this.combatMove=Math.random()<0.55?'crouch':'stand'; }
      else if(distT<14){ this.combatMove=Math.random()<0.6?'strafe':'stand'; }
      else { this.combatMove=['stand','crouch','strafe'][randi(0,2)]; }
      this.strafeT=rand(0.5,1.3); this.strafeDir=Math.random()<0.5?1:-1;
      return;
    }
    // 追踪最后目击位置
    if(this.target&&nowT-this.lastSeenT<9){
      this.state='hunt';
      if(!this.path||this.repathT<=0){ this.setPathTo(this.lastSeenPos.x+rand(-3,3),this.lastSeenPos.z+rand(-3,3)); this.repathT=3; }
      return;
    }
    this.target=null;
    // 目标点逻辑
    if(!this.objective||Math.random()<0.06) this.pickObjective();
    const f=this.objective;
    const dObj=Math.hypot(this.pos.x-f.x,this.pos.z-f.z);
    if(dObj<f.r*0.85){
      // 在旗圈内: 防守/巡逻
      this.state='defend';
      // 尝试用MG42
      if(!this.mgUse&&Math.random()<0.25){
        for(const mg of MG42S){
          if(mg.user) continue;
          if(Math.hypot(mg.x-this.pos.x,mg.z-this.pos.z)<14){
            this.mgUse=mg; mg.user=this; this.mgT=rand(14,24);
            this.pos.x=mg.x-Math.sin(mg.face)*0.8;
            this.pos.z=mg.z-Math.cos(mg.face)*0.8;
            this.path=null;
            return;
          }
        }
      }
      if(!this.defendPt||Math.random()<0.3){
        // 找旗附近掩体点
        let best=null,bd=1e9;
        for(const cp of coverPoints){
          const d=Math.hypot(cp.x-f.x,cp.z-f.z);
          if(d<f.r*1.3){
            const dd=d+rand(0,14);
            if(dd<bd){ bd=dd; best=cp; }
          }
        }
        this.defendPt=best||{x:f.x+rand(-6,6),z:f.z+rand(-6,6)};
        this.setPathTo(this.defendPt.x,this.defendPt.z);
      }
    } else {
      this.state='advance';
      if(!this.path||this.repathT<=0){
        this.setPathTo(f.x+rand(-6,6),f.z+rand(-6,6));
        this.repathT=rand(4,7);
      }
    }
  }
  findCover(){
    const threat=this.target?this.target.pos:this.lastSeenPos;
    return this.findCoverFrom(threat);
  }
  findCoverFrom(threat){
    let best=null,bd=1e9;
    for(const cp of coverPoints){
      const d=Math.hypot(cp.x-this.pos.x,cp.z-this.pos.z);
      if(d>26) continue;
      // 掩体点要求: 从威胁到点的视线被阻挡
      const dir=V3(cp.x-threat.x,1.15,cp.z-threat.z);
      const dist=Math.hypot(dir.x,dir.z);
      const org=V3(threat.x,heightAt(threat.x,threat.z)+1.5,threat.z);
      dir.y=(heightAt(cp.x,cp.z)+0.9)-org.y; dir.normalize();
      const hit=raycastWorld(org,dir,dist);
      if(!hit||hit.dist>dist-0.8) continue;
      const sc=d-rand(0,6);
      if(sc<bd){ bd=sc; best=cp; }
    }
    return best;
  }
  setPathTo(x,z){
    if(NAV.budget<=0){ this.repathT=Math.min(this.repathT>0?this.repathT:0.3,0.3); return; }
    NAV.budget--;
    this.path=NAV.findPath(this.pos.x,this.pos.z,x,z);
    this.pathI=0;
    this.moveIntent=!!this.path;
  }
  /* 每帧更新 */
  update(dt){
    if(!this.alive){
      this.deathAnimT-=dt;
      const k=clamp(1-Math.max(this.deathAnimT,0)/0.6,0,1);
      if(k<=1){
        if(!this.deathDir) this.deathDir=Math.random()<0.5?1:-1;
        this.mesh.root.rotation.x=-HPI*k*this.deathDir;
        this.mesh.pelvis.position.y=lerp(0.94,0.2,k);
        this.mesh.tag.visible=false;
      }
      this.respawnT-=dt;
      if(this.respawnT<2.5&&this.mesh.root.visible) this.mesh.root.visible=false;
      if(this.respawnT<=0&&!matchOver){ this.deathDir=0; this.spawn(); }
      return;
    }
    this.perceptT-=dt;
    if(this.perceptT<=0){ this.perceptT=0.16; this.perceive(); }
    this.decisionT-=dt;
    if(this.decisionT<=0&&!(this.state==='nade'&&this.nadeAnimT>0)){ this.decisionT=rand(0.35,0.6); this.decide(); }
    this.suppression=Math.max(0,this.suppression-dt*0.35);
    this.nadeCd-=dt; this.repathT-=dt; this.reactT-=dt;
    if(this.reloadT>0){
      this.reloadT-=dt;
      if(this.reloadT<=0){ this.mag=this.def.mag; }
    }
    if(this.boltT>0) this.boltT-=dt;
    if(this.fireT>0) this.fireT-=dt;
    if(this.burstPause>0) this.burstPause-=dt;

    const engaged=this.target&&this.target.alive&&nowT-this.lastSeenT<4;
    // ---- 炮位操作 ----
    if(this.empUse){ this.updateEmplacement(dt); return; }
    // ---- MG42 模式 ----
    if(this.mgUse){
      const mg=this.mgUse;
      this.crouch=false;
      this.vel.set(0,0,0);
      this.pos.x=mg.x-Math.sin(mg.face)*0.85;
      this.pos.z=mg.z-Math.cos(mg.face)*0.85;
      this.pos.y=standHeight(this.pos.x,this.pos.z,this.pos.y+1);
      if(engaged){
        const tp=this.target.pos;
        const wantYaw=Math.atan2(tp.x-mg.x,tp.z-mg.z);
        if(Math.abs(angDiff(mg.face,wantYaw))<1.05){
          this.yaw=angleLerpTo(this.yaw,wantYaw,4*dt);
          mg.yaw.rotation.y=angDiff(mg.face,this.yaw);
          const dy=(tp.y+1.2)-(mg.y);
          const dh=Math.hypot(tp.x-mg.x,tp.z-mg.z);
          mg.pitch.rotation.x=-clamp(Math.atan2(dy,dh),-0.3,0.35);
          if(this.reactT<=0&&this.fireT<=0&&mg.heat<1){
            this.fireT=60/1100;
            mg.heat+=0.012;
            const eye=V3(mg.x,mg.y+0.1,mg.z);
            const dir=V3(tp.x-eye.x,tp.y+1.1+rand(-0.4,0.4)-eye.y,tp.z-eye.z).normalize();
            const spread=0.02*(this.suppression+0.7);
            dir.x+=rand(-spread,spread); dir.y+=rand(-spread,spread); dir.z+=rand(-spread,spread); dir.normalize();
            const muzzle=V3(mg.x+dir.x*1.3,mg.y+0.1+dir.y*1.3,mg.z+dir.z*1.3);
            const mgDef={dmg:26,headMul:1.8,snd:'mg',tracer:2};
            fireBullet(this,eye,dir,mgDef,muzzle);
          }
        }
      }
      mg.heat=Math.max(0,mg.heat-dt*0.15);
      this.updateMesh(dt,0);
      return;
    }
    // ---- 近战 ----
    if(this.state==='melee'&&engaged){
      const d=Math.hypot(this.target.pos.x-this.pos.x,this.target.pos.z-this.pos.z);
      this.faceTo(this.target.pos,dt,7);
      if(d>1.6){ this.moveDir(Math.atan2(this.target.pos.x-this.pos.x,this.target.pos.z-this.pos.z),4.4,dt); }
      this.meleeT-=dt;
      if(d<2.3&&this.meleeT<=0){
        this.meleeT=1.1;
        setTimeout(()=>{
          if(this.alive&&this.target&&this.target.alive){
            const dd=Math.hypot(this.target.pos.x-this.pos.x,this.target.pos.z-this.pos.z);
            if(dd<2.6){ this.target.damage(70,this,false); AudioSys.click(500,0.4,0.08); }
          }
        },380);
        this.mesh.armR.userData.swing=0.4;
      }
      this.updateMesh(dt,Math.hypot(this.vel.x,this.vel.z));
      return;
    }
    // ---- 投雷 ----
    if(this.state==='atnade'){
      const tk=this.atTankTarget;
      if(!tk||!tk.alive){ this.state='idle'; this.nadeAnimT=0; }
      else {
        this.faceTo(tk.pos,dt,6);
        this.vel.set(0,0,0);
        this.nadeAnimT-=dt;
        this.mesh.armR.userData.swing=0.6;
        if(this.nadeAnimT<=0){
          this.atn--; this.nadeCd=rand(8,14);
          const eye=this.eyePos();
          const lead=V3(tk.pos.x+Math.sin(tk.yaw)*tk.vel*0.5,tk.pos.y+0.8,tk.pos.z+Math.cos(tk.yaw)*tk.vel*0.5);
          const dist=eye.distanceTo(lead);
          const tof=clamp(dist/12,0.7,1.8);
          const vel=lead.sub(eye).multiplyScalar(1/tof);
          vel.y+=0.5*9.8*tof;
          throwNade(this,eye,vel,1.6,true);
          this.state='combat';
        }
        this.updateMesh(dt,0);
        return;
      }
    }
    if(this.state==='nade'){
      this.faceTo(this.lastSeenPos,dt,5);
      this.vel.set(0,0,0);
      this.nadeAnimT-=dt;
      this.mesh.armR.userData.swing=0.6;
      if(this.nadeAnimT<=0){
        this.nades--; this.nadeCd=rand(13,22);
        const eye=this.eyePos();
        const tgt=V3(this.lastSeenPos.x+rand(-2,2),heightAt(this.lastSeenPos.x,this.lastSeenPos.z),this.lastSeenPos.z+rand(-2,2));
        const dist=eye.distanceTo(tgt);
        const tof=clamp(dist/13,0.75,1.9);
        const vel=tgt.sub(eye).multiplyScalar(1/tof);
        vel.y+=0.5*9.8*tof;
        throwNade(this,eye,vel);
        this.state='combat';
      }
      this.updateMesh(dt,0);
      return;
    }
    // ---- 移动逻辑 ----
    let speed=0, moveYaw=this.yaw;
    let wantMove=false;
    if(this.state==='cover'&&this.coverPt){
      const d=Math.hypot(this.coverPt.x-this.pos.x,this.coverPt.z-this.pos.z);
      if(d>1.2){
        wantMove=true; speed=4.6;
        moveYaw=this.followPath(dt)??Math.atan2(this.coverPt.x-this.pos.x,this.coverPt.z-this.pos.z);
      } else {
        this.crouch=true;
        this.coverT-=dt;
        // 换弹
        if(this.mag<this.def.mag*0.4&&this.reloadT<=0){ this.reloadT=this.def.reload; }
        if(this.coverT<=0){ this.state='combat'; this.crouch=false; this.peekT=rand(1.4,2.8); }
      }
    }
    else if(this.state==='combat'&&engaged){
      const d=Math.hypot(this.target.pos.x-this.pos.x,this.target.pos.z-this.pos.z);
      this.crouch=this.combatMove==='crouch';
      if(this.combatMove==='strafe'){
        this.strafeT-=dt;
        if(this.strafeT<=0){ this.strafeT=rand(0.5,1.2); this.strafeDir*=-1; }
        const toT=Math.atan2(this.target.pos.x-this.pos.x,this.target.pos.z-this.pos.z);
        moveYaw=toT+HPI*this.strafeDir;
        wantMove=true; speed=3.0;
        // 距离调整
        if(d>40){ moveYaw=toT+0.5*this.strafeDir; speed=3.6; }
      }
    }
    else if(this.state==='hunt'){
      const my=this.followPath(dt);
      if(my!==null){ moveYaw=my; wantMove=true; speed=this.suppression>0.5?2.4:3.4; }
      this.crouch=this.suppression>0.8;
    }
    else if(this.state==='advance'){
      const my=this.followPath(dt);
      if(my!==null){ moveYaw=my; wantMove=true; speed=this.suppression>0.6?3.0:5.0; }
      this.crouch=false;
    }
    else if(this.state==='defend'){
      const my=this.followPath(dt);
      if(my!==null){ moveYaw=my; wantMove=true; speed=2.6; }
      else {
        // 站定扫视
        this.crouch=Math.random()<0.002?!this.crouch:this.crouch;
        if(!engaged){
          this.scanT=(this.scanT||0)-dt;
          if(this.scanT<=0){ this.scanT=rand(2,4); this.scanYaw=this.yaw+rand(-1.6,1.6); }
          this.yaw=angleLerpTo(this.yaw,this.scanYaw??this.yaw,1.2*dt);
        }
      }
    }
    if(wantMove){ this.moveDir(moveYaw,speed*this.speedMul,dt); } else { this.vel.x*=0.7; this.vel.z*=0.7; }
    // 卡住检测: 0.9秒内位移过小 → 侧移脱困并重寻路
    if(wantMove&&this.unstickT<=0){
      this.stuckT+=dt;
      if(this.stuckT>0.9){
        if(this.pos.distanceTo(this.lastPos)<0.8){
          this.unstickT=rand(0.35,0.6);
          this.unstickDir=Math.random()<0.5?1:-1;
          this.path=null; this.repathT=0;
        }
        this.lastPos.copy(this.pos);
        this.stuckT=0;
      }
    } else if(!wantMove) this.stuckT=0;
    // ---- 朝向 ----
    if(engaged){
      this.faceTo(this.target.pos,dt,6.5);
    } else if(this.target&&nowT-this.lastSeenT<8){
      this.faceTo(this.lastSeenPos,dt,4);
    } else if(wantMove){
      this.yaw=angleLerpTo(this.yaw,moveYaw,5*dt);
    }
    // ---- 开火 ----
    if(engaged&&this.reactT<=0&&this.reloadT<=0&&this.boltT<=0&&this.mag>0&&this.state!=='cover'){
      const tp=this.target.pos;
      const wantYaw=Math.atan2(tp.x-this.pos.x,tp.z-this.pos.z);
      if(Math.abs(angDiff(this.yaw,wantYaw))<0.14){
        if(this.def.type==='auto'){
          if(this.burstPause<=0){
            if(this.burstLeft<=0){ this.burstLeft=randi(3,6); }
            if(this.fireT<=0){
              this.shootAt(this.target);
              this.fireT=60/this.def.rpm;
              if(--this.burstLeft<=0) this.burstPause=rand(0.5,1.1);
            }
          }
        } else {
          if(this.fireT<=0){
            this.shootAt(this.target);
            this.fireT=this.def.type==='bolt'?0.1:rand(0.45,0.85);
            if(this.def.type==='bolt') this.boltT=this.def.boltT||1.0;
          }
        }
      }
    }
    if(this.mag<=0&&this.reloadT<=0){ this.reloadT=this.def.reload*rand(1,1.25); }
    this.updateMesh(dt,Math.hypot(this.vel.x,this.vel.z));
  }
  updateEmplacement(dt){
    const g=this.empUse;
    this.crouch=false;
    this.vel.set(0,0,0);
    this.pos.y=standHeight(this.pos.x,this.pos.z,this.pos.y+1);
    g.cd-=dt;
    if(g.kind==='at'){
      this.pos.x=g.x-Math.sin(g.face)*1.2;
      this.pos.z=g.z-Math.cos(g.face)*1.2;
      const tk=enemyTankNear(this,160);
      if(tk){
        const wantYaw=Math.atan2(tk.pos.x-g.x,tk.pos.z-g.z);
        const rel=clamp(angDiff(g.face,wantYaw),-1.0,1.0);
        g.yaw.rotation.y=dampF(g.yaw.rotation.y,rel,4,dt);
        this.yaw=g.face+g.yaw.rotation.y;
        const dh=Math.hypot(tk.pos.x-g.x,tk.pos.z-g.z);
        const pit=clamp(Math.atan2((tk.pos.y+1.2)-g.y,dh),-0.1,0.25);
        g.pitch.rotation.x=dampF(g.pitch.rotation.x,-pit,4,dt);
        if(Math.abs(angDiff(g.face+g.yaw.rotation.y,wantYaw))<0.05&&g.cd<=0&&this.reactT<=0){
          g.cd=4.5+rand(0,1);
          const dir=V3(Math.sin(this.yaw)*Math.cos(pit),Math.sin(pit),Math.cos(this.yaw)*Math.cos(pit));
          const o=V3(g.x,g.y+0.25,g.z).addScaledVector(dir,2.6);
          const chk=raycastWorld(o,dir,dh);
          if(!chk||chk.dist>dh-4){
            AudioSys.cannon(o.distanceTo(camera.position)*1.3);
            spawnP(PT.flash,o.x,o.y,o.z,dir.x*3,dir.y*3,dir.z*3,0.8,8,0.1,1,0,true);
            spawnP(PT.dark,o.x,o.y,o.z,dir.x*2,1,dir.z*2,0.8,2.5,0.8,0.7,0.5);
            shells.push({pos:o.clone(),vel:dir.clone().multiplyScalar(150),team:this.team,owner:this,life:3,kind:'at',trail:0});
            this.lastFiredT=nowT;
          } else g.cd=0.8;
        }
      }
    } else {
      // 防空炮
      this.pos.x=g.x; this.pos.z=g.z-0.9;
      const pl=enemyPlaneAlive(this.team);
      if(pl){
        // 预判提前量
        const lead=pl.pos.clone();
        const dist=lead.distanceTo(V3(g.x,g.y,g.z));
        const tof=dist/150;
        lead.x+=Math.sin(pl.yaw)*Math.cos(pl.pitch)*pl.speed*tof;
        lead.z+=Math.cos(pl.yaw)*Math.cos(pl.pitch)*pl.speed*tof;
        lead.y+=Math.sin(pl.pitch)*pl.speed*tof;
        const wantYaw=Math.atan2(lead.x-g.x,lead.z-g.z);
        g.yaw.rotation.y=angleLerpTo(g.yaw.rotation.y,wantYaw,3*dt);
        this.yaw=g.yaw.rotation.y;
        const dh=Math.hypot(lead.x-g.x,lead.z-g.z);
        const pit=clamp(Math.atan2(lead.y-g.y,Math.max(dh,3)),0.05,1.35);
        g.pitch.rotation.x=dampF(g.pitch.rotation.x,-pit,5,dt);
        if(g.cd<=0&&dist<220){
          g.cd=0.5;
          const dir=V3(Math.sin(wantYaw)*Math.cos(pit),Math.sin(pit),Math.cos(wantYaw)*Math.cos(pit));
          dir.x+=rand(-0.03,0.03); dir.y+=rand(-0.02,0.02); dir.z+=rand(-0.03,0.03); dir.normalize();
          fireFlak(g,this,dir);
          this.lastFiredT=nowT;
        }
      }
    }
    this.updateMesh(dt,0);
  }
  followPath(dt){
    if(!this.path||this.pathI>=this.path.length){ this.path=null; return null; }
    const wp=this.path[this.pathI];
    const dx=wp[0]-this.pos.x, dz=wp[1]-this.pos.z;
    if(dx*dx+dz*dz<1.44){ this.pathI++; return this.followPath(dt); }
    return Math.atan2(dx,dz);
  }
  moveDir(yaw,speed,dt){
    // 卡住时: 先横向侧移脱困
    if(this.unstickT>0){
      this.unstickT-=dt;
      yaw+=HPI*this.unstickDir;
    } else {
      // 前方障碍规避触须
      const probe=(a)=>!occBlocked(this.pos.x+Math.sin(a)*1.4,this.pos.z+Math.cos(a)*1.4);
      if(!probe(yaw)){
        let found=false;
        for(const da of [0.55,-0.55,1.1,-1.1,1.65,-1.65]){
          if(probe(yaw+da)){ yaw+=da; found=true; break; }
        }
        if(!found) yaw+=Math.PI*(Math.random()<0.5?0.5:-0.5);
      }
    }
    const cr=this.crouch?0.5:1;
    this.vel.x=Math.sin(yaw)*speed*cr;
    this.vel.z=Math.cos(yaw)*speed*cr;
    this.pos.x+=this.vel.x*dt;
    this.pos.z+=this.vel.z*dt;
    collideMove(this.pos,0.4);
    const gh=standHeight(this.pos.x,this.pos.z,this.pos.y+0.6);
    this.pos.y=dampF(this.pos.y,gh,20,dt);
  }
  faceTo(p,dt,rate){
    const wantYaw=Math.atan2(p.x-this.pos.x,p.z-this.pos.z);
    this.yaw=angleLerpTo(this.yaw,wantYaw,rate*dt);
    const dy=(p.y+1.3)-(this.pos.y+1.5);
    const dh=Math.hypot(p.x-this.pos.x,p.z-this.pos.z);
    this.pitch=dampF(this.pitch,Math.atan2(dy,Math.max(dh,0.1)),8,dt);
  }
  shootAt(tgt){
    this.mag--;
    const diff=DIFF_TABLE[SETTINGS.diff];
    const eye=this.eyePos();
    const aimP=V3(tgt.pos.x,tgt.pos.y+(tgt.crouch?0.9:1.25)+rand(-0.15,0.25),tgt.pos.z);
    // 移动目标预测(轻微)
    if(tgt.vel){ aimP.x+=tgt.vel.x*rand(0,0.12); aimP.z+=tgt.vel.z*rand(0,0.12); }
    const dir=aimP.sub(eye).normalize();
    const dHor=Math.hypot(tgt.pos.x-this.pos.x,tgt.pos.z-this.pos.z);
    const tSpd=tgt.vel?Math.hypot(tgt.vel.x,tgt.vel.z):0;
    let err=(1.7-this.aimSettle)*diff.spreadMul*(1+this.suppression*1.0)*(this.crouch?0.75:1)*0.016;
    err*=1+clamp(tSpd/5,0,1.2)*0.85;              // 移动目标显著更难命中
    if(dHor<15) err*=1.4;                          // 近距离甩枪困难 → 冲锋可行
    else if(dHor<28) err*=1.15;
    if(tgt.isPlayer&&tgt.sprinting) err*=1.2;      // 疾跑压低命中
    dir.x+=rand(-err,err); dir.y+=rand(-err,err)*0.7; dir.z+=rand(-err,err);
    dir.normalize();
    const muzzleP=eye.clone().addScaledVector(V3(Math.sin(this.yaw),this.pitch*0.5,Math.cos(this.yaw)).normalize(),0.7);
    muzzleP.y-=0.12;
    fireBullet(this,eye,dir,this.def,muzzleP);
    this.mesh.armR.userData.swing=0.12;
  }
  updateMesh(dt,speed){
    const m=this.mesh;
    m.root.position.copy(this.pos);
    m.root.rotation.y=this.yaw+Math.PI;
    m.root.rotation.x=0; m.root.rotation.z=0;
    const crouchY=this.crouch?0.62:0.94;
    m.pelvis.position.y=dampF(m.pelvis.position.y,crouchY,10,dt);
    // 走路摆动
    this.animT+=dt*(3+speed*1.9);
    const sw=clamp(speed/4,0,1.3)*0.62;
    m.legL.rotation.x=Math.sin(this.animT)*sw;
    m.legR.rotation.x=-Math.sin(this.animT)*sw;
    if(this.crouch){ m.legL.rotation.x-=0.8; m.legR.rotation.x-=0.5; }
    // 手臂: 持枪姿势
    const engaged=this.target&&nowT-this.lastSeenT<4.5;
    const aimPitch=engaged?-this.pitch:0;
    const armBaseR=-1.15+aimPitch, armBaseL=-1.05+aimPitch;
    m.armR.rotation.x=dampF(m.armR.rotation.x,armBaseR,8,dt);
    m.armL.rotation.x=dampF(m.armL.rotation.x,armBaseL,8,dt);
    m.armL.rotation.y=0.5; m.armR.rotation.y=-0.12;
    if(m.armR.userData.swing>0){
      m.armR.userData.swing-=dt;
      m.armR.rotation.x+=Math.sin(m.armR.userData.swing*16)*0.5;
    }
    m.headG.rotation.x=engaged?clamp(-this.pitch,-0.5,0.5)*0.5:0;
    // 视觉朝向平滑
    m.tag.visible=this.team===player.team&&this.pos.distanceTo(camera.position)<60;
  }
}

/* ================================================================
   模块D2:载具 / 反坦克炮 / 防空炮 / 航空支援
================================================================ */
const tanks=[], shells=[];
const tankGeoCache={
  hull:new THREE.BoxGeometry(3.4,1.15,5.8),
  track:new THREE.BoxGeometry(0.6,1.1,6.1),
  turret:new THREE.CylinderGeometry(1.35,1.42,0.75,10),
  barrel:new THREE.CylinderGeometry(0.14,0.18,3.4,8),
};
class Tank {
  constructor(team){
    this.team=team; this.hp=800; this.maxHp=800; this.alive=true;
    this.name=team===0?'M4 谢尔曼':'四号坦克';
    this.pos=V3(); this.yaw=0; this.turretYaw=0; this.turretPitch=0; this.vel=0;
    this.playerDriven=false; this.isAI=true;
    this.crew={ name:this.name, team, kills:0, deaths:0, score:0, isPlayer:false, isCrew:true, pos:this.pos, alive:true, lastFiredT:-99, damage(){}, vel:V3() };
    this.cannonCd=0; this.mgCd=0;
    this.objective=null; this.thinkCD=0; this.stuckT=0; this.lastPos=V3(); this.smokeT=0;
    this.respawnT=0;
    const col=team===0?0x586e40:0x54585088;
    this.matBody=new THREE.MeshLambertMaterial({color:team===0?0x586e40:0x545a50});
    const matT=new THREE.MeshLambertMaterial({color:team===0?0x404e30:0x3e4238});
    const matB=new THREE.MeshLambertMaterial({color:0x363830});
    this.grp=new THREE.Group();
    const h=new THREE.Mesh(tankGeoCache.hull,this.matBody); h.position.y=0.85; this.grp.add(h);
    const gl=new THREE.Mesh(new THREE.BoxGeometry(3.2,0.25,0.7),this.matBody);
    gl.position.set(0,0.95,2.5); gl.rotation.x=-0.7; this.grp.add(gl);
    const t1=new THREE.Mesh(tankGeoCache.track,matT); t1.position.set(-1.7,0.55,0); this.grp.add(t1);
    const t2=new THREE.Mesh(tankGeoCache.track,matT); t2.position.set(1.7,0.55,0); this.grp.add(t2);
    this.turretG=new THREE.Group(); this.turretG.position.y=1.55; this.grp.add(this.turretG);
    this.turretG.add(new THREE.Mesh(tankGeoCache.turret,this.matBody));
    this.pitchG=new THREE.Group(); this.turretG.add(this.pitchG);
    const bar=new THREE.Mesh(tankGeoCache.barrel,matB); bar.position.set(0,0.15,1.9); bar.rotation.x=HPI;
    this.pitchG.add(bar);
    this.muzzle=V3();
    this.grp.traverse(o=>{ if(o.isMesh) o.castShadow=true; });
    world.add(this.grp);
    this.engine=AudioSys.createEngine('tank');
    tanks.push(this);
    this.respawn();
  }
  aimDir(){
    const a=this.yaw+this.turretYaw, p=this.turretPitch;
    return V3(Math.sin(a)*Math.cos(p),Math.sin(p),Math.cos(a)*Math.cos(p));
  }
  update(dt){
    if(!this.alive){
      this.respawnT-=dt;
      this.engine.update(0,0);
      if(this.respawnT<=0&&!matchOver) this.respawn();
      return;
    }
    this.cannonCd-=dt; this.mgCd-=dt;
    const dCam=this.pos.distanceTo(camera.position);
    this.engine.update(58+Math.abs(this.vel)*7, clamp(0.3-dCam/110,0,0.3)*(0.55+Math.abs(this.vel)*0.08));
    if(this.isAI&&!this.playerDriven) this.tankAI(dt);
    // 行驶
    const sn=Math.sin(this.yaw), cs=Math.cos(this.yaw);
    this.pos.x+=sn*this.vel*dt; this.pos.z+=cs*this.vel*dt;
    for(const b of BOXES){
      if(this.pos.y+2<b.minY||this.pos.y>b.maxY) continue;
      if(b.maxY-heightAt((b.minX+b.maxX)/2,(b.minZ+b.maxZ)/2)<0.7) continue; // 低矮物碾过
      const cx=clamp(this.pos.x,b.minX,b.maxX), cz=clamp(this.pos.z,b.minZ,b.maxZ);
      const dx=this.pos.x-cx, dz=this.pos.z-cz;
      const d2=dx*dx+dz*dz;
      if(d2<2.4*2.4){
        if(d2>1e-8){
          const d=Math.sqrt(d2), push=(2.4-d)/d;
          this.pos.x+=dx*push; this.pos.z+=dz*push;
        }
        this.vel*=0.4;
        this.stuckT+=dt*2;
      }
    }
    for(const t2 of tanks){
      if(t2===this||!t2.alive) continue;
      const dx=this.pos.x-t2.pos.x, dz=this.pos.z-t2.pos.z;
      const d2=dx*dx+dz*dz;
      if(d2<4.8*4.8&&d2>1e-6){
        const d=Math.sqrt(d2), push=(4.8-d)/d*0.5;
        this.pos.x+=dx*push; this.pos.z+=dz*push;
      }
    }
    const lim=MAP_SIZE/2+6;
    this.pos.x=clamp(this.pos.x,-lim,lim); this.pos.z=clamp(this.pos.z,-lim,lim);
    this.pos.y=dampF(this.pos.y,heightAt(this.pos.x,this.pos.z),8,dt);
    // 碾压敌步兵
    if(Math.abs(this.vel)>1.4){
      for(const s of combatants){
        if(!s.alive||s.team===this.team||s.onVehicle) continue;
        if(Math.hypot(s.pos.x-this.pos.x,s.pos.z-this.pos.z)<2.5) s.damage(260,this.playerDriven?player:this.crew,false);
      }
    }
    // 视觉
    this.grp.position.copy(this.pos);
    this.grp.rotation.set(0,this.yaw,0);
    this.turretG.rotation.y=this.turretYaw;
    this.pitchG.rotation.x=-this.turretPitch;
    const dir=this.aimDir();
    this.muzzle.set(this.pos.x+dir.x*3.7,this.pos.y+1.7+dir.y*3.7,this.pos.z+dir.z*3.7);
    // 低血冒烟
    if(this.hp<this.maxHp*0.45){
      this.smokeT-=dt;
      if(this.smokeT<=0){ this.smokeT=0.12;
        spawnP(PT.dark,this.pos.x+rand(-0.8,0.8),this.pos.y+1.8,this.pos.z+rand(-0.8,0.8),rand(-0.3,0.3),rand(1,2),rand(-0.3,0.3),0.7,1.4,rand(0.8,1.4),0.7,0.2);
      }
    }
  }
  tankAI(dt){
    this.thinkCD-=dt;
    if(this.thinkCD<=0){
      this.thinkCD=rand(0.5,0.8);
      let best=null,bs=-1e9;
      for(const f of FLAGS){
        let sc=-Math.hypot(f.x-this.pos.x,f.z-this.pos.z)*0.8;
        if(f.owner!==this.team) sc+=130;
        if(f.owner===this.team&&f.capTeam!==-1&&f.capTeam!==this.team) sc+=220;
        sc+=rand(0,30);
        if(sc>bs){ bs=sc; best=f; }
      }
      this.objective=best;
    }
    // 目标: 敌坦克 > 步兵
    let tgt=null, tgtIsTank=false, bd=1e9;
    for(const t of tanks){ if(t.alive&&t.team!==this.team){ const d=t.pos.distanceTo(this.pos); if(d<240&&d<bd){ bd=d; tgt=t; tgtIsTank=true; } } }
    if(!tgt){
      for(const s of combatants){
        if(!s.alive||s.team===this.team||s.onVehicle) continue;
        const d=Math.hypot(s.pos.x-this.pos.x,s.pos.z-this.pos.z);
        if(d<110&&d<bd){ bd=d; tgt=s; }
      }
    }
    if(tgt){
      const aimY=tgt.pos.y+(tgtIsTank?1.3:1.0);
      const wantA=Math.atan2(tgt.pos.x-this.pos.x,tgt.pos.z-this.pos.z);
      const wantTY=angDiff(this.yaw,wantA);
      this.turretYaw=angleLerpTo(this.turretYaw,wantTY,1.4*dt);
      const dh=Math.hypot(tgt.pos.x-this.pos.x,tgt.pos.z-this.pos.z);
      this.turretPitch=dampF(this.turretPitch,clamp(Math.atan2(aimY-(this.pos.y+1.7),Math.max(dh,2)),-0.15,0.3),3,dt);
      if(Math.abs(angDiff(this.turretYaw,wantTY))<0.06&&this.cannonCd<=0){
        const dir=this.aimDir();
        const o=this.muzzle.clone();
        const chk=raycastWorld(o,dir,bd);
        if(!chk||chk.dist>bd-3){ this.fireCannon(); }
      }
    }
    // 行驶决策
    const f=this.objective;
    if(f){
      const dObj=Math.hypot(f.x-this.pos.x,f.z-this.pos.z);
      const engagedStop=tgt&&bd<60&&!tgtIsTank;
      if(dObj>22&&!engagedStop){
        const wantY=Math.atan2(f.x-this.pos.x,f.z-this.pos.z);
        let steer=wantY, bestSc=-1;
        for(const a of [0,0.4,-0.4,0.8,-0.8,1.3,-1.3]){
          const px=this.pos.x+Math.sin(wantY+a)*8, pz=this.pos.z+Math.cos(wantY+a)*8;
          const px2=this.pos.x+Math.sin(wantY+a)*4, pz2=this.pos.z+Math.cos(wantY+a)*4;
          if(!occBlocked(px,pz)&&!occBlocked(px2,pz2)){
            const sc=1-Math.abs(a)*0.5;
            if(sc>bestSc){ bestSc=sc; steer=wantY+a; }
          }
        }
        if(this.stuckT>1.6){
          this.vel=-3.2;
          this.yaw=angleLerpTo(this.yaw,steer+1.2,0.8*dt);
          if(this.stuckT>3){ this.stuckT=0; }
          this.stuckT+=dt;
        } else {
          this.yaw=angleLerpTo(this.yaw,steer,0.75*dt);
          this.vel=dampF(this.vel,Math.abs(angDiff(this.yaw,steer))>0.7?2.2:5.4,3,dt);
          // 卡住检测
          if(this.pos.distanceTo(this.lastPos)<0.35){ this.stuckT+=dt; } else { this.stuckT=Math.max(0,this.stuckT-dt*2); }
          if((this._lp=(this._lp||0)+dt)>0.5){ this._lp=0; this.lastPos.copy(this.pos); }
        }
      } else {
        this.vel=dampF(this.vel,0,4,dt);
      }
    }
  }
  fireCannon(){
    if(this.cannonCd>0) return;
    this.cannonCd=5.4;
    const dir=this.aimDir();
    const origin=this.muzzle.clone();
    const shooter=this.playerDriven?player:this.crew;
    AudioSys.cannon(this.pos.distanceTo(camera.position));
    spawnP(PT.flash,origin.x,origin.y,origin.z, dir.x*3,dir.y*3,dir.z*3, 1.0,10,0.12,1,0,true);
    spawnP(PT.dark,origin.x,origin.y,origin.z, dir.x*3+rand(-1,1),1.5,dir.z*3+rand(-1,1), 1.0,3,0.9,0.7,0.5);
    if(this.playerDriven) addTrauma(0.5);
    shells.push({pos:origin.clone(),vel:dir.clone().multiplyScalar(130),team:this.team,owner:shooter,life:3.5,kind:'tank',trail:0});
    shooter.lastFiredT=nowT;
    gunEvents.push({x:origin.x,z:origin.z,team:this.team,t:nowT,shooter});
  }
  takeDmg(amt,attacker){
    if(!this.alive) return;
    this.hp-=amt;
    if(this.playerDriven){ dmgFlash=Math.min(1,dmgFlash+0.35); addTrauma(0.3); }
    if(this.hp<=0) this.die(attacker);
  }
  die(attacker){
    this.alive=false;
    this.respawnT=rand(40,52);
    this.vel=0; this.crew.alive=false;
    if(this.playerDriven){
      this.playerDriven=false;
      player.onVehicle=null;
      player.damage(999,attacker,false);
    }
    if(attacker&&attacker.team!==this.team){
      attacker.kills=(attacker.kills||0)+1;
      attacker.score=(attacker.score||0)+300;
      addKillfeed(attacker,{name:this.name,team:this.team,isPlayer:false},false);
      if(attacker.isPlayer) showScorePop('+300 摧毁坦克');
    }
    tickets[this.team]=Math.max(0,tickets[this.team]-5);
    explosionFX(this.pos.clone().add(V3(0,1.4,0)));
    AudioSys.explosion(this.pos.distanceTo(camera.position));
    this.grp.traverse(o=>{ if(o.isMesh&&o.material.color) o.material=new THREE.MeshLambertMaterial({color:0x1e1c18}); });
    this.grp.rotation.x=rand(-0.12,0.12);
  }
  respawn(){
    this.pos.set(BASES[this.team].x+rand(-6,6),0,BASES[this.team].z+(this.team===0?14:-14));
    this.pos.y=heightAt(this.pos.x,this.pos.z);
    this.yaw=this.team===0?HPI:-HPI;
    this.turretYaw=0; this.turretPitch=0;
    this.hp=this.maxHp; this.alive=true; this.isAI=true; this.playerDriven=false;
    this.crew.alive=true;
    this.stuckT=0; this.vel=0;
    this.grp.rotation.set(0,this.yaw,0);
    this.grp.traverse(o=>{ if(o.isMesh&&o.material.color&&o.material.color.getHex()===0x1e1c18) o.material=this.matBody; });
  }
}
function enemyTankNear(sol,dist){
  for(const t of tanks) if(t.alive&&t.team!==sol.team&&Math.hypot(t.pos.x-sol.pos.x,t.pos.z-sol.pos.z)<dist) return t;
  return null;
}
/* ---------- 炮弹(坦克/AT炮共用) ---------- */
function updateShells(dt){
  for(let i=shells.length-1;i>=0;i--){
    const s=shells[i];
    s.life-=dt;
    if(s.life<=0){ shells.splice(i,1); continue; }
    const step=s.vel.length()*dt;
    const dir=s.vel.clone().normalize();
    s.vel.y-=3.5*dt;
    // 曳光
    s.trail-=dt;
    if(s.trail<=0){ s.trail=0.02; spawnP(PT.spark,s.pos.x,s.pos.y,s.pos.z,0,0,0,0.16,-0.1,0.09,1,0,true); }
    // 碰撞检测(段)
    let hitD=step+0.5, hit=null, hitTank=null, hitSol=null;
    const wr=raycastWorld(s.pos,dir,hitD);
    if(wr){ hitD=wr.dist; hit=wr; }
    for(const t of tanks){
      if(!t.alive||t.team===s.team) continue;
      const r=rayCyl(s.pos,dir,{x:t.pos.x,z:t.pos.z,r:2.3,y0:t.pos.y,y1:t.pos.y+2.6},hitD);
      if(r){ hitD=r.t; hit=r; hitTank=t; hitSol=null; }
    }
    const sr=raySoldiers(s.pos,dir,hitD,{team:s.team,isPlayer:false});
    if(sr){ hitD=sr.t; hitSol=sr.sol; hitTank=null; hit=sr; }
    if(hit){
      const p=V3(s.pos.x+dir.x*hitD,s.pos.y+dir.y*hitD,s.pos.z+dir.z*hitD);
      if(hitTank){ hitTank.takeDmg(s.kind==='at'?rand(300,380):rand(260,330),s.owner); }
      if(hitSol){ hitSol.damage(200,s.owner,false); }
      explosionFXSmall(p);
      AudioSys.explosion(p.distanceTo(camera.position)*1.6);
      splashDamage(p,s.owner,s.team,5,s.kind==='at'?70:95,60);
      shells.splice(i,1);
      continue;
    }
    s.pos.addScaledVector(dir,step);
  }
}
function explosionFXSmall(p){
  spawnP(PT.flash,p.x,p.y+0.3,p.z,0,1,0, 1.6,10,0.12,1,0,true);
  for(let i=0;i<6;i++) spawnP(PT.dark,p.x+rand(-0.5,0.5),p.y+rand(0.2,1),p.z+rand(-0.5,0.5), rand(-1.5,1.5),rand(1,3),rand(-1.5,1.5), rand(0.6,1),1.8,rand(0.7,1.3),0.8,0.6);
  for(let i=0;i<6;i++) spawnP(PT.dirt,p.x,p.y+0.2,p.z, rand(-4,4),rand(2,6),rand(-4,4), rand(0.25,0.5),0.5,rand(0.5,0.9),0.9,8);
}
function splashDamage(p,attacker,team,radius,dmgMax,dmgVeh){
  for(const s of combatants){
    if(!s.alive||s.team===team) continue;
    const d=Math.hypot(s.pos.x-p.x,(s.pos.y+0.9)-p.y,s.pos.z-p.z);
    if(d<radius) s.damage(lerp(dmgMax,10,d/radius),attacker,false);
  }
  for(const t of tanks){
    if(!t.alive||t.team===team) continue;
    const d=t.pos.distanceTo(p);
    if(d<radius+2.5) t.takeDmg(lerp(dmgVeh,10,d/(radius+2.5)),attacker);
  }
  if(player.alive&&p.distanceTo(camera.position)<18) addTrauma(0.5);
}

/* ---------- 战斗机(AI空中支援) ---------- */
const planes=[], flakShots=[];
class Plane {
  constructor(team){
    this.team=team; this.hp=110; this.alive=true;
    this.name=team===0?'P-47 雷电':'BF-109';
    this.pos=V3(rand(-100,100),65,team===0?-160:160);
    this.yaw=team===0?0:Math.PI; this.pitch=0; this.speed=44; this.roll=0;
    this.crew={ name:this.name, team, kills:0, deaths:0, score:0, isPlayer:false, isCrew:true, pos:this.pos, alive:true, lastFiredT:-99, damage(){}, vel:V3() };
    this.state='patrol'; this.stateT=rand(6,14);
    this.target=null; this.fireT=0; this.bombCd=rand(10,20);
    this.respawnT=0; this.smokeT=0;
    const mat=new THREE.MeshLambertMaterial({color:team===0?0x74806a:0x70747a});
    this.grp=new THREE.Group();
    const fuse=new THREE.Mesh(new THREE.CylinderGeometry(0.28,0.4,3.6,8),mat);
    fuse.rotation.x=-HPI; this.grp.add(fuse);
    const wing=new THREE.Mesh(new THREE.BoxGeometry(7.6,0.1,1.5),mat);
    wing.position.set(0,-0.05,0.2); this.grp.add(wing);
    const tail=new THREE.Mesh(new THREE.BoxGeometry(2.6,0.08,0.9),mat);
    tail.position.set(0,0.1,-1.7); this.grp.add(tail);
    const fin=new THREE.Mesh(new THREE.BoxGeometry(0.08,0.8,0.8),mat);
    fin.position.set(0,0.4,-1.7); this.grp.add(fin);
    const cockpit=new THREE.Mesh(new THREE.SphereGeometry(0.3,8,6),new THREE.MeshLambertMaterial({color:0x36404a}));
    cockpit.position.set(0,0.3,0.3); this.grp.add(cockpit);
    this.prop=new THREE.Mesh(new THREE.BoxGeometry(1.8,0.12,0.05),new THREE.MeshLambertMaterial({color:0x1a140e}));
    this.prop.position.set(0,0,1.95); this.grp.add(this.prop);
    this.grp.traverse(o=>{ if(o.isMesh) o.castShadow=true; });
    world.add(this.grp);
    this.engine=AudioSys.createEngine('plane');
    planes.push(this);
  }
  update(dt){
    if(!this.alive){
      this.respawnT-=dt;
      this.engine.update(0,0);
      if(this.respawnT<=0&&!matchOver) this.respawn();
      return;
    }
    const dCam=this.pos.distanceTo(camera.position);
    this.engine.update(120+this.speed*1.5, clamp(0.22-dCam/280,0,0.22));
    this.prop.rotation.z+=dt*40;
    this.stateT-=dt; this.fireT-=dt; this.bombCd-=dt;
    if(this.state==='patrol'){
      // 环绕巡航
      const cx=Math.sin(nowT*0.1+this.team*3)*110, cz=Math.cos(nowT*0.1+this.team*3)*110;
      const wantYaw=Math.atan2(cx-this.pos.x,cz-this.pos.z);
      const dy=angDiff(this.yaw,wantYaw);
      this.yaw+=clamp(dy,-0.5*dt,0.5*dt);
      this.roll=dampF(this.roll,clamp(dy*1.4,-0.8,0.8),3,dt);
      this.pitch=dampF(this.pitch,clamp((62-this.pos.y)*0.02,-0.3,0.3),2,dt);
      if(this.stateT<=0){
        // 挑选俯冲目标
        let best=null,bd=1e9;
        for(const t of tanks){ if(t.alive&&t.team!==this.team){ const d=t.pos.distanceTo(this.pos); if(d<250&&d<bd){ bd=d; best=t; } } }
        if(!best||Math.random()<0.55){
          for(const s of combatants){
            if(!s.alive||s.team===this.team||s.onVehicle) continue;
            const d=Math.hypot(s.pos.x-this.pos.x,s.pos.z-this.pos.z);
            if(d<200&&d<bd){ bd=d; best=s; }
          }
        }
        if(best){ this.target=best; this.state='dive'; this.stateT=7; }
        else this.stateT=rand(4,8);
      }
    } else if(this.state==='dive'){
      const tgt=this.target;
      if(!tgt||!tgt.alive||this.stateT<=0){ this.state='climb'; this.stateT=4; }
      else {
        const tp=tgt.pos;
        const wantYaw=Math.atan2(tp.x-this.pos.x,tp.z-this.pos.z);
        const dy=angDiff(this.yaw,wantYaw);
        this.yaw+=clamp(dy,-0.9*dt,0.9*dt);
        this.roll=dampF(this.roll,clamp(dy*1.6,-1,1),4,dt);
        const dh=Math.hypot(tp.x-this.pos.x,tp.z-this.pos.z);
        this.pitch=dampF(this.pitch,clamp(Math.atan2((tp.y+2)-this.pos.y,Math.max(dh,5)),-0.55,0.1),2.5,dt);
        // 航炮
        if(this.fireT<=0&&dh<110&&Math.abs(dy)<0.25&&this.pos.y>12){
          this.fireT=0.085;
          const dir=V3(Math.sin(this.yaw)*Math.cos(this.pitch),Math.sin(this.pitch),Math.cos(this.yaw)*Math.cos(this.pitch));
          dir.x+=rand(-0.02,0.02); dir.y+=rand(-0.015,0.015); dir.z+=rand(-0.02,0.02); dir.normalize();
          const o=this.pos.clone().addScaledVector(dir,2.2);
          fireBullet(this.crew,o,dir,{dmg:17,headMul:1.5,snd:'smg',tracer:1},o);
          // 对坦克打击
          if(tgt.takeDmg&&Math.random()<0.3){
            const r=rayCyl(o,dir,{x:tgt.pos.x,z:tgt.pos.z,r:2.3,y0:tgt.pos.y,y1:tgt.pos.y+2.6},130);
            if(r) tgt.takeDmg(4,this.crew);
          }
        }
        // 投弹
        if(this.bombCd<=0&&dh<40&&this.pos.y<45){
          this.bombCd=rand(16,26);
          const m=new THREE.Mesh(new THREE.SphereGeometry(0.24,8,6),new THREE.MeshLambertMaterial({color:0x24241e}));
          const p=this.pos.clone().add(V3(0,-1,0));
          m.position.copy(p); m.castShadow=true; scene.add(m);
          const bv=V3(Math.sin(this.yaw)*this.speed*0.8,-6,Math.cos(this.yaw)*this.speed*0.8);
          nades.push({m,pos:p,vel:bv,fuse:5,thrower:this.crew,team:this.team,bounces:0,spin:V3(2,0,0),bomb:true});
        }
        // 拉起保护
        const gh=heightAt(this.pos.x,this.pos.z);
        if(this.pos.y<gh+16){ this.state='climb'; this.stateT=4; }
      }
    } else { // climb
      this.pitch=dampF(this.pitch,0.4,2,dt);
      this.roll=dampF(this.roll,0,2,dt);
      if(this.stateT<=0||this.pos.y>58){ this.state='patrol'; this.stateT=rand(8,16); this.target=null; }
    }
    // 运动
    this.pos.x+=Math.sin(this.yaw)*Math.cos(this.pitch)*this.speed*dt;
    this.pos.z+=Math.cos(this.yaw)*Math.cos(this.pitch)*this.speed*dt;
    this.pos.y+=Math.sin(this.pitch)*this.speed*dt;
    const lim2=MAP_SIZE/2+140;
    if(Math.abs(this.pos.x)>lim2||Math.abs(this.pos.z)>lim2){
      const wantYaw=Math.atan2(-this.pos.x,-this.pos.z);
      this.yaw=angleLerpTo(this.yaw,wantYaw,0.6*dt);
    }
    this.grp.position.copy(this.pos);
    this.grp.rotation.order='YXZ';
    this.grp.rotation.set(-this.pitch,this.yaw,this.roll);
    // 受损冒烟
    if(this.hp<60){
      this.smokeT-=dt;
      if(this.smokeT<=0){ this.smokeT=0.06;
        spawnP(PT.dark,this.pos.x,this.pos.y,this.pos.z,0,0,0,0.8,1.2,1.2,0.6,0);
      }
    }
    // 坠地
    const gh=heightAt(this.pos.x,this.pos.z);
    if(this.pos.y<gh+2) this.die(this.lastHitBy||null);
  }
  takeDmg(amt,attacker){
    if(!this.alive) return;
    this.hp-=amt;
    this.lastHitBy=attacker;
    if(this.hp<=0) this.die(attacker);
  }
  die(attacker){
    this.alive=false; this.respawnT=rand(35,48); this.crew.alive=false;
    if(attacker&&attacker.team!==this.team){
      attacker.kills=(attacker.kills||0)+1;
      attacker.score=(attacker.score||0)+250;
      addKillfeed(attacker,{name:this.name,team:this.team,isPlayer:false},false);
      if(attacker.isPlayer) showScorePop('+250 击落战机');
    }
    tickets[this.team]=Math.max(0,tickets[this.team]-3);
    explosionFX(this.pos.clone());
    AudioSys.explosion(this.pos.distanceTo(camera.position));
    this.grp.visible=false;
  }
  respawn(){
    this.pos.set(rand(-100,100),65,this.team===0?-160:160);
    this.yaw=this.team===0?0:Math.PI; this.pitch=0; this.roll=0;
    this.hp=110; this.alive=true; this.crew.alive=true; this.speed=44;
    this.state='patrol'; this.stateT=rand(5,10); this.target=null;
    this.grp.visible=true;
  }
}
function enemyPlaneAlive(team){
  for(const p of planes) if(p.alive&&p.team!==team) return p;
  return null;
}
/* ---------- 高射炮弹 ---------- */
function fireFlak(gun,shooter,dir){
  const o=V3(gun.x,gun.y+0.4,gun.z).addScaledVector(dir,1.6);
  AudioSys.gunshot('mg',o.distanceTo(camera.position),0);
  spawnP(PT.flash,o.x,o.y,o.z,dir.x*2,dir.y*2,dir.z*2,0.35,3,0.07,1,0,true);
  flakShots.push({pos:o.clone(),vel:dir.clone().multiplyScalar(150),team:shooter.team,owner:shooter,life:2.6});
}
function updateFlak(dt){
  for(let i=flakShots.length-1;i>=0;i--){
    const f=flakShots[i];
    f.life-=dt;
    f.pos.addScaledVector(f.vel,dt);
    let boom=f.life<=0;
    for(const p of planes){
      if(!p.alive||p.team===f.team) continue;
      if(p.pos.distanceTo(f.pos)<7){
        p.takeDmg(rand(28,50),f.owner);
        boom=true;
      }
    }
    if(boom){
      spawnP(PT.dark,f.pos.x,f.pos.y,f.pos.z,0,0.4,0,1.6,2.4,1.6,0.85,0);
      spawnP(PT.flash,f.pos.x,f.pos.y,f.pos.z,0,0,0,0.9,5,0.09,1,0,true);
      AudioSys.flakPop(f.pos.distanceTo(camera.position));
      flakShots.splice(i,1);
    }
  }
}

/* ---------- 反坦克炮 / 防空炮 布置 ---------- */
const ATGUNS=[], AAGUNS=[];
function atGun(x,z,face){
  const g0=heightAt(x,z);
  sandbagWall(x-Math.sin(face)*1.6,z-Math.cos(face)*1.6,3.5,face+HPI);
  const grp=new THREE.Group(); grp.position.set(x,g0+0.85,z); grp.rotation.y=face;
  const yaw=new THREE.Group(); grp.add(yaw);
  const pitch=new THREE.Group(); yaw.add(pitch);
  const shield=new THREE.Mesh(new THREE.BoxGeometry(2.0,0.95,0.06),MAT.metal);
  shield.position.set(0,0.15,0.35); yaw.add(shield);
  const barrel=new THREE.Mesh(new THREE.CylinderGeometry(0.06,0.08,2.6,8),vmMats.gun);
  barrel.rotation.x=HPI; barrel.position.z=1.3; pitch.add(barrel);
  pitch.add(new THREE.Mesh(new THREE.BoxGeometry(0.32,0.3,0.6),vmMats.gunL));
  const wl=new THREE.Mesh(new THREE.CylinderGeometry(0.4,0.4,0.12,12),vmMats.gunL);
  wl.rotation.z=HPI; wl.position.set(0.75,-0.35,0); grp.add(wl);
  const wr2=wl.clone(); wr2.position.x=-0.75; grp.add(wr2);
  grp.traverse(o=>{ if(o.isMesh) o.castShadow=true; });
  world.add(grp);
  CYLS.push({x,z,r:0.6,y0:g0,y1:g0+1.2});
  ATGUNS.push({x,z,y:g0+0.85,face,grp,yaw,pitch,user:null,cd:0,kind:'at'});
}
function aaGun(x,z){
  const g0=heightAt(x,z);
  const grp=new THREE.Group(); grp.position.set(x,g0+1.15,z);
  const yaw=new THREE.Group(); grp.add(yaw);
  const pitch=new THREE.Group(); pitch.position.y=0.1; yaw.add(pitch);
  const b1=new THREE.Mesh(new THREE.CylinderGeometry(0.05,0.06,1.7,8),vmMats.gun);
  b1.rotation.x=HPI; b1.position.set(0.22,0,0.85); pitch.add(b1);
  const b2=b1.clone(); b2.position.x=-0.22; pitch.add(b2);
  pitch.add(new THREE.Mesh(new THREE.BoxGeometry(0.7,0.35,0.7),vmMats.gunL));
  const ped=new THREE.Mesh(new THREE.CylinderGeometry(0.2,0.28,1.1,8),vmMats.gunL);
  ped.position.y=-0.6; grp.add(ped);
  const base=new THREE.Mesh(new THREE.CylinderGeometry(0.9,1.0,0.18,10),MAT.metal);
  base.position.y=-1.1; grp.add(base);
  grp.traverse(o=>{ if(o.isMesh) o.castShadow=true; });
  world.add(grp);
  CYLS.push({x,z,r:0.7,y0:g0,y1:g0+1.4});
  AAGUNS.push({x,z,y:g0+1.25,grp,yaw,pitch,user:null,cd:0,kind:'aa'});
}
atGun(-58,30,HPI*0.9);      // A点东向(对德军坦克来向)
atGun(-20,-14,HPI);         // 中部西侧
atGun(58,-30,-HPI*0.9);     // C点西向
atGun(22,16,-HPI);          // 中部东侧
aaGun(-108,10);             // 美军基地
aaGun(108,-10);             // 德军基地
aaGun(6,18);                // B点村内
NAV.refresh();              // 新增炮位沙袋纳入寻路/弹道

/* 出生点选择 */
function pickSpawnFor(team){
  const opts=[BASES[team]];
  for(const f of FLAGS) if(f.owner===team) opts.push(f);
  // 优先离敌人有一定距离且靠前的点
  let best=opts[0],bs=-1e9;
  for(const o of opts){
    let sc=rand(0,40);
    let nearestEnemy=1e9;
    for(const c of combatants){
      if(c.alive&&c.team!==team) nearestEnemy=Math.min(nearestEnemy,Math.hypot(c.pos.x-o.x,c.pos.z-o.z));
    }
    if(nearestEnemy<18) sc-=200;
    // 靠近争夺点
    for(const f of FLAGS){ if(f.capTeam===team) sc+=40-Math.hypot(f.x-o.x,f.z-o.z)*0.3; }
    if(sc>bs){ bs=sc; best=o; }
  }
  return best;
}

/* ================================================================
   模块E:玩家控制器 / 征服模式 / HUD / 主循环
================================================================ */
let nowT=0, matchOver=false, matchTime=15*60;
const tickets=[START_TICKETS,START_TICKETS];
let nadeWarnT=0;

/* ---------- 玩家对象 ---------- */
const player = {
  isPlayer:true, name:'你', team:0, cls:0,
  pos:V3(-118,1,0), vel:V3(), yaw:HPI, pitch:0,
  hp:100, alive:false, deployed:false, crouch:false, onGround:true,
  stamina:1, sprinting:false, ads:false, holdBreath:false,
  mouseDX:0, mouseDY:0, landDip:0, airT:0,
  slots:[], curSlot:0, curW:null, nadeCount:2,
  fireT:0, bloom:0, lastDmgT:-99, deathT:0,
  recoilPitch:0, recoilYaw:0,
  kills:0, deaths:0, score:0,
  nadeHeld:false, nadeFuse:0, onMG:null, resupplyCd:0,
  onVehicle:null, onAT:null, onAA:null, atNades:0, nadeIsAT:false,
  suppressV:0, meleeCd:0,
  eyeH:1.62,
  lastFiredT:-99, killerName:'',
  suppress(a){ this.suppressV=Math.min(1,this.suppressV+a); },
  damage(amt,attacker,isHead){
    if(!this.alive||matchOver) return;
    this.hp-=amt;
    this.lastDmgT=nowT;
    AudioSys.hurt();
    dmgFlash=Math.min(1,dmgFlash+0.5);
    addTrauma(0.25);
    if(attacker){
      const a=Math.atan2(attacker.pos.x-this.pos.x,attacker.pos.z-this.pos.z);
      addDirHit(a);
    }
    if(this.hp<=0){
      this.hp=0;
      this.die(attacker,isHead);
    }
  },
  die(attacker){
    this.alive=false;
    this.deaths++;
    this.deathT=nowT;
    tickets[this.team]=Math.max(0,tickets[this.team]-1);
    this.killerName=attacker?(attacker.name||'敌军'):'';
    if(attacker&&attacker!==this){ attacker.kills=(attacker.kills||0)+1; attacker.score=(attacker.score||0)+100; addKillfeed(attacker,this,false); }
    if(this.onMG){ this.onMG.user=null; this.onMG=null; }
    if(this.onAT){ this.onAT.user=null; this.onAT=null; }
    if(this.onAA){ this.onAA.user=null; this.onAA=null; }
    this.onVehicle=null;
    this.nadeHeld=false;
    document.exitPointerLock&&document.exitPointerLock();
    document.getElementById('blackOv').style.opacity=0.75;
    respawnCd=8;
    setTimeout(()=>{ if(!matchOver) showDeploy(true); },1600);
  },
  doMeleeHit(){
    const dir=camForward();
    const o=camera.position.clone();
    const sr=raySoldiers(o,dir,2.6,player);
    if(sr){
      sr.sol.damage(110,player,false);
      AudioSys.click(500,0.5,0.09);
      addTrauma(0.15);
    } else {
      const wr=raycastWorld(o,dir,2.4);
      if(wr){ impactFX(wr.point,wr.normal,wr.kind); AudioSys.click(1800,0.3,0.05); }
    }
  },
  releaseNade(){
    const dir=camForward();
    const o=camera.position.clone().add(dir.clone().multiplyScalar(0.3));
    if(this.nadeIsAT){
      const v=dir.multiplyScalar(12).add(V3(0,2.2,0)).add(this.vel.clone().multiplyScalar(0.4));
      throwNade(this,o,v,1.6,true);
      this.atNades--;
      this.nadeIsAT=false;
    } else {
      const v=dir.multiplyScalar(15).add(V3(0,2.4,0)).add(this.vel.clone().multiplyScalar(0.4));
      throwNade(this,o,v,this.nadeFuse);
      this.nadeCount--;
    }
    AudioSys.metalSlide(0.2,0.08,600,300);
  },
  finishReload(){
    const w=this.curW;
    if(!w) return;
    const need=w.def.mag-w.mag;
    const take=Math.min(need,w.reserve);
    w.mag+=take; w.reserve-=take;
  },
};
combatants.push(player);

/* ---------- 输入 ---------- */
const keys={};
let pointerLocked=false;
addEventListener('keydown',e=>{
  if(e.code==='Tab'){ e.preventDefault(); document.getElementById('scoreboard').style.display='block'; updateScoreboard(); }
  if(e.repeat) return;
  keys[e.code]=true;
  if(!player.alive||!pointerLocked) return;
  if(e.code==='KeyR') tryReload();
  if(e.code==='KeyC') player.crouch=!player.crouch;
  if(e.code==='Digit1') switchSlot(0);
  if(e.code==='Digit2') switchSlot(1);
  if(e.code==='KeyV'){ if(VM.state==='idle'&&!player.onMG&&!player.onVehicle&&!player.onAT&&!player.onAA&&player.meleeCd<=0){ VM.state='melee'; VM.stateT=0; VM.stateDur=0.5; player.meleeCd=0.7; vmSndFlags={}; AudioSys.metalSlide(0.15,0.06,1200,2000); } }
  if(e.code==='KeyG'){
    if(player.nadeCount>0&&VM.state==='idle'&&!player.onMG&&!player.onVehicle&&!player.onAT&&!player.onAA){
      VM.state='nade'; VM.stateT=0; VM.stateDur=999;
      player.nadeHeld=true; player.nadeFuse=3.8; player.nadeIsAT=false;
      vmSndFlags={};
      AudioSys.click(1600,0.25,0.04);
    }
  }
  if(e.code==='Digit3'){
    if(player.atNades>0&&VM.state==='idle'&&!player.onMG&&!player.onVehicle&&!player.onAT&&!player.onAA){
      VM.state='nade'; VM.stateT=0; VM.stateDur=0.6;
      player.nadeHeld=false; player.nadeIsAT=true;
      vmSndFlags={};
      AudioSys.click(1200,0.3,0.05);
    }
  }
  if(e.code==='KeyE') tryInteract();
});
addEventListener('keyup',e=>{
  keys[e.code]=false;
  if(e.code==='Tab') document.getElementById('scoreboard').style.display='none';
  if(e.code==='KeyG'&&player.nadeHeld){
    player.nadeHeld=false;
    VM.stateT=0; VM.stateDur=0.55;
  }
});
let mouseDown=false;
addEventListener('mousedown',e=>{
  if(!pointerLocked){ return; }
  if(e.button===0) mouseDown=true;
  if(e.button===2&&player.alive&&!player.onMG&&!player.onVehicle&&!player.onAT&&!player.onAA) player.ads=true;
});
addEventListener('mouseup',e=>{
  if(e.button===0) mouseDown=false;
  if(e.button===2) player.ads=false;
});
addEventListener('contextmenu',e=>e.preventDefault());
addEventListener('mousemove',e=>{
  if(!pointerLocked||!player.alive) return;
  const d=WPN_DEFS[VM.key]||{};
  const zoomFac=player.ads?(d.adsFov||60)/74:1;
  const s=0.0022*SETTINGS.sens*zoomFac;
  player.yaw-=e.movementX*s;
  player.pitch-=e.movementY*s;
  player.pitch=clamp(player.pitch,-1.45,1.45);
  player.mouseDX=e.movementX; player.mouseDY=e.movementY;
});
document.addEventListener('pointerlockchange',()=>{
  pointerLocked=document.pointerLockElement===renderer.domElement;
  if(!pointerLocked&&player.alive&&player.deployed&&!matchOver){
    showDeploy(false);
  }
});
function lockPointer(){ renderer.domElement.requestPointerLock(); }

function tryReload(){
  const w=player.curW;
  if(!w||player.onMG||player.onVehicle||player.onAT||player.onAA) return;
  if(VM.state!=='idle') return;
  if(w.mag>=w.def.mag||w.reserve<=0) return;
  VM.state='reload'; VM.stateT=0; VM.stateDur=w.def.reload;
  vmSndFlags={};
}
function switchSlot(i){
  if(i===player.curSlot||!player.slots[i]||player.onMG||player.onVehicle||player.onAT||player.onAA) return;
  if(VM.state!=='idle'&&VM.state!=='fire') return;
  player.curSlot=i;
  player.curW=player.slots[i];
  vmEquip(player.curW.key,player.team);
}
function tryInteract(){
  if(!player.alive) return;
  // 离开载具/炮位
  if(player.onVehicle){
    const t=player.onVehicle;
    t.playerDriven=false; t.isAI=true; t.vel=0;
    player.onVehicle=null;
    // 找车旁落脚点
    for(const off of [[3.2,0],[-3.2,0],[0,4],[0,-4],[4,4]]){
      const px=t.pos.x+off[0], pz=t.pos.z+off[1];
      if(!occBlocked(px,pz)){ player.pos.set(px,0,pz); break; }
      player.pos.set(t.pos.x+3.2,0,t.pos.z);
    }
    player.pos.y=standHeight(player.pos.x,player.pos.z,t.pos.y+2);
    player.vel.set(0,0,0);
    VM.root.visible=true;
    document.getElementById('heatWrap').style.display='none';
    return;
  }
  if(player.onMG){
    player.onMG.user=null; player.onMG=null;
    VM.root.visible=true;
    document.getElementById('heatWrap').style.display='none';
    return;
  }
  if(player.onAT){ player.onAT.user=null; player.onAT=null; VM.root.visible=true; document.getElementById('heatWrap').style.display='none'; return; }
  if(player.onAA){ player.onAA.user=null; player.onAA=null; VM.root.visible=true; document.getElementById('heatWrap').style.display='none'; return; }
  // 进入坦克(仅我方)
  for(const t of tanks){
    if(!t.alive||t.team!==player.team) continue;
    if(Math.hypot(t.pos.x-player.pos.x,t.pos.z-player.pos.z)<4.5){
      t.playerDriven=true; t.isAI=false; t.vel=0;
      player.onVehicle=t;
      player.ads=false; player.nadeHeld=false;
      player.yaw=t.yaw+t.turretYaw+Math.PI; player.pitch=0;
      VM.root.visible=false;
      document.getElementById('heatWrap').style.display='block';
      return;
    }
  }
  // MG42
  for(const mg of MG42S){
    if(mg.user) continue;
    if(Math.hypot(mg.x-player.pos.x,mg.z-player.pos.z)<2.6){
      player.onMG=mg; mg.user=player;
      player.pos.x=mg.x-Math.sin(mg.face)*0.85;
      player.pos.z=mg.z-Math.cos(mg.face)*0.85;
      player.yaw=mg.face; player.ads=false;
      VM.root.visible=false;
      document.getElementById('heatWrap').style.display='block';
      return;
    }
  }
  // 反坦克炮
  for(const g of ATGUNS){
    if(g.user) continue;
    if(Math.hypot(g.x-player.pos.x,g.z-player.pos.z)<3){
      player.onAT=g; g.user=player;
      player.pos.x=g.x-Math.sin(g.face)*1.3;
      player.pos.z=g.z-Math.cos(g.face)*1.3;
      player.yaw=g.face; player.ads=false;
      VM.root.visible=false;
      document.getElementById('heatWrap').style.display='block';
      return;
    }
  }
  // 防空炮
  for(const g of AAGUNS){
    if(g.user) continue;
    if(Math.hypot(g.x-player.pos.x,g.z-player.pos.z)<3){
      player.onAA=g; g.user=player;
      player.pos.x=g.x; player.pos.z=g.z-0.9;
      player.pitch=0.5; player.ads=false;
      VM.root.visible=false;
      document.getElementById('heatWrap').style.display='block';
      return;
    }
  }
  // 弹药箱
  if(player.resupplyCd<=0){
    for(const ac of AMMO_CRATES){
      if(Math.hypot(ac.x-player.pos.x,ac.z-player.pos.z)<2.6){
        for(const s of player.slots){ s.reserve=s.def.reserve; }
        player.nadeCount=CLASSES[player.cls].nades;
        player.atNades=player.cls===2?2:0;
        player.resupplyCd=6;
        AudioSys.metalSlide(0.3,0.15,500,900);
        showScorePop('弹药已补充');
        return;
      }
    }
  }
}

/* ---------- 玩家更新 ---------- */
let camTrauma=0, dmgFlash=0;
function addTrauma(x){ camTrauma=Math.min(1.2,camTrauma+x); }
function updatePlayer(dt){
  if(!player.alive){ return; }
  const p=player;
  p.meleeCd-=dt; p.resupplyCd-=dt;
  p.suppressV=Math.max(0,p.suppressV-dt*0.8);
  // 生命恢复
  if(nowT-p.lastDmgT>6&&p.hp<100) p.hp=Math.min(100,p.hp+9*dt);
  // 载具/炮位模式
  if(p.onVehicle){ updatePlayerTank(dt); return; }
  if(p.onAT){ updatePlayerAT(dt); return; }
  if(p.onAA){ updatePlayerAA(dt); return; }
  // MG42 模式
  if(p.onMG){ updatePlayerMG(dt); return; }
  // ---- 移动 ----
  const fwd=(keys.KeyW?1:0)-(keys.KeyS?1:0);
  const str=(keys.KeyD?1:0)-(keys.KeyA?1:0);
  const wantSprint=keys.ShiftLeft&&fwd>0&&!p.ads&&p.stamina>0.05&&!p.crouch;
  p.sprinting=wantSprint;
  p.holdBreath=keys.ShiftLeft&&p.ads;
  if(p.sprinting) p.stamina=Math.max(0,p.stamina-dt*0.22);
  else if(p.holdBreath) p.stamina=Math.max(0,p.stamina-dt*0.3);
  else p.stamina=Math.min(1,p.stamina+dt*0.16);
  if(p.holdBreath&&p.stamina<=0.02) p.holdBreath=false;
  let speed=p.crouch?2.2:(p.sprinting?6.4:4.3);
  if(p.ads) speed*=0.55;
  if(VM.state==='reload') speed*=0.8;
  const sy=Math.sin(p.yaw), cy=Math.cos(p.yaw);
  let mx=(-sy*fwd + cy*str), mz=(-cy*fwd - sy*str);
  const ml=Math.hypot(mx,mz);
  if(ml>0.01){ mx/=ml; mz/=ml; }
  const accel=p.onGround?38:6;
  p.vel.x=dampF(p.vel.x,mx*speed,accel*dt>1?12:accel,dt);
  p.vel.z=dampF(p.vel.z,mz*speed,accel*dt>1?12:accel,dt);
  // 跳跃/重力
  if(keys.Space&&p.onGround&&p.stamina>0.1){ p.vel.y=4.6; p.onGround=false; p.stamina-=0.08; p.crouch=false; }
  p.vel.y-=13*dt;
  p.pos.x+=p.vel.x*dt; p.pos.y+=p.vel.y*dt; p.pos.z+=p.vel.z*dt;
  collideMove(p.pos,0.42);
  const gh=standHeight(p.pos.x,p.pos.z,p.pos.y+0.6);
  if(p.pos.y<=gh){
    if(!p.onGround&&p.vel.y<-4){ p.landDip=Math.min(1,-p.vel.y*0.1); AudioSys.footstep(true,0); }
    p.pos.y=gh; p.vel.y=0; p.onGround=true;
  } else if(p.pos.y>gh+0.05) p.onGround=false;
  p.landDip=Math.max(0,p.landDip-dt*3);
  // 脚步声
  const sp2=Math.hypot(p.vel.x,p.vel.z);
  p.stepT=(p.stepT||0)-dt*sp2*(p.sprinting?1.25:1);
  if(p.stepT<=0&&sp2>0.8&&p.onGround){ p.stepT=1.55; AudioSys.footstep(p.sprinting,0); }
  // ---- 开火 ----
  const w=p.curW;
  p.fireT-=dt;
  p.bloom=Math.max(0,p.bloom-dt*4);
  if(mouseDown&&w&&VM.state==='idle'&&p.fireT<=0&&!matchOver){
    if(w.mag>0){
      if(w.def.type==='auto'||!p.fireHeld){
        playerShoot(w);
      }
    } else {
      if(!p.fireHeld){ AudioSys.click(2600,0.2,0.03); tryReload(); }
    }
    p.fireHeld=true;
  }
  if(!mouseDown) p.fireHeld=false;
  // 持雷烹饪
  if(p.nadeHeld){
    p.nadeFuse-=dt;
    if(p.nadeFuse<=0){
      p.nadeHeld=false;
      p.nadeCount--;
      VM.state='idle'; VM.gunParts.gun.visible=true; VM.nadeM.visible=false;
      explodeAt(p.pos.clone().add(V3(0,1,0)),p);
    }
  }
  // 后座恢复
  p.recoilPitch*=Math.pow(0.0001,dt*0.7);
  p.recoilYaw*=Math.pow(0.0001,dt*0.7);
  // 交互提示
  updateInteractHint();
}
function playerShoot(w){
  const p=player;
  w.mag--;
  p.fireT=60/w.def.rpm;
  const dir=camForward();
  // 散布
  const adsB=VM.adsBlend;
  let spread=lerp(w.def.spreadHip,w.def.spreadAds,adsB)*(Math.PI/180)*0.5;
  spread*=(1+p.bloom*1.4)*(p.crouch?0.8:1)*(p.onGround?1:2.2)*(Math.hypot(p.vel.x,p.vel.z)>1?1.4:1);
  if(p.holdBreath) spread*=0.4;
  const rv=camRight(), uv=rv.clone().cross(dir).negate();
  dir.addScaledVector(rv,rand(-spread,spread)).addScaledVector(uv,rand(-spread,spread)).normalize();
  p.bloom=Math.min(1,p.bloom+0.16);
  // 后座
  const rec=w.def.recoil*0.011*(p.crouch?0.85:1)*(adsB>0.5?0.85:1);
  p.recoilPitch+=rec;
  p.recoilYaw+=rand(-1,1)*w.def.recSide*0.006;
  p.pitch+=rec*0.55;
  p.yaw+=rand(-1,1)*w.def.recSide*0.003;
  vmFireKick();
  addTrauma(w.def.kick*0.9);
  AudioSys.gunshot(w.def.snd,0,0);
  const o=camera.position.clone();
  fireBullet(p,o,dir,w.def,null);
  // 弹壳
  if(!w.def.enbloc||w.mag>0){
    const cp=camera.position.clone().add(camForward().multiplyScalar(0.35)).add(camRight().multiplyScalar(0.14)).add(V3(0,-0.05,0));
    if(w.def.type!=='bolt') spawnCasing(cp,camRight(),V3(0,1,0));
  }
  // 加兰德 ping
  if(w.def.enbloc&&w.mag===0){
    setTimeout(()=>AudioSys.ping(),80);
    const clipP=camera.position.clone().add(camForward().multiplyScalar(0.4));
    spawnCasing(clipP,camRight(),V3(0,1.5,0));
  }
  // 栓动
  if(w.def.type==='bolt'&&w.mag>0){
    VM.state='bolt'; VM.stateT=0; VM.stateDur=w.def.boltT||1.0;
    vmSndFlags={};
  }
  if(w.mag===0&&w.def.type!=='bolt'){ setTimeout(tryReload,350); }
  else if(w.mag===0){ setTimeout(tryReload,1000); }
}
/* ---------- 玩家载具/炮位控制 ---------- */
function updatePlayerTank(dt){
  const p=player, t=p.onVehicle;
  if(!t.alive){ p.onVehicle=null; return; }
  // 驾驶
  const fwd=(keys.KeyW?1:0)-(keys.KeyS?1:0);
  const turn=(keys.KeyD?1:0)-(keys.KeyA?1:0);
  t.vel=dampF(t.vel,fwd*6.2,2.5,dt);
  t.yaw-=turn*0.85*dt*(fwd<0?-1:1);
  // 炮塔跟随视角(相机yaw与坦克yaw相差π)
  const wantTY=angDiff(t.yaw,p.yaw+Math.PI);
  t.turretYaw=angleLerpTo(t.turretYaw,wantTY,2.2*dt);
  t.turretPitch=dampF(t.turretPitch,clamp(p.pitch,-0.12,0.32),6,dt);
  // 主炮
  p.fireT-=dt;
  if(mouseDown&&t.cannonCd<=0&&!matchOver){
    t.fireCannon();
  }
  // 同步玩家位置(用于占旗)
  p.pos.copy(t.pos);
  p.vel.set(0,0,0);
  document.getElementById('heatFill').style.width=(clamp(1-t.cannonCd/5.4,0,1)*100)+'%';
  document.getElementById('heatFill').style.background=t.cannonCd<=0?'#8fd18f':'linear-gradient(90deg,#e8c56a,#e83a1a)';
  updateInteractHint();
}
function updatePlayerAT(dt){
  const p=player, g=p.onAT;
  g.cd-=dt;
  const rel=clamp(angDiff(g.face,p.yaw),-1.0,1.0);
  p.yaw=g.face+rel;
  p.pitch=clamp(p.pitch,-0.12,0.3);
  g.yaw.rotation.y=rel;
  g.pitch.rotation.x=-p.pitch;
  p.pos.x=g.x-Math.sin(p.yaw)*1.3;
  p.pos.z=g.z-Math.cos(p.yaw)*1.3;
  p.pos.y=standHeight(p.pos.x,p.pos.z,p.pos.y+1);
  p.vel.set(0,0,0);
  if(mouseDown&&g.cd<=0&&!matchOver){
    g.cd=4.5;
    const dir=camForward();
    const o=V3(g.x,g.y+0.25,g.z).addScaledVector(dir,2.7);
    AudioSys.cannon(0);
    addTrauma(0.45);
    spawnP(PT.flash,o.x,o.y,o.z,dir.x*3,dir.y*3,dir.z*3,0.9,9,0.1,1,0,true);
    spawnP(PT.dark,o.x,o.y,o.z,dir.x*2,1,dir.z*2,0.9,2.6,0.9,0.7,0.5);
    shells.push({pos:o.clone(),vel:dir.clone().multiplyScalar(150),team:p.team,owner:p,life:3,kind:'at',trail:0});
    p.lastFiredT=nowT;
  }
  document.getElementById('heatFill').style.width=(clamp(1-g.cd/4.5,0,1)*100)+'%';
  updateInteractHint();
}
function updatePlayerAA(dt){
  const p=player, g=p.onAA;
  g.cd-=dt;
  p.pitch=clamp(p.pitch,0.02,1.35);
  g.yaw.rotation.y=p.yaw;
  g.pitch.rotation.x=-p.pitch;
  p.pos.x=g.x; p.pos.z=g.z-0.9;
  p.pos.y=standHeight(p.pos.x,p.pos.z,p.pos.y+1);
  p.vel.set(0,0,0);
  if(mouseDown&&g.cd<=0&&!matchOver){
    g.cd=0.42;
    const dir=camForward();
    fireFlak(g,p,dir);
    addTrauma(0.1);
    p.recoilPitch+=0.006;
    p.lastFiredT=nowT;
  }
  document.getElementById('heatFill').style.width=(clamp(1-g.cd/0.42,0,1)*100)+'%';
  updateInteractHint();
}
function updatePlayerMG(dt){
  const p=player, mg=p.onMG;
  const relYaw=angDiff(mg.face,p.yaw);
  if(Math.abs(relYaw)>1.05) p.yaw=mg.face+clamp(relYaw,-1.05,1.05);
  p.pitch=clamp(p.pitch,-0.5,0.32);
  p.pos.y=standHeight(p.pos.x,p.pos.z,p.pos.y+1);
  p.vel.set(0,0,0);
  mg.yaw.rotation.y=angDiff(mg.face,p.yaw);
  mg.pitch.rotation.x=-p.pitch*0.9;
  p.fireT-=dt;
  mg.heat=Math.max(0,mg.heat-dt*(mouseDown?0:0.22));
  if(mouseDown&&p.fireT<=0&&mg.heat<1&&!matchOver){
    p.fireT=60/1100;
    mg.heat+=0.014;
    if(mg.heat>=1){ AudioSys.metalSlide(0.4,0.3,400,200); }
    const dir=camForward();
    const spread=0.011+mg.heat*0.015;
    const rv=camRight(), uv=rv.clone().cross(dir).negate();
    dir.addScaledVector(rv,rand(-spread,spread)).addScaledVector(uv,rand(-spread,spread)).normalize();
    p.recoilPitch+=0.004;
    p.pitch+=0.0022;
    p.yaw+=rand(-1,1)*0.002;
    addTrauma(0.06);
    AudioSys.gunshot('mg',0,0);
    const o=V3(mg.x,mg.y+0.12,mg.z);
    const muzz=o.clone().addScaledVector(dir,1.35);
    fireBullet(p,o,dir,{dmg:28,headMul:1.9,snd:'mg',tracer:2},muzz);
    muzzleFXWorld(muzz,dir);
  }
  document.getElementById('heatFill').style.width=(mg.heat*100)+'%';
  updateInteractHint();
}
function updateInteractHint(){
  const el=document.getElementById('interact');
  if(player.onVehicle){ el.style.display='block'; el.innerHTML='<b>E</b> 离开坦克 · <b>WASD</b>驾驶 <b>鼠标</b>炮塔 <b>左键</b>开炮'; return; }
  if(player.onMG){ el.style.display='block'; el.innerHTML='<b>E</b> 离开机枪'; return; }
  if(player.onAT){ el.style.display='block'; el.innerHTML='<b>E</b> 离开反坦克炮'; return; }
  if(player.onAA){ el.style.display='block'; el.innerHTML='<b>E</b> 离开防空炮'; return; }
  for(const t of tanks){
    if(t.alive&&t.team===player.team&&Math.hypot(t.pos.x-player.pos.x,t.pos.z-player.pos.z)<4.5){
      el.style.display='block'; el.innerHTML='<b>E</b> 进入 '+t.name; return;
    }
  }
  for(const mg of MG42S){
    if(!mg.user&&Math.hypot(mg.x-player.pos.x,mg.z-player.pos.z)<2.6){
      el.style.display='block'; el.innerHTML='<b>E</b> 使用 MG42 机枪'; return;
    }
  }
  for(const g of ATGUNS){
    if(!g.user&&Math.hypot(g.x-player.pos.x,g.z-player.pos.z)<3){
      el.style.display='block'; el.innerHTML='<b>E</b> 使用反坦克炮'; return;
    }
  }
  for(const g of AAGUNS){
    if(!g.user&&Math.hypot(g.x-player.pos.x,g.z-player.pos.z)<3){
      el.style.display='block'; el.innerHTML='<b>E</b> 使用防空炮'; return;
    }
  }
  if(player.resupplyCd<=0){
    for(const ac of AMMO_CRATES){
      if(Math.hypot(ac.x-player.pos.x,ac.z-player.pos.z)<2.6){
        el.style.display='block'; el.innerHTML='<b>E</b> 补充弹药'; return;
      }
    }
  }
  el.style.display='none';
}

/* ---------- 摄像机 ---------- */
let camBobT=0;
function updateCamera(dt){
  const p=player;
  if(!p.alive){
    // 观战: 缓慢环绕
    const t=nowT*0.05;
    camera.position.set(Math.sin(t)*80,42,Math.cos(t)*80);
    camera.lookAt(0,2,0);
    return;
  }
  // 坦克第三人称
  if(p.onVehicle){
    const t=p.onVehicle;
    camTrauma=Math.max(0,camTrauma-dt*2.2);
    const sh=camTrauma*camTrauma*0.04;
    const anchor=V3(t.pos.x,t.pos.y+2.6,t.pos.z);
    camera.rotation.order='YXZ';
    camera.rotation.set(p.pitch+Math.sin(nowT*53)*sh, p.yaw+Math.sin(nowT*67)*sh, 0);
    const fwdV=V3(-Math.sin(p.yaw)*Math.cos(p.pitch), Math.sin(p.pitch), -Math.cos(p.yaw)*Math.cos(p.pitch));
    const back=fwdV.clone().negate(); back.y+=0.35; back.normalize();
    let camD=10.5;
    const hit=raycastWorld(anchor,back,camD);
    if(hit) camD=Math.max(2.5,hit.dist-0.5);
    camera.position.copy(anchor).addScaledVector(back,camD);
    camera.fov=dampF(camera.fov,68,10,dt);
    camera.updateProjectionMatrix();
    return;
  }
  const eyeTgt=p.crouch?1.12:1.62;
  p.eyeH=dampF(p.eyeH,eyeTgt,12,dt);
  let ex=p.pos.x, ey=p.pos.y+p.eyeH, ez=p.pos.z;
  // 走路视角摆动
  const sp2=Math.hypot(p.vel.x,p.vel.z);
  if(sp2>0.5&&p.onGround) camBobT+=dt*(p.sprinting?11:7.4)*clamp(sp2/4.2,0.4,1.5);
  const bobA=clamp(sp2/4.2,0,1)*(p.ads?0.3:1)*(p.onMG?0:1);
  ey+=Math.sin(camBobT*2)*0.021*bobA;
  ex+=Math.cos(camBobT)*0.011*bobA*Math.cos(p.yaw);
  ez+=Math.cos(camBobT)*0.011*bobA*-Math.sin(p.yaw);
  ey-=p.landDip*0.16;
  camera.position.set(ex,ey,ez);
  // 视角抖动 trauma
  camTrauma=Math.max(0,camTrauma-dt*2.2);
  const sh=camTrauma*camTrauma*0.05;
  const shX=(Math.sin(nowT*67)+Math.sin(nowT*41))*sh;
  const shY=(Math.sin(nowT*53)+Math.sin(nowT*31))*sh;
  const rollBob=Math.sin(camBobT)*0.006*bobA;
  camera.rotation.set(0,0,0);
  camera.rotation.order='YXZ';
  camera.rotation.y=p.yaw+shY*0.5+p.recoilYaw;
  camera.rotation.x=p.pitch+shX*0.5+p.recoilPitch;
  camera.rotation.z=rollBob+shX*0.3;
}

/* ---------- 征服模式 ---------- */
function updateFlags(dt){
  let owned=[0,0];
  for(const f of FLAGS){
    let c0=0,c1=0;
    for(const c of combatants){
      if(!c.alive||c.onVehicle) continue;
      if(Math.hypot(c.pos.x-f.x,c.pos.z-f.z)<f.r&&Math.abs(c.pos.y-f.gy)<7){
        if(c.team===0)c0++; else c1++;
      }
    }
    for(const t of tanks){
      if(!t.alive) continue;
      if(Math.hypot(t.pos.x-f.x,t.pos.z-f.z)<f.r){
        if(t.team===0)c0++; else c1++;
      }
    }
    f.present=[c0,c1];
    let attacker=-1;
    if(c0>0&&c1===0) attacker=0;
    else if(c1>0&&c0===0) attacker=1;
    if(attacker>=0&&attacker!==f.owner){
      const n=attacker===0?c0:c1;
      const rate=(0.07+0.035*Math.min(n,3))*(f.owner===-1?1.35:1);
      if(f.capTeam!==attacker){
        f.cap-=rate*dt*1.6;
        if(f.cap<=0){ f.cap=0; f.capTeam=attacker; }
      } else {
        f.cap+=rate*dt;
        if(f.cap>=1){
          f.cap=0; 
          const oldOwner=f.owner;
          f.owner=attacker; f.capTeam=-1;
          drawFlagTex(f);
          addKillMsg(`${TEAM_NAME[attacker]} 占领了 ${f.id} 点`,attacker);
          for(const c of combatants){
            if(c.alive&&c.team===attacker&&Math.hypot(c.pos.x-f.x,c.pos.z-f.z)<f.r){
              c.score=(c.score||0)+150;
              if(c.isPlayer) showScorePop('+150 占领 '+f.id+' 点');
            }
          }
        }
      }
    } else {
      f.cap-=dt*0.06;
      if(f.cap<=0){ f.cap=0; f.capTeam=-1; }
    }
    if(f.owner>=0) owned[f.owner]++;
    // 旗帜升降+飘动
    f.flagMesh.position.y=dampF(f.flagMesh.position.y,f.owner!==-1?f.poleTop-f.cap*2:f.gy+3.2+f.cap*2.4,4,dt);
    f.flagMesh.rotation.y=Math.sin(nowT*1.6+f.x)*0.22;
    f.flagMesh.scale.x=1+Math.sin(nowT*5+f.z)*0.06;
  }
  // 兵力流失
  if(!matchOver){
    const d=owned[0]-owned[1];
    if(d>0) tickets[1]=Math.max(0,tickets[1]-dt*d*0.10);
    else if(d<0) tickets[0]=Math.max(0,tickets[0]-dt*(-d)*0.10);
    matchTime-=dt;
    if(tickets[0]<=0||tickets[1]<=0||matchTime<=0){
      endMatch();
    }
  }
}
function endMatch(){
  matchOver=true;
  const winner=tickets[0]===tickets[1]?-1:(tickets[0]>tickets[1]?0:1);
  document.exitPointerLock&&document.exitPointerLock();
  setTimeout(()=>{
    document.querySelectorAll('.screen').forEach(s=>s.classList.add('hidden'));
    const end=document.getElementById('end');
    end.classList.remove('hidden');
    const title=document.getElementById('endTitle');
    if(winner===player.team){ title.textContent='胜  利'; title.className='win'; }
    else if(winner===-1){ title.textContent='平  局'; title.className='win'; }
    else { title.textContent='战  败'; title.className='lose'; }
    document.getElementById('endStats').innerHTML=
      `${TEAM_NAME[0]} 剩余兵力 <b>${Math.ceil(tickets[0])}</b> · ${TEAM_NAME[1]} 剩余兵力 <b>${Math.ceil(tickets[1])}</b><br>`+
      `你的战绩:<b>${player.kills}</b> 击杀 / <b>${player.deaths}</b> 阵亡 · 得分 <b>${player.score}</b>`;
  },900);
}

/* ---------- HUD ---------- */
const el=id=>document.getElementById(id);
function addKillfeed(attacker,victim,isHead){
  const kf=el('killfeed');
  const div=document.createElement('div');
  const an=attacker.isPlayer?'<span class="me">你</span>':`<span class="kf${attacker.team}">${attacker.name}</span>`;
  const vn=victim.isPlayer?'<span class="me">你</span>':`<span class="kf${victim.team}">${victim.name}</span>`;
  div.innerHTML=`${an} ${isHead?'☠':'✕'} ${vn}`;
  kf.prepend(div);
  while(kf.children.length>6) kf.lastChild.remove();
  setTimeout(()=>{ div.style.opacity=0; setTimeout(()=>div.remove(),1000); },5200);
}
function addKillMsg(txt,team){
  const m=el('msgC');
  m.textContent=txt;
  m.style.color=team===player.team?'#ffe08a':'#ff8a7a';
  m.style.opacity=1;
  clearTimeout(m._t);
  m._t=setTimeout(()=>m.style.opacity=0,2400);
}
let scorePopT=null;
function showScorePop(txt){
  const s=el('scorePop');
  s.textContent=txt; s.style.opacity=1;
  clearTimeout(scorePopT);
  scorePopT=setTimeout(()=>s.style.opacity=0,1400);
}
function onPlayerKill(victim,isHead){
  player.score+=isHead?125:100;
  showScorePop(isHead?'+125 爆头击杀':'+100 击杀');
  const hm=el('hitmark');
  hm.classList.add('kill');
  hm.style.opacity=1;
  setTimeout(()=>hm.style.opacity=0,320);
  AudioSys.hitmarkSnd(true);
}
function onPlayerHit(sol,isHead){
  const hm=el('hitmark');
  hm.classList.remove('kill');
  hm.style.opacity=1;
  clearTimeout(hm._t);
  hm._t=setTimeout(()=>hm.style.opacity=0,140);
  AudioSys.hitmarkSnd(false);
}
function addDirHit(worldAng){
  const rel=worldAng-player.yaw+Math.PI;
  const d=document.createElement('div');
  d.className='dirHit';
  d.style.transform=`rotate(${-rel}rad)`;
  el('dirHits').appendChild(d);
  setTimeout(()=>d.remove(),900);
}
function updateScoreboard(){
  const mk=(team,tb)=>{
    const rows=[];
    const list=combatants.filter(c=>c.team===team);
    list.sort((a,b)=>(b.score||0)-(a.score||0));
    for(const c of list){
      rows.push(`<tr class="${c.isPlayer?'meRow':''}"><td>${c.isPlayer?'★ 你':c.name}</td><td>${c.kills||0}</td><td>${c.deaths||0}</td><td>${c.score||0}</td></tr>`);
    }
    el(tb).innerHTML=rows.join('');
  };
  mk(0,'sbL'); mk(1,'sbR');
}
/* 小地图 */
const mmC=el('minimap').getContext('2d');
function drawMinimap(){
  const S=190, half=S/2, range=90;
  mmC.clearRect(0,0,S,S);
  mmC.save();
  mmC.beginPath(); mmC.arc(half,half,half-2,0,TAU); mmC.clip();
  mmC.fillStyle='rgba(24,32,18,.9)'; mmC.fillRect(0,0,S,S);
  const cx=player.alive?player.pos.x:0, cz=player.alive?player.pos.z:0;
  const rot=player.alive?player.yaw:0;
  const toMap=(x,z)=>{
    let dx=x-cx, dz=z-cz;
    const c=Math.cos(rot), s=Math.sin(rot);
    const rx=dx*c-dz*s, rz=dx*s+dz*c;
    return [half+rx/range*half, half+rz/range*half];
  };
  // 道路示意
  mmC.strokeStyle='rgba(150,130,90,.4)'; mmC.lineWidth=5;
  mmC.beginPath();
  for(let x=-130;x<=130;x+=10){
    const [px,py]=toMap(x,3*Math.sin(x*0.02));
    x===-130?mmC.moveTo(px,py):mmC.lineTo(px,py);
  }
  mmC.stroke();
  // 旗点
  for(const f of FLAGS){
    const [px,py]=toMap(f.x,f.z);
    mmC.beginPath(); mmC.arc(px,py,10,0,TAU);
    mmC.fillStyle=f.owner===0?'rgba(90,140,220,.75)':f.owner===1?'rgba(220,110,90,.75)':'rgba(160,160,150,.6)';
    mmC.fill();
    if(f.capTeam!==-1&&f.cap>0.03){
      mmC.beginPath(); mmC.arc(px,py,12,-HPI,-HPI+f.cap*TAU);
      mmC.strokeStyle='#fff'; mmC.lineWidth=2; mmC.stroke();
    }
    mmC.fillStyle='#fff'; mmC.font='bold 11px sans-serif'; mmC.textAlign='center';
    mmC.fillText(f.id,px,py+4);
  }
  // 士兵
  for(const s of soldiers){
    if(!s.alive) continue;
    const [px,py]=toMap(s.pos.x,s.pos.z);
    if(px<0||py<0||px>S||py>S) continue;
    if(s.team===player.team){
      mmC.fillStyle='#7dd87d';
      mmC.beginPath(); mmC.arc(px,py,2.5,0,TAU); mmC.fill();
    } else if(nowT-s.lastFiredT<2.5){
      mmC.fillStyle='#ff5040';
      mmC.beginPath(); mmC.arc(px,py,3,0,TAU); mmC.fill();
    }
  }
  // 手雷
  for(const n of nades){
    const [px,py]=toMap(n.pos.x,n.pos.z);
    mmC.fillStyle='#ffd050'; mmC.fillRect(px-1.5,py-1.5,3,3);
  }
  // 坦克
  for(const t of tanks){
    if(!t.alive) continue;
    const [px,py]=toMap(t.pos.x,t.pos.z);
    if(px<-8||py<-8||px>S+8||py>S+8) continue;
    mmC.fillStyle=t.team===0?'#6da5e8':'#e87a68';
    mmC.fillRect(px-4,py-4,8,8);
    mmC.strokeStyle='#fff'; mmC.lineWidth=1; mmC.strokeRect(px-4,py-4,8,8);
  }
  // 飞机
  for(const pl of planes){
    if(!pl.alive) continue;
    const [px,py]=toMap(pl.pos.x,pl.pos.z);
    if(px<-8||py<-8||px>S+8||py>S+8) continue;
    mmC.save();
    mmC.translate(px,py);
    mmC.rotate(-(pl.yaw)+(player.alive?player.yaw:0)+Math.PI);
    mmC.fillStyle=pl.team===0?'#9dc5f8':'#f8a898';
    mmC.beginPath(); mmC.moveTo(0,-5); mmC.lineTo(4,4); mmC.lineTo(0,2); mmC.lineTo(-4,4); mmC.closePath(); mmC.fill();
    mmC.restore();
  }
  // 玩家箭头
  if(player.alive){
    mmC.save();
    mmC.translate(half,half);
    mmC.fillStyle='#fff';
    mmC.beginPath(); mmC.moveTo(0,-6); mmC.lineTo(4,5); mmC.lineTo(-4,5); mmC.closePath(); mmC.fill();
    mmC.restore();
  }
  mmC.restore();
  mmC.strokeStyle='rgba(210,200,160,.4)'; mmC.lineWidth=1.5;
  mmC.beginPath(); mmC.arc(half,half,half-2,0,TAU); mmC.stroke();
}
/* 罗盘 */
const cpC=el('compass').getContext('2d');
function drawCompass(){
  const W=460,H=26;
  cpC.clearRect(0,0,W,H);
  cpC.fillStyle='rgba(0,0,0,.35)'; cpC.fillRect(0,0,W,H);
  const yaw=player.alive?player.yaw:0;
  cpC.font='12px sans-serif'; cpC.textAlign='center'; 
  const marks=[[0,'北'],[HPI,'西'],[Math.PI,'南'],[-HPI,'东'],[Math.PI/4,'西北'],[-Math.PI/4,'东北'],[Math.PI*0.75,'西南'],[-Math.PI*0.75,'东南']];
  for(const [a,label] of marks){
    let rel=angDiff(yaw,a);
    if(Math.abs(rel)>1.2) continue;
    const x=W/2-rel/1.2*(W/2);
    cpC.fillStyle='rgba(255,255,255,.75)';
    cpC.fillText(label,x,17);
  }
  for(const f of FLAGS){
    const a=Math.atan2(f.x-player.pos.x,f.z-player.pos.z);
    let rel=angDiff(yaw,a);
    if(Math.abs(rel)>1.2) continue;
    const x=W/2-rel/1.2*(W/2);
    cpC.fillStyle=f.owner===0?'#8fc1ff':f.owner===1?'#ff9c8a':'#ccc';
    cpC.font='bold 13px sans-serif';
    cpC.fillText(f.id,x,13);
    cpC.fillRect(x-1,18,2,5);
    cpC.font='12px sans-serif';
  }
  cpC.fillStyle='#ffd77a'; cpC.fillRect(W/2-1,2,2,6);
}
function updateHUD(dt){
  const w=player.curW;
  if(player.onVehicle){
    const t=player.onVehicle;
    el('wname').textContent=t.name;
    el('wmode').textContent='装甲 '+Math.max(0,Math.round(t.hp/t.maxHp*100))+'%';
    el('magN').textContent=t.cannonCd<=0?'就绪':t.cannonCd.toFixed(1);
    el('magN').style.color=t.cannonCd<=0?'#9fd89f':'#e0c080';
    el('resN').textContent='';
    el('nadeN').textContent='75mm 主炮';
  } else if(player.onAT){
    el('wname').textContent='57mm 反坦克炮';
    el('wmode').textContent='穿甲弹';
    el('magN').textContent=player.onAT.cd<=0?'就绪':player.onAT.cd.toFixed(1);
    el('magN').style.color=player.onAT.cd<=0?'#9fd89f':'#e0c080';
    el('resN').textContent=''; el('nadeN').textContent='';
  } else if(player.onAA){
    el('wname').textContent='防空炮';
    el('wmode').textContent='高爆弹';
    el('magN').textContent='∞';
    el('magN').style.color='#eee';
    el('resN').textContent=''; el('nadeN').textContent='';
  } else if(w){
    el('wname').textContent=player.onMG?'MG42 通用机枪':w.def.name;
    el('wmode').textContent=player.onMG?'全自动':w.def.mode;
    el('magN').textContent=player.onMG?'∞':w.mag;
    el('resN').textContent=player.onMG?'':'/ '+w.reserve;
    el('magN').style.color=(!player.onMG&&w.mag<=Math.max(2,w.def.mag*0.25))?'#ff7060':'#eee';
    el('nadeN').textContent='手雷 ×'+player.nadeCount+(player.cls===2?' · AT雷[3] ×'+player.atNades:'');
  }
  el('hpNum').textContent=Math.ceil(player.hp);
  el('hpBar').style.width=player.hp+'%';
  el('hpBar').style.background=player.hp>50?'#cfe0c0':(player.hp>25?'#e0c080':'#e07060');
  el('stamBar').style.width=(player.stamina*100)+'%';
  el('tickets').innerHTML=`<span class="tl">${TEAM_NAME[0]} ${Math.ceil(tickets[0])}</span> &nbsp;·&nbsp; <span class="tr">${Math.ceil(tickets[1])} ${TEAM_NAME[1]}</span>`;
  const mm=Math.floor(Math.max(0,matchTime)/60), ss2=Math.floor(Math.max(0,matchTime)%60);
  el('timeTxt').textContent=`${mm}:${ss2<10?'0':''}${ss2}`;
  FLAGS.forEach((f,i)=>{
    const fi=el('fi'+f.id);
    fi.className=(f.owner===0?'f0':f.owner===1?'f1':'')+(f.capTeam!==-1&&f.cap>0.03?' fc':'');
  });
  // 占点面板
  let inFlag=null;
  if(player.alive) for(const f of FLAGS){ if(Math.hypot(player.pos.x-f.x,player.pos.z-f.z)<f.r) inFlag=f; }
  const cp2=el('capPanel');
  if(inFlag&&(inFlag.capTeam!==-1&&inFlag.cap>0.02||inFlag.owner!==player.team)){
    cp2.style.display='block';
    el('capLetter').textContent=inFlag.id;
    el('capBarFill').style.width=(inFlag.cap*100)+'%';
    el('capBarFill').style.background=inFlag.capTeam===0?'#8fc1ff':'#ff9c8a';
    el('capTxt').textContent=inFlag.capTeam===player.team?'正在占领…':(inFlag.capTeam===-1?(inFlag.owner===player.team?'我方控制':'站在旗点范围内占领'):'敌军正在占领!');
  } else cp2.style.display='none';
  // 覆盖层
  dmgFlash=Math.max(0,dmgFlash-dt*2);
  el('dmgOv').style.opacity=dmgFlash;
  el('lowOv').style.opacity=player.alive&&player.hp<35?(1-player.hp/35)*0.85:0;
  el('supOv').style.opacity=player.suppressV*0.7;
  nadeWarnT-=dt;
  el('nadeWarn').style.display=nadeWarnT>0?'block':'none';
  // 准星
  const ch=el('crosshair');
  if(player.alive&&!player.ads){
    ch.style.display='block';
    const gap=8+player.bloom*26+(Math.hypot(player.vel.x,player.vel.z)>1?6:0);
    ch.style.setProperty('--gap',gap+'px');
  } else if(player.alive&&player.ads&&!WPN_DEFS[VM.key]?.scoped){
    ch.style.display='none';
  } else ch.style.display='none';
  drawMinimap();
  drawCompass();
}

/* ---------- 界面流程 ---------- */
let respawnCd=0, selectedSpawn=0;
function initMenuUI(){
  el('teamUS').onclick=()=>{ SETTINGS.team=0; el('teamUS').classList.add('sel'); el('teamGER').classList.remove('sel'); };
  el('teamGER').onclick=()=>{ SETTINGS.team=1; el('teamGER').classList.add('sel'); el('teamUS').classList.remove('sel'); };
  document.querySelectorAll('.diffBtn').forEach(b=>b.onclick=()=>{
    SETTINGS.diff=+b.dataset.d;
    document.querySelectorAll('.diffBtn').forEach(x=>x.classList.toggle('sel',x===b));
  });
  document.querySelectorAll('.qualBtn').forEach(b=>b.onclick=()=>{
    SETTINGS.quality=+b.dataset.q;
    document.querySelectorAll('.qualBtn').forEach(x=>x.classList.toggle('sel',x===b));
    applyQuality();
  });
  el('sensRange').oninput=e=>{ SETTINGS.sens=e.target.value/100; el('sensVal').textContent=SETTINGS.sens.toFixed(1); };
  el('volRange').oninput=e=>{ SETTINGS.vol=e.target.value/100; el('volVal').textContent=e.target.value; AudioSys.setVol(SETTINGS.vol); };
  el('startBtn').onclick=()=>{
    AudioSys.init();
    player.team=SETTINGS.team;
    startMatch();
    el('menu').classList.add('hidden');
    showDeploy(true);
  };
  el('againBtn').onclick=()=>location.reload();
  // 兵种卡
  const grid=el('classGrid');
  CLASSES.forEach((c,i)=>{
    const d=document.createElement('div');
    d.className='classCard'+(i===0?' sel':'');
    d.innerHTML=`<div class="cname">${c.name}</div><div class="cwpn" id="cw${i}"></div><div class="cwpn">手雷 ×${c.nades}</div>`;
    d.onclick=()=>{
      player.cls=i;
      document.querySelectorAll('.classCard').forEach(x=>x.classList.toggle('sel',x===d));
    };
    grid.appendChild(d);
  });
  el('deployBtn').onclick=()=>{
    if(respawnCd>0||matchOver) return;
    deployPlayer();
  };
  el('resumeBtn').onclick=()=>{
    document.querySelectorAll('.screen').forEach(s=>s.classList.add('hidden'));
    lockPointer();
  };
  renderer.domElement.addEventListener('click',()=>{
    if(player.alive&&player.deployed&&!pointerLocked&&!matchOver) lockPointer();
  });
}
function showDeploy(isDead){
  document.querySelectorAll('.screen').forEach(s=>s.classList.add('hidden'));
  el('deploy').classList.remove('hidden');
  el('blackOv').style.opacity=0;
  CLASSES.forEach((c,i)=>{ el('cw'+i).textContent=player.team===0?c.desc0:c.desc1; });
  el('deathInfo').textContent=isDead&&player.killerName?`你被 ${player.killerName} 击杀了`:'';
  el('resumeBtn').style.display=(player.alive&&player.deployed)?'inline-block':'none';
  el('deployBtn').style.display=player.alive&&player.deployed?'none':'inline-block';
  buildSpawnList();
  drawDeployMap();
}
function buildSpawnList(){
  const list=el('spawnList');
  list.innerHTML='';
  const opts=[{name:'主基地',x:BASES[player.team].x,z:BASES[player.team].z,id:-1}];
  FLAGS.forEach((f,i)=>{ if(f.owner===player.team) opts.push({name:f.id+' 点',x:f.x,z:f.z,id:i}); });
  if(!opts.some(o=>o.id===selectedSpawn)) selectedSpawn=-1;
  opts.forEach(o=>{
    const b=document.createElement('button');
    b.className='spawnBtn'+(o.id===selectedSpawn?' sel':'');
    b.textContent='◈ '+o.name;
    b.onclick=()=>{ selectedSpawn=o.id; document.querySelectorAll('.spawnBtn').forEach(x=>x.classList.remove('sel')); b.classList.add('sel'); };
    list.appendChild(b);
  });
}
function drawDeployMap(){
  const c=el('deployMap').getContext('2d');
  const S=300;
  c.fillStyle='#141a10'; c.fillRect(0,0,S,S);
  const toM=(x,z)=>[S/2+x/150*S/2, S/2+z/150*S/2];
  c.strokeStyle='rgba(150,130,90,.5)'; c.lineWidth=4;
  c.beginPath();
  for(let x=-130;x<=130;x+=10){ const [px,py]=toM(x,3*Math.sin(x*0.02)); x===-130?c.moveTo(px,py):c.lineTo(px,py); }
  c.stroke();
  // 基地
  [0,1].forEach(t=>{
    const [px,py]=toM(BASES[t].x,BASES[t].z);
    c.fillStyle=t===0?'#4a70b0':'#b05a4a';
    c.fillRect(px-8,py-8,16,16);
    c.fillStyle='#fff'; c.font='10px sans-serif'; c.textAlign='center';
    c.fillText(t===0?'盟':'德',px,py+3);
  });
  for(const f of FLAGS){
    const [px,py]=toM(f.x,f.z);
    c.beginPath(); c.arc(px,py,13,0,TAU);
    c.fillStyle=f.owner===0?'rgba(90,140,220,.85)':f.owner===1?'rgba(220,110,90,.85)':'rgba(150,150,140,.7)';
    c.fill();
    c.fillStyle='#fff'; c.font='bold 13px sans-serif'; c.textAlign='center';
    c.fillText(f.id,px,py+4);
  }
  for(const s of soldiers){
    if(!s.alive) continue;
    const [px,py]=toM(s.pos.x,s.pos.z);
    c.fillStyle=s.team===0?'#7da8e8':'#e8907d';
    c.beginPath(); c.arc(px,py,2,0,TAU); c.fill();
  }
}
function deployPlayer(){
  const p=player;
  let sx,sz;
  if(selectedSpawn===-1){ sx=BASES[p.team].x; sz=BASES[p.team].z; }
  else {
    const f=FLAGS[selectedSpawn];
    if(f.owner!==p.team){ sx=BASES[p.team].x; sz=BASES[p.team].z; }
    else { sx=f.x; sz=f.z; }
  }
  p.pos.set(sx+rand(-4,4),0,sz+rand(-4,4));
  p.pos.y=standHeight(p.pos.x,p.pos.z,10);
  p.vel.set(0,0,0);
  p.hp=100; p.alive=true; p.deployed=true; p.crouch=false;
  p.stamina=1; p.suppressV=0; p.bloom=0;
  p.yaw=Math.atan2(-sx,-sz); p.pitch=0;
  // 武器
  const wkeys=CLASSES[p.cls][p.team===0?'us':'ger'];
  p.slots=wkeys.map(k=>({key:k,def:WPN_DEFS[k],mag:WPN_DEFS[k].mag,reserve:WPN_DEFS[k].reserve}));
  p.curSlot=0; p.curW=p.slots[0];
  p.nadeCount=CLASSES[p.cls].nades;
  p.atNades=p.cls===2?2:0;
  p.nadeIsAT=false; p.nadeHeld=false;
  p.onVehicle=null; p.onMG=null; p.onAT=null; p.onAA=null;
  document.getElementById('heatWrap').style.display='none';
  vmEquip(p.curW.key,p.team);
  VM.root.visible=true;
  document.querySelectorAll('.screen').forEach(s=>s.classList.add('hidden'));
  el('blackOv').style.opacity=0;
  lockPointer();
}

/* ---------- 比赛初始化 ---------- */
function startMatch(){
  const names0=[...US_NAMES].sort(()=>Math.random()-0.5);
  const names1=[...GER_NAMES].sort(()=>Math.random()-0.5);
  for(let t=0;t<2;t++){
    const n=t===player.team?BOTS_PER_TEAM-1:BOTS_PER_TEAM;
    for(let i=0;i<n;i++){
      const cls=[0,0,1,1,2,2,3,0,1][i%9];
      const bot=new Bot(t,cls,(t===0?names0:names1)[i]);
      bot.spawn();
    }
  }
  // 初始阵地: A归盟军, C归德军
  FLAGS[0].owner=0; drawFlagTex(FLAGS[0]);
  FLAGS[2].owner=1; drawFlagTex(FLAGS[2]);
  // 载具
  new Tank(0); new Tank(1);
  new Plane(0); new Plane(1);
}

/* ---------- 主循环 ---------- */
let lastT=performance.now(), fpsAcc=0, fpsN=0, fpsShow=0;
function loop(){
  requestAnimationFrame(loop);
  const now=performance.now();
  let dt=Math.min((now-lastT)/1000,0.05);
  lastT=now;
  nowT=now/1000;
  // FPS
  fpsAcc+=1/Math.max(dt,0.001); fpsN++;
  if(fpsN>=30){ fpsShow=Math.round(fpsAcc/fpsN); fpsAcc=0; fpsN=0; el('fps').textContent=fpsShow+' FPS'; }
  gunEvents=gunEvents.filter(e=>nowT-e.t<0.6);
  NAV.budget=2;
  updatePlayer(dt);
  updateCamera(dt);
  for(const s of soldiers) s.update(dt);
  for(const t of tanks) t.update(dt);
  for(const pl of planes) pl.update(dt);
  updateShells(dt);
  updateFlak(dt);
  updateNades(dt);
  updateParticles(dt);
  updateTracers(dt);
  updateCasings(dt);
  updateFlags(dt);
  if(flashTimer>0){ flashTimer-=dt; if(flashTimer<=0) flashLight.intensity=0; }
  if(player.alive&&player.curW&&!player.onMG&&!player.onVehicle&&!player.onAT&&!player.onAA){
    updateViewModel(dt,player);
  } else if(player.alive&&(player.onMG||player.onAT||player.onAA)){
    camera.fov=dampF(camera.fov,74,10,dt);
    camera.updateProjectionMatrix();
  }
  player.mouseDX*=Math.pow(0.0001,dt*3);
  player.mouseDY*=Math.pow(0.0001,dt*3);
  updateHUD(dt);
  // 渲染
  renderer.clear();
  renderer.render(scene,camera);
  if(player.alive&&!player.onMG&&VM.root.visible){
    renderer.clearDepth();
    renderer.render(vmScene,vmCamera);
  }
  // 复活倒计时
  if(!player.alive&&player.deployed&&!matchOver){
    respawnCd=Math.max(0,respawnCd-dt);
    el('respawnTxt').textContent=respawnCd>0?`(${Math.ceil(respawnCd)})`:'';
    const btn=el('deployBtn');
    btn.disabled=respawnCd>0;
    if(nowT%0.5<0.1) drawDeployMap();
  }
}
applyQuality();
initMenuUI();
loop();

</script>
</body>
</html>

Game Source: 钢铁前线 1944 · 诺曼底

Creator: GoldenRider11

Libraries: three

Complexity: complex (4846 lines, 205.1 KB)

The full source code is displayed above on this page.

Remix Instructions

To remix this game, copy the source code above and modify it. Add a ARCADELAB header at the top with "remix_of: 1944-goldenrider11" to link back to the original. Then publish at arcadelab.ai/publish.