钢铁前线 1944 · 诺曼底
by GoldenRider114846 lines205.1 KB🛠️ Three.js (3D graphics)
<!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> · <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> · <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.