Burma Expedition · 4 Waves
by UltraNinja804745 lines197.2 KB🛠️ Three.js (3D graphics)
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Burma Expedition · 4 Waves</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Segoe UI', sans-serif;
overflow: hidden;
background: #1a1a1a;
color: #eee;
user-select: none;
}
#gameContainer {
position: relative;
width: 100vw;
height: 100vh;
}
canvas {
display: block;
}
#hud {
position: absolute;
top: 20px;
left: 20px;
z-index: 10;
pointer-events: none;
font-size: 14px;
line-height: 1.7;
text-shadow: 0 0 10px rgba(0, 0, 0, 0.95);
background: rgba(0, 0, 0, 0.55);
padding: 12px 18px;
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.08);
min-width: 200px;
backdrop-filter: blur(4px);
}
#hud .health {
font-size: 22px;
font-weight: bold;
}
#hud .health .hp-fill {
color: #4caf50;
}
#hud .health .hp-fill.low {
color: #ff4444;
animation: pulse 0.8s infinite;
}
#hud .ammo {
font-size: 20px;
font-weight: bold;
margin-top: 2px;
}
#hud .ammo .ammo-num {
color: #ffd700;
}
#hud .weapon-name {
font-size: 16px;
color: #88ccff;
margin-top: 2px;
font-weight: 600;
}
#hud .enemies {
margin-top: 4px;
color: #ff8a80;
}
#hud .enemies span {
font-weight: bold;
}
#hud .wave-info {
margin-top: 4px;
color: #ffd700;
font-weight: bold;
}
#hud .weapon-hint {
font-size: 12px;
color: #aaa;
margin-top: 4px;
border-top: 1px solid rgba(255, 255, 255, 0.1);
padding-top: 4px;
}
#hud .weapon-hint kbd {
background: #333;
padding: 0 8px;
border-radius: 4px;
border: 1px solid #555;
font-size: 11px;
color: #fff;
}
@keyframes pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
#crosshair {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 5;
pointer-events: none;
width: 32px;
height: 32px;
}
#crosshair .dot {
position: absolute;
width: 4px;
height: 4px;
background: rgba(255, 255, 255, 0.9);
border-radius: 50%;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
#crosshair .line {
position: absolute;
background: rgba(255, 255, 255, 0.6);
}
#crosshair .line-v {
width: 1.5px;
height: 12px;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
#crosshair .line-h {
width: 12px;
height: 1.5px;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
#hitMarker {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 6;
pointer-events: none;
opacity: 0;
width: 40px;
height: 40px;
}
#hitMarker .x {
position: absolute;
background: #ff4444;
width: 20px;
height: 2px;
top: 50%;
left: 50%;
}
#hitMarker .x1 {
transform: translate(-50%, -50%) rotate(45deg);
}
#hitMarker .x2 {
transform: translate(-50%, -50%) rotate(-45deg);
}
#hitMarker.active {
animation: hitFlash 0.25s ease-out;
}
@keyframes hitFlash {
0% {
opacity: 1;
transform: translate(-50%, -50%) scale(0.6);
}
100% {
opacity: 0;
transform: translate(-50%, -50%) scale(1.4);
}
}
#damageOverlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 4;
pointer-events: none;
opacity: 0;
background: radial-gradient(ellipse at center, transparent 50%, rgba(255, 0, 0, 0.35) 100%);
transition: opacity 0.08s;
}
#damageOverlay.show {
opacity: 1;
}
#lockOverlay {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 20;
background: rgba(0, 0, 0, 0.9);
padding: 30px 40px;
border-radius: 12px;
text-align: center;
border: 1px solid #555;
pointer-events: auto;
cursor: pointer;
user-select: none;
max-width: 520px;
backdrop-filter: blur(8px);
}
#lockOverlay h2 {
color: #ffd700;
margin-bottom: 12px;
font-size: 28px;
}
#lockOverlay p {
margin: 6px 0;
font-size: 15px;
color: #ccc;
}
#lockOverlay .keys {
display: flex;
flex-wrap: wrap;
gap: 6px;
justify-content: center;
margin: 10px 0;
}
#lockOverlay .key {
background: #333;
border: 1px solid #666;
border-radius: 4px;
padding: 2px 10px;
font-weight: bold;
font-size: 13px;
color: #fff;
}
#lockOverlay .warning {
color: #ff8a80;
margin-top: 8px;
font-size: 13px;
}
#gameOver {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 25;
background: rgba(0, 0, 0, 0.92);
padding: 40px 50px;
border-radius: 16px;
text-align: center;
border: 2px solid #ff4444;
display: none;
min-width: 320px;
backdrop-filter: blur(8px);
}
#gameOver h2 {
color: #ff4444;
font-size: 38px;
margin-bottom: 16px;
}
#gameOver p {
font-size: 17px;
margin: 6px 0;
color: #ddd;
}
#gameOver .stat {
color: #ffd700;
font-weight: bold;
}
#gameOver button {
margin-top: 22px;
background: #ffd700;
color: #000;
border: none;
padding: 12px 36px;
font-size: 18px;
font-weight: bold;
border-radius: 8px;
cursor: pointer;
transition: background 0.2s;
}
#gameOver button:hover {
background: #ffed4a;
}
#reloadIndicator {
position: absolute;
bottom: 100px;
left: 50%;
transform: translateX(-50%);
z-index: 10;
background: rgba(0, 0, 0, 0.8);
padding: 8px 24px;
border-radius: 20px;
font-size: 16px;
color: #ff9800;
display: none;
pointer-events: none;
border: 1px solid #ff9800;
font-weight: 600;
}
#boltIndicator {
position: absolute;
bottom: 140px;
left: 50%;
transform: translateX(-50%);
z-index: 10;
background: rgba(0, 0, 0, 0.8);
padding: 6px 20px;
border-radius: 16px;
font-size: 14px;
color: #4caf50;
display: none;
pointer-events: none;
border: 1px solid #4caf50;
font-weight: 600;
}
#killFeed {
position: absolute;
top: 80px;
right: 20px;
z-index: 10;
pointer-events: none;
text-align: right;
}
#killFeed .entry {
background: rgba(0, 0, 0, 0.75);
padding: 6px 16px;
margin-bottom: 4px;
border-radius: 6px;
border-left: 3px solid #ff4444;
font-size: 14px;
animation: slideIn 0.3s ease-out;
color: #eee;
}
@keyframes slideIn {
from {
transform: translateX(60px);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
#pickupMsg {
position: absolute;
bottom: 180px;
left: 50%;
transform: translateX(-50%);
z-index: 10;
background: rgba(0, 0, 0, 0.8);
padding: 8px 24px;
border-radius: 12px;
font-size: 16px;
color: #88ff88;
display: none;
pointer-events: none;
border: 1px solid #88ff88;
font-weight: 600;
text-align: center;
max-width: 400px;
}
#pickupPrompt {
position: absolute;
bottom: 240px;
left: 50%;
transform: translateX(-50%);
z-index: 10;
background: rgba(0, 0, 0, 0.85);
padding: 8px 24px;
border-radius: 12px;
font-size: 18px;
color: #ffd700;
display: none;
pointer-events: none;
border: 1px solid #ffd700;
font-weight: 600;
text-align: center;
animation: pulse 1.2s infinite;
}
#loading {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: 26px;
color: #ffd700;
z-index: 30;
}
.hidden {
display: none !important;
}
#wheelHint {
position: absolute;
bottom: 60px;
left: 50%;
transform: translateX(-50%);
z-index: 10;
color: rgba(255, 255, 255, 0.3);
font-size: 13px;
pointer-events: none;
background: rgba(0, 0, 0, 0.3);
padding: 4px 16px;
border-radius: 12px;
}
#waveTransition {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 15;
background: rgba(0, 0, 0, 0.85);
padding: 30px 50px;
border-radius: 16px;
text-align: center;
border: 2px solid #ffd700;
display: none;
pointer-events: none;
backdrop-filter: blur(8px);
min-width: 300px;
}
#waveTransition h2 {
color: #ffd700;
font-size: 36px;
margin-bottom: 8px;
}
#waveTransition p {
color: #ccc;
font-size: 18px;
}
#waveTransition .sub {
color: #88ff88;
font-size: 16px;
margin-top: 6px;
}
#victoryMsg {
color: #ffd700 !important;
font-size: 20px !important;
font-weight: bold;
}
#waveCounter {
position: absolute;
top: 20px;
right: 20px;
z-index: 10;
pointer-events: none;
background: rgba(0, 0, 0, 0.6);
padding: 8px 18px;
border-radius: 8px;
border: 1px solid #ffd700;
color: #ffd700;
font-weight: bold;
font-size: 18px;
}
</style>
</head>
<body>
<div id="gameContainer">
<div id="loading">Loading...</div>
<div id="lockOverlay">
<h2>🏯 Burma Expedition</h2>
<p>Click to lock mouse · WASD move</p>
<div class="keys">
<span class="key">W</span><span class="key">A</span>
<span class="key">S</span><span class="key">D</span>
<span class="key">Space</span> Jump
<span class="key">Shift</span> Sprint
</div>
<div class="keys">
<span class="key">LMB</span> Fire
<span class="key">R</span> Reload
<span class="key">G</span> Bolt (Lee-Enfield)
<span class="key">Scroll</span> Switch
<span class="key">E</span> Pickup
</div>
<p style="color:#ff8a80;margin-top:10px;">💀 4 Waves · Enemies do not respawn</p>
<p style="color:#88ff88;font-size:14px;">🎯 Kill 7/8 of each wave to advance!</p>
<p style="font-size:13px;color:#888;margin-top:6px;">Start: Lee-Enfield · Scroll to switch</p>
</div>
<canvas id="radarCanvas" style="position:absolute; top:80px; right:20px; width:120px; height:120px; z-index:10; pointer-events:none; border-radius:50%; background:rgba(0,0,0,0.5); border:2px solid #888;"></canvas>
<div id="hud">
<div class="health">❤️ <span class="hp-fill" id="hpText">100</span>%</div>
<div class="weapon-name" id="weaponName">🔫 Lee-Enfield</div>
<div class="ammo"><span class="ammo-num" id="ammoText">5 / 30</span></div>
<div class="enemies">👾 Enemies: <span id="enemyCount">0</span> alive</div>
<div class="wave-info">🌊 Wave <span id="waveNum">1</span>/4</div>
<div class="weapon-hint">
<kbd>Scroll</kbd> Switch · <kbd>R</kbd> Reload · <kbd>E</kbd> Pickup
</div>
</div>
<div id="waveCounter">🌊 Wave <span id="waveNum2">1</span>/4</div>
<div id="crosshair">
<div class="dot"></div>
<div class="line line-v"></div>
<div class="line line-h"></div>
</div>
<div id="hitMarker"><div class="x x1"></div><div class="x x2"></div></div>
<div id="damageOverlay"></div>
<div id="reloadIndicator">⏳ Reloading...</div>
<div id="boltIndicator">🔧 Press G to bolt</div>
<div id="pickupMsg">📦 Item picked up</div>
<div id="pickupPrompt">Press <kbd style="background:#333;padding:0 10px;border-radius:4px;border:1px solid #888;">E</kbd> to pickup</div>
<div id="killFeed"></div>
<div id="gameOver">
<h2 id="gameOverTitle">💀 GAME OVER</h2>
<p>Survived: <span class="stat" id="survivedTime">0</span>s</p>
<p>Kills: <span class="stat" id="killsCount">0</span></p>
<p>Accuracy: <span class="stat" id="finalAcc">0%</span></p>
<button id="restartBtn">↻ Restart</button>
</div>
<div id="waveTransition">
<h2 id="waveTitle">🌊 Wave 1</h2>
<p id="waveDesc">Jungle · 15 enemies</p>
<div class="sub" id="waveSub">Kill 7/8 to advance</div>
</div>
<div id="wheelHint">🔄 Scroll to switch weapon</div>
</div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<script>
(function() {
'use strict';
// ============================================================
// CONSTANTS
// ============================================================
const GRAVITY = 22;
const PLAYER_SPEED = 15;
const RUN_MULT = 2.0;
const MOUSE_SENS = 0.002;
const MAP_SIZE = 1000;
const HALF = MAP_SIZE / 2;
const PLAYER_HEIGHT = 10;
const PLAYER_RADIUS = 3;
const ENEMY_HEIGHT = 4;
const ENEMY_RADIUS = 3;
const ENEMY_VISION_RANGE = 600;
const ENEMY_VISION_ANGLE = Math.PI / 1.5;
const ENEMY_HEARING = 40;
const ENEMY_PATROL_RADIUS = 200;
const ENEMY_LOST_TIME = 3.0;
const BULLET_LIFETIME = 3.0;
const SUBSTEPS = 30;
const MAX_DELTA = 1 / 30;
// ---- Wave config ----
const WAVE_CONFIG = [{
wave: 1,
enemyCount: 15,
terrain: 'jungle',
desc: 'Jungle · 15 enemies',
visual: { fog: 0x2a4a2a, bg: 0x1a2a1a, ambient: 0x445544 }
}, {
wave: 2,
enemyCount: 20,
terrain: 'city',
desc: 'City · 20 enemies',
visual: { fog: 0x6a7a8a, bg: 0x3a4a5a, ambient: 0x667788 }
}, {
wave: 3,
enemyCount: 35,
terrain: 'city_dense',
desc: 'Urban · 35 enemies',
visual: { fog: 0x5a5a5a, bg: 0x2a2a2a, ambient: 0x556666 }
}, {
wave: 4,
enemyCount: 50,
terrain: 'city_dense',
desc: 'Urban · 50 enemies',
visual: { fog: 0x4a4a4a, bg: 0x1a1a1a, ambient: 0x445555 }
}];
// ============================================================
// WEAPON DEFS (WW2 themed)
// ============================================================
const WEAPON_DEFS = {
lee_enfield: {
id: 'lee_enfield',
name: 'Lee-Enfield',
displayName: 'Lee-Enfield Rifle',
damage: 300,
fireRate: 40,
maxAmmo: 5,
totalAmmo: 30,
reloadTime: 3.0,
spread: 0.015,
needsBolt: true,
range: 1000,
auto: false,
icon: '🎯',
modelFn: 'createLeeEnfield'
},
thompson: {
id: 'thompson',
name: 'Thompson',
displayName: 'Thompson SMG',
damage: 25,
fireRate: 600,
maxAmmo: 30,
totalAmmo: 120,
reloadTime: 2.0,
spread: 0.08,
needsBolt: false,
range: 300,
auto: true,
icon: '💥',
modelFn: 'createThompson'
},
m1garand: {
id: 'm1garand',
name: 'M1 Garand',
displayName: 'M1 Garand',
damage: 55,
fireRate: 350,
maxAmmo: 8,
totalAmmo: 48,
reloadTime: 2.8,
spread: 0.035,
needsBolt: false,
range: 500,
auto: false,
icon: '🔫',
modelFn: 'createM1Garand'
},
stg44: {
id: 'stg44',
name: 'STG44',
displayName: 'STG44 Assault',
damage: 45,
fireRate: 450,
maxAmmo: 30,
totalAmmo: 90,
reloadTime: 2.5,
spread: 0.055,
needsBolt: false,
range: 450,
auto: true,
icon: '🔫',
modelFn: 'createSTG44'
},
colt1911: {
id: 'colt1911',
name: 'Colt 1911',
displayName: 'Colt 1911',
damage: 80,
fireRate: 120,
maxAmmo: 7,
totalAmmo: 28,
reloadTime: 2.0,
spread: 0.04,
needsBolt: false,
range: 400,
auto: false,
icon: '🔫',
modelFn: 'createColt1911'
}
};
const WEAPON_IDS = ['lee_enfield', 'thompson', 'm1garand', 'stg44', 'colt1911'];
// ============================================================
// STATE
// ============================================================
let scene, camera, renderer;
let player, playerVel = new THREE.Vector3();
let moveF = false,
moveB = false,
moveL = false,
moveR = false;
let canJump = true,
isJumping = false,
onGround = false;
let mouseX = 0,
mouseY = 0;
let isShift = false,
isLocked = false;
let clock = new THREE.Clock();
let gameOver = false,
gameStart = 0;
let enemies = [],
obstacles = [];
let bullets = [];
let kills = 0,
shotsFired = 0,
shotsHit = 0;
let playerHP = 100,
maxHP = 100;
let godMode = false,
infAmmo = false;
// ---- Wave system ----
let currentWave = 0; // 0-index
let waveActive = false;
let enemiesSpawned = 0;
let waveKills = 0;
let waveTotalEnemies = 0;
let waveComplete = false;
let gameVictory = false;
// ---- Weapons ----
let currentWeaponId = 'lee_enfield';
let unlockedWeapons = ['lee_enfield'];
let weaponStates = {};
function initWeaponStates() {
for (const id of WEAPON_IDS) {
const def = WEAPON_DEFS[id];
weaponStates[id] = {
currentAmmo: def.maxAmmo,
totalAmmo: def.totalAmmo,
boltReady: true,
isReloading: false,
reloadTimer: 0,
boltTimer: null,
};
}
}
initWeaponStates();
function getWeaponState(id) { return weaponStates[id]; }
function getCurrentState() { return weaponStates[currentWeaponId]; }
function getCurrentDef() { return WEAPON_DEFS[currentWeaponId]; }
let lastShot = 0;
let mouseDown = false;
let pickups = [];
let pickupIdCounter = 0;
let weaponDisplay = null;
let weaponModels = {};
// ---- World objects ----
let trees = [];
let treeMeshes = [];
// ============================================================
// AABB HELPERS
// ============================================================
function aabbFromPosSize(px, py, pz, w, h, d) {
return { minX: px - w / 2, maxX: px + w / 2, minY: py - h / 2, maxY: py + h / 2, minZ: pz - d / 2,
maxZ: pz + d / 2 };
}
function aabbFromObstacle(obs) {
return { minX: obs.minX, maxX: obs.maxX, minY: obs.minY, maxY: obs.maxY, minZ: obs.minZ, maxZ: obs.maxZ };
}
function aabbFromEnemy(e) {
const p = e.position;
const halfW = ENEMY_RADIUS * 1.1;
const halfD = ENEMY_RADIUS;
const minY = p.y - ENEMY_HEIGHT / 2;
const maxY = p.y + ENEMY_HEIGHT / 2 + ENEMY_RADIUS * 1.6;
return { minX: p.x - halfW, maxX: p.x + halfW, minY: minY, maxY: maxY, minZ: p.z - halfD, maxZ: p.z +
halfD };
}
function aabbFromPlayer() {
const p = player.position;
return aabbFromPosSize(p.x, p.y, p.z, PLAYER_RADIUS * 2, PLAYER_HEIGHT, PLAYER_RADIUS * 2);
}
function pointInAABB(px, py, pz, aabb) {
return px >= aabb.minX && px <= aabb.maxX && py >= aabb.minY && py <= aabb.maxY && pz >= aabb.minZ && pz <=
aabb.maxZ;
}
function aabbOverlap(a, b) {
return a.minX <= b.maxX && a.maxX >= b.minX && a.minY <= b.maxY && a.maxY >= b.minY && a.minZ <= b.maxZ &&
a.maxZ >= b.minZ;
}
// ============================================================
// GRID & A*
// ============================================================
const GRID_SIZE = 16;
const GRID_W = Math.ceil(MAP_SIZE / GRID_SIZE);
const GRID_H = Math.ceil(MAP_SIZE / GRID_SIZE);
let grid = [];
function worldToGrid(wx, wz) {
const gx = Math.floor((wx + HALF) / GRID_SIZE);
const gz = Math.floor((wz + HALF) / GRID_SIZE);
return { gx, gz };
}
function gridToWorld(gx, gz) {
return { wx: gx * GRID_SIZE - HALF + GRID_SIZE / 2, wz: gz * GRID_SIZE - HALF + GRID_SIZE / 2 };
}
function isInGrid(gx, gz) { return gx >= 0 && gx < GRID_W && gz >= 0 && gz < GRID_H; }
function isWalkable(gx, gz) {
if (!isInGrid(gx, gz)) return false;
return grid[gz] && grid[gz][gx] === 0;
}
function initGrid() {
grid = [];
for (let z = 0; z < GRID_H; z++) {
grid[z] = new Uint8Array(GRID_W);
}
for (const obs of obstacles) {
const min = worldToGrid(obs.minX, obs.minZ);
const max = worldToGrid(obs.maxX, obs.maxZ);
for (let gz = Math.max(0, min.gz - 1); gz <= Math.min(GRID_H - 1, max.gz + 1); gz++) {
for (let gx = Math.max(0, min.gx - 1); gx <= Math.min(GRID_W - 1, max.gx + 1); gx++) {
grid[gz][gx] = 1;
}
}
}
for (let i = 0; i < GRID_W; i++) {
grid[0][i] = 1;
grid[GRID_H - 1][i] = 1;
grid[i][0] = 1;
grid[i][GRID_W - 1] = 1;
}
const pc = worldToGrid(0, 0);
for (let dz = -3; dz <= 3; dz++) {
for (let dx = -3; dx <= 3; dx++) {
const gx = pc.gx + dx,
gz = pc.gz + dz;
if (isInGrid(gx, gz)) grid[gz][gx] = 0;
}
}
}
class AStarNode {
constructor(gx, gz, parent = null) { this.gx = gx;
this.gz = gz;
this.parent = parent;
this.g = 0;
this.h = 0;
this.f = 0; }
}
function heuristic(a, b) { return Math.abs(a.gx - b.gx) + Math.abs(a.gz - b.gz); }
function aStar(startGx, startGz, goalGx, goalGz) {
if (!isWalkable(startGx, startGz) || !isWalkable(goalGx, goalGz)) return null;
if (startGx === goalGx && startGz === goalGz) return [{ gx: goalGx, gz: goalGz }];
const open = [];
const closed = new Set();
const openMap = new Map();
const startNode = new AStarNode(startGx, startGz);
const goalNode = new AStarNode(goalGx, goalGz);
startNode.g = 0;
startNode.h = heuristic(startNode, goalNode);
startNode.f = startNode.h;
open.push(startNode);
openMap.set(`${startGx},${startGz}`, startNode);
const dirs = [
[0, 1],
[1, 0],
[0, -1],
[-1, 0],
[1, 1],
[1, -1],
[-1, 1],
[-1, -1]
];
const dirCost = [1, 1, 1, 1, 1.414, 1.414, 1.414, 1.414];
while (open.length > 0) {
let bestIdx = 0;
for (let i = 1; i < open.length; i++) {
if (open[i].f < open[bestIdx].f) bestIdx = i;
}
const current = open.splice(bestIdx, 1)[0];
const key = `${current.gx},${current.gz}`;
closed.add(key);
openMap.delete(key);
if (current.gx === goalGx && current.gz === goalGz) {
const path = [];
let node = current;
while (node) { path.push({ gx: node.gx, gz: node.gz });
node = node.parent; }
path.reverse();
return path;
}
for (let d = 0; d < dirs.length; d++) {
const ngx = current.gx + dirs[d][0];
const ngz = current.gz + dirs[d][1];
const nkey = `${ngx},${ngz}`;
if (!isWalkable(ngx, ngz)) continue;
if (closed.has(nkey)) continue;
if (d >= 4) {
const ax = current.gx + dirs[d][0];
const az = current.gz;
const bx = current.gx;
const bz = current.gz + dirs[d][1];
if (!isWalkable(ax, az) || !isWalkable(bx, bz)) continue;
}
const newG = current.g + 2.5 * dirCost[d];
let neighbor = openMap.get(nkey);
if (!neighbor) {
neighbor = new AStarNode(ngx, ngz, current);
neighbor.g = newG;
neighbor.h = heuristic(neighbor, goalNode);
neighbor.f = neighbor.g + neighbor.h;
open.push(neighbor);
openMap.set(nkey, neighbor);
} else if (newG < neighbor.g) {
neighbor.parent = current;
neighbor.g = newG;
neighbor.f = neighbor.g + neighbor.h;
}
}
}
return null;
}
function hasLineOfSight(wx1, wz1, wx2, wz2) {
const dist = Math.sqrt((wx2 - wx1) ** 2 + (wz2 - wz1) ** 2);
const steps = Math.ceil(dist / 2);
for (let i = 0; i <= steps; i++) {
const t = i / steps;
const px = wx1 + (wx2 - wx1) * t;
const pz = wz1 + (wz2 - wz1) * t;
const g = worldToGrid(px, pz);
if (!isWalkable(g.gx, g.gz)) return false;
for (let dz = -1; dz <= 1; dz++) {
for (let dx = -1; dx <= 1; dx++) {
const ngx = g.gx + dx,
ngz = g.gz + dz;
if (isInGrid(ngx, ngz) && grid[ngz][ngx] === 1) return false;
}
}
}
return true;
}
function calculateEnemyPath(enemy, target) {
const start = enemy.position;
const end = target;
const dist = start.distanceTo(end);
if (dist < 5) return [{ wx: end.x, wz: end.z }];
let startG = worldToGrid(start.x, start.z);
let endG = worldToGrid(end.x, end.z);
if (!isWalkable(startG.gx, startG.gz)) {
let found = false;
for (let r = 1; r <= 3; r++) {
for (let dz = -r; dz <= r; dz++) {
for (let dx = -r; dx <= r; dx++) {
const ngx = startG.gx + dx,
ngz = startG.gz + dz;
if (isWalkable(ngx, ngz)) { startG.gx = ngx;
startG.gz = ngz;
found = true; break; }
}
if (found) break;
}
if (found) break;
}
if (!found) return null;
}
if (!isWalkable(endG.gx, endG.gz)) {
let found = false;
for (let r = 1; r <= 3; r++) {
for (let dz = -r; dz <= r; dz++) {
for (let dx = -r; dx <= r; dx++) {
const ngx = endG.gx + dx,
ngz = endG.gz + dz;
if (isWalkable(ngx, ngz)) { endG.gx = ngx;
endG.gz = ngz;
found = true; break; }
}
if (found) break;
}
if (found) break;
}
if (!found) return null;
}
if (hasLineOfSight(start.x, start.z, end.x, end.z)) {
return [{ wx: end.x, wz: end.z }];
}
const path = aStar(startG.gx, startG.gz, endG.gx, endG.gz);
if (!path) return null;
const result = [];
for (let i = 1; i < path.length; i++) {
const w = gridToWorld(path[i].gx, path[i].gz);
result.push({ wx: w.wx, wz: w.wz });
}
if (result.length > 0) {
const last = result[result.length - 1];
if (Math.abs(last.wx - end.x) > 5 || Math.abs(last.wz - end.z) > 5) {
result.push({ wx: end.x, wz: end.z });
}
} else {
result.push({ wx: end.x, wz: end.z });
}
return result;
}
// ============================================================
// WW2 WEAPON MODELS (voxel style)
// ============================================================
function createLeeEnfield() {
const g = new THREE.Group();
const m = (c) => new THREE.MeshLambertMaterial({ color: c });
const parts = [
{ pos: [0, 0, 0], size: [2.6, 0.4, 0.8], color: 0x3a2a1a },
{ pos: [0.2, 0.2, 0], size: [1.8, 0.25, 0.5], color: 0x4a3a2a },
{ pos: [2.0, 0, 0], size: [1.8, 0.18, 0.18], color: 0x2a2a2a },
{ pos: [2.8, 0, 0], size: [0.6, 0.15, 0.15], color: 0x1a1a1a },
{ pos: [3.2, 0, 0], size: [0.15, 0.3, 0.3], color: 0x444444 },
{ pos: [-2.0, 0, 0], size: [1.0, 0.5, 0.5], color: 0x5a4a3a },
{ pos: [-2.4, 0, 0], size: [0.6, 0.4, 0.4], color: 0x6a5a4a },
{ pos: [-2.7, 0.1, 0], size: [0.4, 0.3, 0.3], color: 0x7a6a5a },
{ pos: [-0.6, -0.4, 0], size: [0.4, 0.5, 0.3], color: 0x3a3a3a },
{ pos: [-0.7, -0.6, 0], size: [0.3, 0.3, 0.25], color: 0x4a4a4a },
{ pos: [0.6, -0.5, 0], size: [0.5, 0.5, 0.3], color: 0x333333 },
{ pos: [0.6, -0.7, 0], size: [0.4, 0.3, 0.25], color: 0x222222 },
{ pos: [0.3, 0.5, 0], size: [0.5, 0.3, 0.3], color: 0x555555 },
{ pos: [0.3, 0.6, 0], size: [0.4, 0.2, 0.2], color: 0x666666 },
{ pos: [0.0, 0.5, 0.4], size: [0.1, 0.25, 0.08], color: 0x88aaff },
{ pos: [0.0, 0.5, -0.4], size: [0.1, 0.25, 0.08], color: 0x88aaff },
{ pos: [0.6, 0.5, 0.4], size: [0.1, 0.25, 0.08], color: 0x88aaff },
{ pos: [0.6, 0.5, -0.4], size: [0.1, 0.25, 0.08], color: 0x88aaff },
{ pos: [2.0, -0.3, 0.4], size: [0.25, 0.08, 0.08], color: 0x555555 },
{ pos: [2.0, -0.3, -0.4], size: [0.25, 0.08, 0.08], color: 0x555555 },
{ pos: [-1.0, 0.4, 0.4], size: [0.2, 0.08, 0.08], color: 0x777777 },
{ pos: [-1.0, 0.4, -0.4], size: [0.2, 0.08, 0.08], color: 0x777777 },
];
parts.forEach(({ pos, size, color }) => {
const mesh = new THREE.Mesh(new THREE.BoxGeometry(...size), m(color));
mesh.position.set(pos[0], pos[1], pos[2]);
g.add(mesh);
});
return g;
}
function createThompson() {
const g = new THREE.Group();
const m = (c) => new THREE.MeshLambertMaterial({ color: c });
const parts = [
{ pos: [0, 0, 0], size: [2.0, 0.6, 1.0], color: 0x333333 },
{ pos: [0.3, 0.3, 0], size: [1.2, 0.3, 0.6], color: 0x444444 },
{ pos: [1.6, 0, 0], size: [0.8, 0.2, 0.2], color: 0x222222 },
{ pos: [2.2, 0, 0], size: [0.5, 0.18, 0.18], color: 0x111111 },
{ pos: [-0.6, -0.4, 0], size: [0.5, 0.6, 0.3], color: 0x5a3a2a },
{ pos: [-0.7, -0.6, 0], size: [0.4, 0.3, 0.25], color: 0x6a4a3a },
{ pos: [0.5, -0.5, 0], size: [0.5, 0.5, 0.3], color: 0x3a3a3a },
{ pos: [0.5, -0.7, 0], size: [0.4, 0.3, 0.25], color: 0x2a2a2a },
{ pos: [-0.8, 0.4, 0], size: [0.3, 0.15, 0.3], color: 0x6a5a4a },
{ pos: [-0.9, 0.5, 0], size: [0.2, 0.1, 0.2], color: 0x7a6a5a },
{ pos: [1.4, 0.3, 0.5], size: [0.5, 0.08, 0.08], color: 0x444444 },
{ pos: [1.4, 0.3, -0.5], size: [0.5, 0.08, 0.08], color: 0x444444 },
{ pos: [0.8, 0.6, 0], size: [0.4, 0.1, 0.1], color: 0x555555 },
{ pos: [2.5, 0, 0], size: [0.15, 0.25, 0.25], color: 0x333333 },
{ pos: [-0.2, 0.1, 0.6], size: [0.1, 0.1, 0.06], color: 0x666666 },
{ pos: [-0.2, 0.1, -0.6], size: [0.1, 0.1, 0.06], color: 0x666666 },
];
parts.forEach(({ pos, size, color }) => {
const mesh = new THREE.Mesh(new THREE.BoxGeometry(...size), m(color));
mesh.position.set(pos[0], pos[1], pos[2]);
g.add(mesh);
});
return g;
}
function createM1Garand() {
const g = new THREE.Group();
const m = (c) => new THREE.MeshLambertMaterial({ color: c });
const parts = [
{ pos: [0, 0, 0], size: [2.2, 0.5, 0.7], color: 0x3a2a1a },
{ pos: [0.3, 0.25, 0], size: [1.6, 0.25, 0.4], color: 0x4a3a2a },
{ pos: [1.8, 0, 0], size: [1.4, 0.18, 0.18], color: 0x2a2a2a },
{ pos: [2.6, 0, 0], size: [0.5, 0.15, 0.15], color: 0x1a1a1a },
{ pos: [3.0, 0, 0], size: [0.12, 0.28, 0.28], color: 0x444444 },
{ pos: [-1.8, 0, 0], size: [0.8, 0.4, 0.4], color: 0x5a4a3a },
{ pos: [-2.2, 0, 0], size: [0.5, 0.35, 0.35], color: 0x6a5a4a },
{ pos: [-2.5, 0.1, 0], size: [0.3, 0.3, 0.3], color: 0x7a6a5a },
{ pos: [-0.4, -0.4, 0], size: [0.4, 0.5, 0.3], color: 0x3a3a3a },
{ pos: [-0.5, -0.6, 0], size: [0.3, 0.3, 0.25], color: 0x4a4a4a },
{ pos: [0.4, -0.5, 0], size: [0.5, 0.5, 0.3], color: 0x333333 },
{ pos: [0.4, -0.7, 0], size: [0.4, 0.3, 0.25], color: 0x222222 },
{ pos: [0.6, 0.5, 0], size: [0.6, 0.2, 0.2], color: 0x555555 },
{ pos: [1.2, 0.3, 0.4], size: [0.3, 0.08, 0.08], color: 0x666666 },
{ pos: [1.2, 0.3, -0.4], size: [0.3, 0.08, 0.08], color: 0x666666 },
{ pos: [-1.0, 0.35, 0.35], size: [0.15, 0.08, 0.08], color: 0x777777 },
{ pos: [-1.0, 0.35, -0.35], size: [0.15, 0.08, 0.08], color: 0x777777 },
{ pos: [-0.2, 0.3, 0.6], size: [0.08, 0.08, 0.08], color: 0x888888 },
{ pos: [-0.2, 0.3, -0.6], size: [0.08, 0.08, 0.08], color: 0x888888 },
];
parts.forEach(({ pos, size, color }) => {
const mesh = new THREE.Mesh(new THREE.BoxGeometry(...size), m(color));
mesh.position.set(pos[0], pos[1], pos[2]);
g.add(mesh);
});
return g;
}
function createSTG44() {
const g = new THREE.Group();
const m = (c) => new THREE.MeshLambertMaterial({ color: c });
const parts = [
{ pos: [0, 0, 0], size: [1.8, 0.5, 0.8], color: 0x444444 },
{ pos: [0.3, 0.25, 0], size: [1.2, 0.25, 0.5], color: 0x555555 },
{ pos: [1.6, 0, 0], size: [1.2, 0.2, 0.2], color: 0x333333 },
{ pos: [2.2, 0, 0], size: [0.6, 0.18, 0.18], color: 0x222222 },
{ pos: [2.6, 0, 0], size: [0.2, 0.3, 0.3], color: 0x444444 },
{ pos: [-1.6, 0, 0], size: [0.8, 0.5, 0.5], color: 0x5a4a3a },
{ pos: [-2.0, 0, 0], size: [0.5, 0.4, 0.4], color: 0x6a5a4a },
{ pos: [-2.3, 0.1, 0], size: [0.3, 0.3, 0.3], color: 0x7a6a5a },
{ pos: [0.8, -0.1, 0.5], size: [0.6, 0.2, 0.1], color: 0x4a3a2a },
{ pos: [0.8, -0.1, -0.5], size: [0.6, 0.2, 0.1], color: 0x4a3a2a },
{ pos: [0.8, 0.1, 0.5], size: [0.6, 0.1, 0.1], color: 0x3a2a1a },
{ pos: [0.8, 0.1, -0.5], size: [0.6, 0.1, 0.1], color: 0x3a2a1a },
{ pos: [-0.4, -0.4, 0], size: [0.4, 0.5, 0.3], color: 0x3a3a3a },
{ pos: [-0.5, -0.6, 0], size: [0.3, 0.3, 0.25], color: 0x4a4a4a },
{ pos: [0.1, -0.5, 0], size: [0.4, 0.5, 0.3], color: 0x2a2a2a },
{ pos: [0.0, -0.7, 0], size: [0.3, 0.3, 0.25], color: 0x333333 },
{ pos: [1.8, 0.4, 0], size: [0.1, 0.15, 0.1], color: 0x666666 },
{ pos: [-0.6, 0.4, 0], size: [0.15, 0.1, 0.15], color: 0x666666 },
{ pos: [0.5, 0.2, 0.55], size: [0.12, 0.08, 0.08], color: 0x555555 },
];
parts.forEach(({ pos, size, color }) => {
const mesh = new THREE.Mesh(new THREE.BoxGeometry(...size), m(color));
mesh.position.set(pos[0], pos[1], pos[2]);
g.add(mesh);
});
return g;
}
function createColt1911() {
const g = new THREE.Group();
const m = (c) => new THREE.MeshLambertMaterial({ color: c });
const parts = [
{ pos: [0, 0.2, 0], size: [1.4, 0.35, 0.5], color: 0x444444 },
{ pos: [0.2, 0.32, 0], size: [1.0, 0.12, 0.35], color: 0x555555 },
{ pos: [1.2, 0.2, 0], size: [0.6, 0.2, 0.2], color: 0x333333 },
{ pos: [1.6, 0.2, 0], size: [0.35, 0.18, 0.18], color: 0x222222 },
{ pos: [1.8, 0.45, 0], size: [0.08, 0.12, 0.08], color: 0x666666 },
{ pos: [-0.8, 0.45, 0], size: [0.2, 0.08, 0.2], color: 0x666666 },
{ pos: [-0.6, -0.2, 0], size: [0.4, 0.5, 0.25], color: 0x5a3a2a },
{ pos: [-0.6, -0.35, 0], size: [0.35, 0.25, 0.2], color: 0x6a4a3a },
{ pos: [-0.7, -0.25, 0.35], size: [0.25, 0.08, 0.04], color: 0x4a2a1a },
{ pos: [-0.7, -0.25, -0.35], size: [0.25, 0.08, 0.04], color: 0x4a2a1a },
{ pos: [-0.2, -0.1, 0.35], size: [0.2, 0.12, 0.06], color: 0x333333 },
{ pos: [-0.2, -0.1, -0.35], size: [0.2, 0.12, 0.06], color: 0x333333 },
{ pos: [-0.15, -0.18, 0], size: [0.06, 0.12, 0.06], color: 0x222222 },
{ pos: [-0.5, -0.5, 0], size: [0.25, 0.25, 0.2], color: 0x333333 },
{ pos: [-0.5, -0.65, 0], size: [0.2, 0.15, 0.15], color: 0x222222 },
{ pos: [0.5, 0.1, 0.4], size: [0.08, 0.08, 0.06], color: 0x666666 },
{ pos: [0.5, 0.1, -0.4], size: [0.08, 0.08, 0.06], color: 0x666666 },
{ pos: [0.0, 0.5, 0], size: [0.6, 0.04, 0.04], color: 0x555555 },
{ pos: [1.9, 0.2, 0], size: [0.15, 0.25, 0.25], color: 0x444444 },
{ pos: [-0.8, 0.25, 0], size: [0.08, 0.12, 0.12], color: 0x333333 },
{ pos: [-0.6, 0.18, 0.4], size: [0.06, 0.06, 0.06], color: 0xff0000 },
{ pos: [-0.6, 0.18, -0.4], size: [0.06, 0.06, 0.06], color: 0xff0000 },
];
parts.forEach(({ pos, size, color }) => {
const mesh = new THREE.Mesh(new THREE.BoxGeometry(...size), m(color));
mesh.position.set(pos[0], pos[1], pos[2]);
g.add(mesh);
});
return g;
}
// ============================================================
// TERRAIN BUILDERS
// ============================================================
function clearWorldObjects() {
// Remove trees
for (const t of treeMeshes) {
scene.remove(t);
}
treeMeshes = [];
trees = [];
// Remove old obstacles that are not buildings/cars/etc? We'll rebuild everything.
// But we need to keep the ground. Actually we'll rebuild the whole terrain.
// So we'll remove all obstacles and meshes except player and enemies.
for (const obs of obstacles) {
if (obs.mesh) scene.remove(obs.mesh);
// also remove any associated meshes
}
obstacles = [];
// Remove pickups
for (const p of pickups) {
scene.remove(p.group);
}
pickups = [];
// Remove bullets
for (const b of bullets) {
scene.remove(b.mesh);
if (b.trail) scene.remove(b.trail);
}
bullets = [];
// Remove enemies
for (const e of enemies) {
scene.remove(e.group);
}
enemies = [];
// Clear grid
grid = [];
}
function buildTree(x, z) {
const group = new THREE.Group();
const trunkMat = new THREE.MeshLambertMaterial({ color: 0x5a3a2a });
const leafMat = new THREE.MeshLambertMaterial({ map: createLeafTexture() }); // 使用树叶纹理
const trunk = new THREE.Mesh(new THREE.CylinderGeometry(0.8, 1.2, 6, 6), trunkMat);
trunk.position.set(0, 3, 0);
trunk.castShadow = true;
trunk.receiveShadow = true;
group.add(trunk);
const leaf1 = new THREE.Mesh(new THREE.ConeGeometry(4.5, 4.5, 6), leafMat);
leaf1.position.set(0, 7, 0);
leaf1.castShadow = true;
leaf1.receiveShadow = true;
group.add(leaf1);
const leaf2 = new THREE.Mesh(new THREE.ConeGeometry(3.5, 3.5, 6), leafMat);
leaf2.position.set(0, 9.5, 0);
leaf2.castShadow = true;
leaf2.receiveShadow = true;
group.add(leaf2);
const leaf3 = new THREE.Mesh(new THREE.ConeGeometry(2.5, 2.5, 6), leafMat);
leaf3.position.set(0, 11.5, 0);
leaf3.castShadow = true;
leaf3.receiveShadow = true;
group.add(leaf3);
group.position.set(x, 0, z);
scene.add(group);
treeMeshes.push(group);
// Collision
const radius = 2.2;
const height = 12;
obstacles.push({
position: new THREE.Vector3(x, height / 2, z),
width: radius * 2,
height: height,
depth: radius * 2,
minX: x - radius,
maxX: x + radius,
minY: 0,
maxY: height,
minZ: z - radius,
maxZ: z + radius,
isTree: true,
mesh: group
});
return group;
}
function buildBeetleCar(x, z, rotY) {
const group = new THREE.Group();
const metalTex = createRustyMetalTexture(); // 生成一次,所有部件共享
const bodyMat = new THREE.MeshLambertMaterial({ map: metalTex });
const darkMat = new THREE.MeshLambertMaterial({ color: 0x5a4a2a });
const rustMat = new THREE.MeshLambertMaterial({ color: 0x6a4a2a });
const glassMat = new THREE.MeshLambertMaterial({ color: 0x88bbdd, transparent: true, opacity: 0.5 });
// Body
const body = new THREE.Mesh(new THREE.BoxGeometry(5, 2.2, 4.2), bodyMat);
body.position.y = 1.1;
body.castShadow = true;
body.receiveShadow = true;
group.add(body);
// Roof / cab (使用玻璃材质)
const cab = new THREE.Mesh(new THREE.BoxGeometry(2.8, 1.4, 3.6), glassMat);
cab.position.set(-0.4, 2.8, 0);
cab.castShadow = true;
cab.receiveShadow = true;
group.add(cab);
// Fenders (rusty)
const fMat = rustMat;
const f1 = new THREE.Mesh(new THREE.BoxGeometry(1.2, 0.3, 4.6), fMat);
f1.position.set(2.6, 0.4, 0);
group.add(f1);
const f2 = new THREE.Mesh(new THREE.BoxGeometry(1.2, 0.3, 4.6), fMat);
f2.position.set(-2.6, 0.4, 0);
group.add(f2);
// Wheels (dark)
const wheelMat = new THREE.MeshLambertMaterial({ color: 0x222222 });
const wPos = [
[-2.2, 0.5, 2.0],
[-2.2, 0.5, -2.0],
[2.2, 0.5, 2.0],
[2.2, 0.5, -2.0]
];
for (const wp of wPos) {
const wheel = new THREE.Mesh(new THREE.CylinderGeometry(0.7, 0.7, 0.4, 8), wheelMat);
wheel.rotation.x = Math.PI / 2;
wheel.position.set(wp[0], wp[1], wp[2]);
wheel.castShadow = true;
wheel.receiveShadow = true;
group.add(wheel);
}
// Headlights (rusty)
const hlMat = new THREE.MeshLambertMaterial({ color: 0xcccc77, emissive: 0x888844, emissiveIntensity: 0.1 });
const hl1 = new THREE.Mesh(new THREE.SphereGeometry(0.4, 6, 6), hlMat);
hl1.position.set(2.7, 0.8, 1.0);
group.add(hl1);
const hl2 = new THREE.Mesh(new THREE.SphereGeometry(0.4, 6, 6), hlMat);
hl2.position.set(2.7, 0.8, -1.0);
group.add(hl2);
// Tail lights (dim)
const tlMat = new THREE.MeshLambertMaterial({ color: 0x883333 });
const tl1 = new THREE.Mesh(new THREE.SphereGeometry(0.3, 6, 6), tlMat);
tl1.position.set(-2.7, 0.8, 1.0);
group.add(tl1);
const tl2 = new THREE.Mesh(new THREE.SphereGeometry(0.3, 6, 6), tlMat);
tl2.position.set(-2.7, 0.8, -1.0);
group.add(tl2);
// Rust spots (decorative)
const rustSpotMat = new THREE.MeshLambertMaterial({ color: 0x6a4a2a });
for (let i = 0; i < 8; i++) {
const rs = new THREE.Mesh(new THREE.SphereGeometry(0.2 + Math.random() * 0.3, 4, 4), rustSpotMat);
rs.position.set((Math.random() - 0.5) * 4, 0.5 + Math.random() * 1.5, (Math.random() - 0.5) * 3.5);
group.add(rs);
}
group.position.set(x, 0, z);
group.rotation.y = rotY || Math.random() * Math.PI * 2;
const scale = 3;
group.scale.set(scale, scale, scale);
scene.add(group);
// Collision (simplified box)
const halfW = 2.8 * scale;
const halfD = 2.2 * scale;
const halfH = 1.3 * scale;
obstacles.push({
position: new THREE.Vector3(x, halfH, z),
width: halfW * 2,
height: halfH * 2,
depth: halfD * 2,
minX: x - halfW,
maxX: x + halfW,
minY: 0,
maxY: halfH * 2,
minZ: z - halfD,
maxZ: z + halfD,
isCar: true,
mesh: group
});
return group;
}
// 生成木纹纹理(丛林)
function createWoodTexture() {
const canvas = document.createElement('canvas');
canvas.width = 256;
canvas.height = 256;
const ctx = canvas.getContext('2d');
// 底色
ctx.fillStyle = '#8B5A2B';
ctx.fillRect(0, 0, 256, 256);
// 木纹线
for (let i = 0; i < 60; i++) {
const y = 5 + Math.random() * 246;
ctx.strokeStyle = `hsl(30, 40%, ${20 + Math.random() * 30}%)`;
ctx.lineWidth = 1 + Math.random() * 3;
ctx.beginPath();
let x = 0;
while (x < 256) {
ctx.lineTo(x, y + Math.sin(x * 0.05 + i) * 3 + (Math.random() - 0.5) * 2);
x += 2;
}
ctx.stroke();
}
// 增加一些节疤
for (let i = 0; i < 8; i++) {
const cx = 20 + Math.random() * 216;
const cy = 20 + Math.random() * 216;
const r = 6 + Math.random() * 12;
const grad = ctx.createRadialGradient(cx, cy, 0, cx, cy, r);
grad.addColorStop(0, '#4A2A1A');
grad.addColorStop(1, '#8B5A2B');
ctx.fillStyle = grad;
ctx.beginPath();
ctx.arc(cx, cy, r, 0, Math.PI * 2);
ctx.fill();
}
const tex = new THREE.CanvasTexture(canvas);
tex.wrapS = tex.wrapT = THREE.ClampToEdgeWrapping;
return tex;
}
// 生成混凝土纹理(带弹坑)
function createConcreteTexture(withCraters = true) {
const canvas = document.createElement('canvas');
canvas.width = 256;
canvas.height = 256;
const ctx = canvas.getContext('2d');
// 混凝土底色(灰色带随机颗粒)
ctx.fillStyle = '#9A9A9A';
ctx.fillRect(0, 0, 256, 256);
for (let i = 0; i < 2000; i++) {
const x = Math.random() * 256;
const y = Math.random() * 256;
const v = 120 + Math.random() * 80;
ctx.fillStyle = `rgb(${v},${v},${v})`;
ctx.fillRect(x, y, 1, 1);
}
if (withCraters) {
// 弹坑(暗色凹陷,边缘亮色)
for (let i = 0; i < 30; i++) {
const cx = 20 + Math.random() * 216;
const cy = 20 + Math.random() * 216;
const r = 4 + Math.random() * 18;
// 坑底
const grad = ctx.createRadialGradient(cx, cy, 0, cx, cy, r);
grad.addColorStop(0, '#2A2A2A');
grad.addColorStop(0.6, '#4A4A4A');
grad.addColorStop(1, '#AAAAAA');
ctx.fillStyle = grad;
ctx.beginPath();
ctx.arc(cx, cy, r, 0, Math.PI * 2);
ctx.fill();
// 边缘高光
ctx.strokeStyle = '#BBBBBB';
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.arc(cx - 2, cy - 2, r * 0.8, 0, Math.PI * 2);
ctx.stroke();
}
// 额外小弹坑
for (let i = 0; i < 60; i++) {
const cx = Math.random() * 256;
const cy = Math.random() * 256;
const r = 2 + Math.random() * 5;
ctx.fillStyle = '#3A3A3A';
ctx.beginPath();
ctx.arc(cx, cy, r, 0, Math.PI * 2);
ctx.fill();
}
// 裂纹(模拟弹坑引起的裂缝)
ctx.strokeStyle = '#6A6A6A';
ctx.lineWidth = 0.5;
for (let i = 0; i < 20; i++) {
const sx = Math.random() * 256;
const sy = Math.random() * 256;
ctx.beginPath();
ctx.moveTo(sx, sy);
let x = sx, y = sy;
for (let j = 0; j < 8; j++) {
x += (Math.random() - 0.5) * 20;
y += (Math.random() - 0.5) * 20;
ctx.lineTo(x, y);
}
ctx.stroke();
}
}
const tex = new THREE.CanvasTexture(canvas);
tex.wrapS = tex.wrapT = THREE.ClampToEdgeWrapping;
return tex;
}
function createLeafTexture() {
const canvas = document.createElement('canvas');
canvas.width = 256;
canvas.height = 256;
const ctx = canvas.getContext('2d');
ctx.fillStyle = '#2a7a2a';
ctx.fillRect(0, 0, 256, 256);
for (let i = 0; i < 800; i++) {
const x = Math.random() * 256;
const y = Math.random() * 256;
const r = 3 + Math.random() * 12;
const g = 120 + Math.random() * 80;
ctx.fillStyle = `rgba(${40 + Math.random()*30}, ${g}, ${40 + Math.random()*30}, 0.6)`;
ctx.beginPath();
ctx.arc(x, y, r, 0, Math.PI * 2);
ctx.fill();
}
// 叶脉线条 - 这里使用了 const x, y,但重新赋值会报错,应该改为 let
ctx.strokeStyle = '#1a5a1a';
ctx.lineWidth = 0.5;
for (let i = 0; i < 30; i++) {
let x = Math.random() * 256; // 改为 let
let y = Math.random() * 256; // 改为 let
ctx.beginPath();
ctx.moveTo(x, y);
for (let j = 0; j < 6; j++) {
x += (Math.random() - 0.5) * 20; // 修改 x
y += (Math.random() - 0.5) * 20; // 修改 y
ctx.lineTo(x, y);
}
ctx.stroke();
}
const tex = new THREE.CanvasTexture(canvas);
tex.wrapS = tex.wrapT = THREE.RepeatWrapping;
tex.repeat.set(1, 1);
return tex;
}
function createGrassTexture() {
const canvas = document.createElement('canvas');
canvas.width = 512;
canvas.height = 512;
const ctx = canvas.getContext('2d');
// 底色改为深绿色
ctx.fillStyle = '#2a5a2a';
ctx.fillRect(0, 0, 512, 512);
// 草叶斑点(亮度降低)
for (let i = 0; i < 3000; i++) {
const x = Math.random() * 512;
const y = Math.random() * 512;
const g = 80 + Math.random() * 60; // 从 120+80 降至 80+60
const r = 30 + Math.random() * 20; // 从 40+30 降至 30+20
ctx.fillStyle = `rgba(${r}, ${g}, ${r-20}, 0.6)`;
ctx.fillRect(x, y, 1 + Math.random() * 4, 1 + Math.random() * 4);
}
// 随机草叶线条(也适当调暗)
ctx.strokeStyle = 'rgba(40,80,40,0.3)'; // 从 50,100,50 改为 40,80,40
ctx.lineWidth = 0.5;
for (let i = 0; i < 200; i++) {
const x = Math.random() * 512;
const y = Math.random() * 512;
ctx.beginPath();
ctx.moveTo(x, y);
ctx.lineTo(x + (Math.random() - 0.5) * 10, y + 2 + Math.random() * 6);
ctx.stroke();
}
const tex = new THREE.CanvasTexture(canvas);
tex.wrapS = tex.wrapT = THREE.RepeatWrapping;
tex.repeat.set(30, 30);
return tex;
}function createAsphaltTexture() {
const canvas = document.createElement('canvas');
canvas.width = 256;
canvas.height = 256;
const ctx = canvas.getContext('2d');
ctx.fillStyle = '#3a3a3a';
ctx.fillRect(0, 0, 256, 256);
for (let i = 0; i < 2000; i++) {
const x = Math.random() * 256;
const y = Math.random() * 256;
const v = 50 + Math.random() * 60;
ctx.fillStyle = `rgb(${v}, ${v}, ${v})`;
ctx.fillRect(x, y, 1, 1);
}
ctx.strokeStyle = 'rgba(20,20,20,0.3)';
ctx.lineWidth = 0.8;
for (let i = 0; i < 30; i++) {
let sx = Math.random() * 256;
let sy = Math.random() * 256;
ctx.beginPath();
ctx.moveTo(sx, sy);
for (let j = 0; j < 5; j++) {
sx += (Math.random() - 0.5) * 30;
sy += (Math.random() - 0.5) * 30;
ctx.lineTo(sx, sy);
}
ctx.stroke();
}
const tex = new THREE.CanvasTexture(canvas);
tex.wrapS = tex.wrapT = THREE.RepeatWrapping;
tex.repeat.set(20, 20);
return tex;
}// 生成锈蚀金属纹理(用于车辆)
function createRustyMetalTexture() {
const canvas = document.createElement('canvas');
canvas.width = 256;
canvas.height = 256;
const ctx = canvas.getContext('2d');
// 金属底色
ctx.fillStyle = '#8a7a3a';
ctx.fillRect(0, 0, 256, 256);
// 锈斑
for (let i = 0; i < 300; i++) {
const x = Math.random() * 256;
const y = Math.random() * 256;
const r = 2 + Math.random() * 15;
const c = Math.floor(60 + Math.random() * 80);
ctx.fillStyle = `rgba(${c}, ${c-20}, ${c-40}, 0.7)`;
ctx.beginPath();
ctx.arc(x, y, r, 0, Math.PI * 2);
ctx.fill();
}
// 划痕
ctx.strokeStyle = 'rgba(50,40,20,0.3)';
ctx.lineWidth = 0.8;
for (let i = 0; i < 50; i++) {
const sx = Math.random() * 256;
const sy = Math.random() * 256;
ctx.beginPath();
ctx.moveTo(sx, sy);
ctx.lineTo(sx + (Math.random()-0.5)*30, sy + (Math.random()-0.5)*30);
ctx.stroke();
}
// 高光斑点
for (let i = 0; i < 100; i++) {
const x = Math.random() * 256;
const y = Math.random() * 256;
const v = 180 + Math.random() * 70;
ctx.fillStyle = `rgba(${v}, ${v-20}, ${v-40}, 0.2)`;
ctx.beginPath();
ctx.arc(x, y, 1+Math.random()*3, 0, Math.PI*2);
ctx.fill();
}
const tex = new THREE.CanvasTexture(canvas);
tex.wrapS = tex.wrapT = THREE.RepeatWrapping;
tex.repeat.set(2, 2);
return tex;
}
// ============================================================
// SKY GENERATOR (Canvas 2D)
// ============================================================
function createSkyTexture(waveIdx) {
const canvas = document.createElement('canvas');
canvas.width = 1024;
canvas.height = 512;
const ctx = canvas.getContext('2d');
const configs = [
// 波次1: 丛林 - 暖黄绿
{ top: '#87CEEB', mid: '#b8d4a0', bottom: '#7a9a6a', cloud: '#ffffff' },
// 波次2: 城市 - 灰蓝
{ top: '#6a8a9a', mid: '#8a9aa0', bottom: '#5a6a6a', cloud: '#ccdde0' },
// 波次3: 城市密集 - 黄昏
{ top: '#d4a050', mid: '#c49070', bottom: '#5a4a3a', cloud: '#e8c8a0' },
// 波次4: 城市密集 - 阴天/战火
{ top: '#4a5a5a', mid: '#6a6a5a', bottom: '#3a3a3a', cloud: '#8a8a7a' }
];
const cfg = configs[waveIdx] || configs[0];
// ---- 1. 渐变天空 ----
const grad = ctx.createLinearGradient(0, 0, 0, canvas.height);
grad.addColorStop(0, cfg.top);
grad.addColorStop(0.5, cfg.mid);
grad.addColorStop(1, cfg.bottom);
ctx.fillStyle = grad;
ctx.fillRect(0, 0, canvas.width, canvas.height);
// ---- 2. 太阳/光晕(可选) ----
if (waveIdx < 3) {
const sunX = canvas.width * 0.75;
const sunY = canvas.height * 0.15;
const gradSun = ctx.createRadialGradient(sunX, sunY, 0, sunX, sunY, 200);
gradSun.addColorStop(0, 'rgba(255,240,200,0.6)');
gradSun.addColorStop(0.3, 'rgba(255,220,150,0.3)');
gradSun.addColorStop(1, 'rgba(255,200,100,0)');
ctx.fillStyle = gradSun;
ctx.fillRect(0, 0, canvas.width, canvas.height);
}
// ---- 3. 云朵(随机分布) ----
function drawCloud(cx, cy, scale, alpha) {
const count = 5 + Math.floor(Math.random() * 4);
for (let i = 0; i < count; i++) {
const x = cx + (Math.random() - 0.5) * 200 * scale;
const y = cy + (Math.random() - 0.5) * 40 * scale;
const r = (20 + Math.random() * 40) * scale;
const gradCloud = ctx.createRadialGradient(x, y, 0, x, y, r);
gradCloud.addColorStop(0, `rgba(255,255,255,${alpha * 0.9})`);
gradCloud.addColorStop(0.7, `rgba(255,255,255,${alpha * 0.4})`);
gradCloud.addColorStop(1, `rgba(255,255,255,0)`);
ctx.fillStyle = gradCloud;
ctx.beginPath();
ctx.arc(x, y, r, 0, Math.PI * 2);
ctx.fill();
}
}
const cloudColors = [cfg.cloud, '#ffffff', '#e8e8e8'];
const cloudCount = 12 + Math.floor(Math.random() * 8);
for (let i = 0; i < cloudCount; i++) {
const x = Math.random() * canvas.width;
const y = 20 + Math.random() * canvas.height * 0.5;
const scale = 0.5 + Math.random() * 1.5;
const alpha = 0.3 + Math.random() * 0.5;
drawCloud(x, y, scale, alpha);
}
// ---- 4. 微弱的雾气/尘埃(低空) ----
const fogGrad = ctx.createLinearGradient(0, canvas.height * 0.6, 0, canvas.height);
fogGrad.addColorStop(0, 'rgba(200,200,200,0)');
fogGrad.addColorStop(1, `rgba(${waveIdx < 2 ? '180,200,160' : '160,160,150'}, 0.15)`);
ctx.fillStyle = fogGrad;
ctx.fillRect(0, canvas.height * 0.6, canvas.width, canvas.height * 0.4);
const texture = new THREE.CanvasTexture(canvas);
texture.magFilter = THREE.LinearFilter;
texture.minFilter = THREE.LinearMipmapLinearFilter;
texture.wrapS = THREE.RepeatWrapping;
texture.wrapT = THREE.ClampToEdgeWrapping;
texture.repeat.set(1, 1);
return texture;
}
function buildTerrain(waveIdx) {
const config = WAVE_CONFIG[waveIdx];
const terrain = config.terrain;
const groundGeo = new THREE.PlaneGeometry(MAP_SIZE, MAP_SIZE);
let groundMat;
if (terrain === 'jungle') {
groundMat = new THREE.MeshLambertMaterial({ map: createGrassTexture() });
} else {
groundMat = new THREE.MeshLambertMaterial({ map: createAsphaltTexture() });
}
const ground = new THREE.Mesh(groundGeo, groundMat);
ground.rotation.x = -Math.PI / 2;
ground.receiveShadow = true;
scene.add(ground);
// ---- Road lines ----
const lineMat = new THREE.MeshLambertMaterial({ color: 0x555555 });
for (let i = -HALF + 50; i < HALF - 50; i += 60) {
const l1 = new THREE.Mesh(new THREE.PlaneGeometry(2, MAP_SIZE - 100), lineMat);
l1.rotation.x = -Math.PI / 2;
l1.position.set(i, 0.1, 0);
scene.add(l1);
const l2 = new THREE.Mesh(new THREE.PlaneGeometry(MAP_SIZE - 100, 2), lineMat);
l2.rotation.x = -Math.PI / 2;
l2.position.set(0, 0.1, i);
scene.add(l2);
}
const buildingColors = [0x8a7a6a, 0x9a8a7a, 0x7a8a9a, 0x6a7a8a, 0xaa9a8a, 0x8a9a7a, 0x7a6a5a, 0x5a6a7a];
// 辅助函数:为建筑物添加窗户装饰(从原代码移植)
function addWindows(mesh, w, h, d, x, y, z) {
const winMat = new THREE.MeshLambertMaterial({ color: 0x88aacc, emissive: 0x224466, emissiveIntensity: 0.15 });
const winCount = Math.floor(Math.random() * 6) + 4;
for (let wi = 0; wi < winCount; wi++) {
const wx = (Math.random() - 0.5) * w * 0.8;
const wz = (Math.random() - 0.5) * d * 0.8;
const wy = (Math.random() - 0.5) * h * 0.8;
const win = new THREE.Mesh(new THREE.PlaneGeometry(2.5, 3.5), winMat);
const face = Math.floor(Math.random() * 4);
if (face === 0) {
win.position.set(w / 2 + 0.1, h / 2 + wy, wz);
win.rotation.y = Math.PI / 2;
} else if (face === 1) {
win.position.set(-w / 2 - 0.1, h / 2 + wy, wz);
win.rotation.y = -Math.PI / 2;
} else if (face === 2) {
win.position.set(wx, h / 2 + wy, d / 2 + 0.1);
} else {
win.position.set(wx, h / 2 + wy, -d / 2 - 0.1);
win.rotation.y = Math.PI;
}
scene.add(win);
}
}
// 在 buildTerrain 中,进入建筑物循环之前
let texTop, texSide;
if (terrain === 'jungle') {
texTop = createWoodTexture(); // 顶面用木纹
texSide = createWoodTexture(); // 侧面也用木纹(也可稍暗)
} else {
texTop = createConcreteTexture(false); // 顶面干净混凝土
texSide = createConcreteTexture(true); // 侧面带弹坑
}
// 在 buildTerrain 开头,定义纹理变量
let stairTexture;
if (terrain === 'jungle') {
stairTexture = createWoodTexture(); // 木纹
} else {
stairTexture = createConcreteTexture(true); // 带弹坑的混凝土
}
// 然后在创建楼梯时使用:
const stairMat = new THREE.MeshLambertMaterial({ map: stairTexture });
if (terrain === 'jungle') {
// ---- Jungle: lots of trees, few buildings ----
const treeCount = 120 + Math.floor(Math.random() * 40);
for (let i = 0; i < treeCount; i++) {
const x = (Math.random() - 0.5) * MAP_SIZE * 0.9;
const z = (Math.random() - 0.5) * MAP_SIZE * 0.9;
if (Math.abs(x) < 30 && Math.abs(z) < 30) continue;
let overlap = false;
for (const obs of obstacles) {
if (Math.abs(x - obs.position.x) < 5 + obs.width / 2 &&
Math.abs(z - obs.position.z) < 5 + obs.depth / 2) { overlap = true; break; }
}
if (!overlap) buildTree(x, z);
}
// Few buildings
for (let i = 0; i < 15; i++) {
let ok = false, tries = 0;
while (!ok && tries < 200) {
tries++;
const w = 15 + Math.random() * 25;
const h = 10 + Math.random() * 8;
const d = 15 + Math.random() * 25;
const x = (Math.random() - 0.5) * MAP_SIZE * 0.75;
const z = (Math.random() - 0.5) * MAP_SIZE * 0.75;
if (Math.abs(x) < 50 && Math.abs(z) < 50) continue;
let overlap = false;
for (const obs of obstacles) {
if (Math.abs(x - obs.position.x) < w / 2 + obs.width / 2 + 8 &&
Math.abs(z - obs.position.z) < d / 2 + obs.depth / 2 + 8) { overlap = true; break; }
}
if (!overlap) {
// 替换原来的 mesh 创建
const materials = [
new THREE.MeshLambertMaterial({ map: texSide }), // +x
new THREE.MeshLambertMaterial({ map: texSide }), // -x
new THREE.MeshLambertMaterial({ map: texTop }), // +y (顶面)
new THREE.MeshLambertMaterial({ map: texSide }), // -y (底面,也可用侧面)
new THREE.MeshLambertMaterial({ map: texSide }), // +z
new THREE.MeshLambertMaterial({ map: texSide }) // -z
];
const mesh = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), materials);
mesh.position.set(x, h / 2, z);
mesh.castShadow = true;
mesh.receiveShadow = true;
scene.add(mesh);
addWindows(mesh, w, h, d, x, h / 2, z);
obstacles.push({
mesh: mesh,
width: w,
height: h,
depth: d,
position: new THREE.Vector3(x, h / 2, z),
minX: x - w / 2,
maxX: x + w / 2,
minY: 0,
maxY: h,
minZ: z - d / 2,
maxZ: z + d / 2,
});
// ---- 护栏 ----
const railHeight = 0.5;
const railThick = 0.3;
const railMat = new THREE.MeshLambertMaterial({ color: 0xcccccc });
// 前
const rf = new THREE.Mesh(new THREE.BoxGeometry(w, railHeight, railThick), railMat);
rf.position.set(x, h, z + d / 2 - railThick / 2);
rf.castShadow = true;
rf.receiveShadow = true;
scene.add(rf);
obstacles.push({
mesh: rf, // 添加这一行
position: new THREE.Vector3(x, h + railHeight / 2, z + d / 2 - railThick / 2),
width: w, height: railHeight, depth: railThick,
minX: x - w / 2, maxX: x + w / 2,
minY: h, maxY: h + railHeight,
minZ: z + d / 2 - railThick, maxZ: z + d / 2,
isCar: false, isRail: true
});
// 后
const rb = new THREE.Mesh(new THREE.BoxGeometry(w, railHeight, railThick), railMat);
rb.position.set(x, h, z - d / 2 + railThick / 2);
rb.castShadow = true;
rb.receiveShadow = true;
scene.add(rb);
obstacles.push({
mesh: rb,
position: new THREE.Vector3(x, h + railHeight / 2, z - d / 2 + railThick / 2),
width: w, height: railHeight, depth: railThick,
minX: x - w / 2, maxX: x + w / 2,
minY: h, maxY: h + railHeight,
minZ: z - d / 2, maxZ: z - d / 2 + railThick,
isCar: false, isRail: true
});
// 左
const rl = new THREE.Mesh(new THREE.BoxGeometry(railThick, railHeight, d), railMat);
rl.position.set(x - w / 2 + railThick / 2, h, z);
rl.castShadow = true;
rl.receiveShadow = true;
scene.add(rl);
obstacles.push({
mesh: rl,
position: new THREE.Vector3(x - w / 2 + railThick / 2, h + railHeight / 2, z),
width: railThick, height: railHeight, depth: d,
minX: x - w / 2, maxX: x - w / 2 + railThick,
minY: h, maxY: h + railHeight,
minZ: z - d / 2, maxZ: z + d / 2,
isCar: false, isRail: true
});
// 右
const rr = new THREE.Mesh(new THREE.BoxGeometry(railThick, railHeight, d), railMat);
rr.position.set(x + w / 2 - railThick / 2, h, z);
rr.castShadow = true;
rr.receiveShadow = true;
scene.add(rr);
obstacles.push({
mesh: rr,
position: new THREE.Vector3(x + w / 2 - railThick / 2, h + railHeight / 2, z),
width: railThick, height: railHeight, depth: d,
minX: x + w / 2 - railThick, maxX: x + w / 2,
minY: h, maxY: h + railHeight,
minZ: z - d / 2, maxZ: z + d / 2,
isCar: false, isRail: true
});
// ---- 楼梯(南北方向) ----
const dirZ = Math.random() < 0.5 ? 1 : -1;
const stairWidth = w * 0.6;
const stepDepth = 2;
const stepHeight = 2;
const numSteps = Math.ceil(h / stepHeight);
let startZ = (dirZ === 1) ? z + d / 2 + stepDepth / 2 : z - d / 2 - stepDepth / 2;
for (let step = 0; step < numSteps; step++) {
const stepX = x;
const stepZ = startZ + dirZ * (step * stepDepth + stepDepth / 2);
const stepY = h - step * stepHeight - stepHeight / 2;
if (stepY < 0) break;
const meshStair = new THREE.Mesh(new THREE.BoxGeometry(stairWidth, stepHeight, stepDepth), stairMat);
meshStair.position.set(stepX, stepY, stepZ);
meshStair.castShadow = true;
meshStair.receiveShadow = true;
scene.add(meshStair);
obstacles.push({
mesh: meshStair,
position: new THREE.Vector3(stepX, stepY, stepZ),
width: stairWidth,
height: stepHeight,
depth: stepDepth,
minX: stepX - stairWidth / 2,
maxX: stepX + stairWidth / 2,
minY: stepY - stepHeight / 2,
maxY: stepY + stepHeight / 2,
minZ: stepZ - stepDepth / 2,
maxZ: stepZ + stepDepth / 2,
isCar: false,
isStair: true
});
}
ok = true;
}
}
}
// Few cars
for (let i = 0; i < 8; i++) {
let ok = false, tries = 0;
while (!ok && tries < 100) {
tries++;
const x = (Math.random() - 0.5) * MAP_SIZE * 0.8;
const z = (Math.random() - 0.5) * MAP_SIZE * 0.8;
if (Math.abs(x) < 40 && Math.abs(z) < 40) continue;
let overlap = false;
for (const obs of obstacles) {
if (Math.abs(x - obs.position.x) < 4 + obs.width / 2 &&
Math.abs(z - obs.position.z) < 3 + obs.depth / 2) { overlap = true; break; }
}
if (!overlap) {
buildBeetleCar(x, z, Math.random() * Math.PI * 2);
ok = true;
}
}
}
} else {
// ---- City: lots of buildings, some trees, many cars ----
const buildingCount = terrain === 'city_dense' ? 70 : 40;
for (let i = 0; i < buildingCount; i++) {
let ok = false, tries = 0;
while (!ok && tries < 300) {
tries++;
const w = 15 + Math.random() * 35;
const h = 12 + Math.random() * 12;
const d = 15 + Math.random() * 35;
const x = (Math.random() - 0.5) * MAP_SIZE * 0.85;
const z = (Math.random() - 0.5) * MAP_SIZE * 0.85;
if (Math.abs(x) < 45 && Math.abs(z) < 45) continue;
let overlap = false;
for (const obs of obstacles) {
if (Math.abs(x - obs.position.x) < w / 2 + obs.width / 2 + 10 &&
Math.abs(z - obs.position.z) < d / 2 + obs.depth / 2 + 10) { overlap = true; break; }
}
if (!overlap) {
// 替换原来的 mesh 创建
const materials = [
new THREE.MeshLambertMaterial({ map: texSide }), // +x
new THREE.MeshLambertMaterial({ map: texSide }), // -x
new THREE.MeshLambertMaterial({ map: texTop }), // +y (顶面)
new THREE.MeshLambertMaterial({ map: texSide }), // -y (底面,也可用侧面)
new THREE.MeshLambertMaterial({ map: texSide }), // +z
new THREE.MeshLambertMaterial({ map: texSide }) // -z
];
const mesh = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), materials);
mesh.position.set(x, h / 2, z);
mesh.castShadow = true;
mesh.receiveShadow = true;
scene.add(mesh);
addWindows(mesh, w, h, d, x, h / 2, z);
obstacles.push({
mesh: mesh,
width: w,
height: h,
depth: d,
position: new THREE.Vector3(x, h / 2, z),
minX: x - w / 2,
maxX: x + w / 2,
minY: 0,
maxY: h,
minZ: z - d / 2,
maxZ: z + d / 2,
});
// ---- 楼梯 ----
const dirZ = Math.random() < 0.5 ? 1 : -1;
const stairWidth = w * 0.6;
const stepDepth = 2;
const stepHeight = 2;
const numSteps = Math.ceil(h / stepHeight);
let startZ = (dirZ === 1) ? z + d / 2 + stepDepth / 2 : z - d / 2 - stepDepth / 2;
for (let step = 0; step < numSteps; step++) {
const stepX = x;
const stepZ = startZ + dirZ * (step * stepDepth + stepDepth / 2);
const stepY = h - step * stepHeight - stepHeight / 2;
if (stepY < 0) break;
const meshStair = new THREE.Mesh(new THREE.BoxGeometry(stairWidth, stepHeight, stepDepth), stairMat);
meshStair.position.set(stepX, stepY, stepZ);
meshStair.castShadow = true;
meshStair.receiveShadow = true;
scene.add(meshStair);
obstacles.push({
mesh: meshStair,
position: new THREE.Vector3(stepX, stepY, stepZ),
width: stairWidth,
height: stepHeight,
depth: stepDepth,
minX: stepX - stairWidth / 2,
maxX: stepX + stairWidth / 2,
minY: stepY - stepHeight / 2,
maxY: stepY + stepHeight / 2,
minZ: stepZ - stepDepth / 2,
maxZ: stepZ + stepDepth / 2,
isCar: false,
isStair: true
});
}
ok = true;
}
}
}
// Trees in city
const treeCount = terrain === 'city_dense' ? 20 + Math.floor(Math.random() * 15) : 30 + Math.floor(Math.random() * 20);
for (let i = 0; i < treeCount; i++) {
const x = (Math.random() - 0.5) * MAP_SIZE * 0.85;
const z = (Math.random() - 0.5) * MAP_SIZE * 0.85;
if (Math.abs(x) < 40 && Math.abs(z) < 40) continue;
let overlap = false;
for (const obs of obstacles) {
if (Math.abs(x - obs.position.x) < 5 + obs.width / 2 &&
Math.abs(z - obs.position.z) < 5 + obs.depth / 2) { overlap = true; break; }
}
if (!overlap) buildTree(x, z);
}
// Cars (many)
const carCount = terrain === 'city_dense' ? 45 + Math.floor(Math.random() * 20) : 25 + Math.floor(Math.random() * 15);
for (let i = 0; i < carCount; i++) {
let ok = false, tries = 0;
while (!ok && tries < 150) {
tries++;
const x = (Math.random() - 0.5) * MAP_SIZE * 0.82;
const z = (Math.random() - 0.5) * MAP_SIZE * 0.82;
if (Math.abs(x) < 40 && Math.abs(z) < 40) continue;
let overlap = false;
for (const obs of obstacles) {
if (Math.abs(x - obs.position.x) < 4 + obs.width / 2 &&
Math.abs(z - obs.position.z) < 3 + obs.depth / 2) { overlap = true; break; }
}
if (!overlap) {
buildBeetleCar(x, z, Math.random() * Math.PI * 2);
ok = true;
}
}
}
// Street lamps
const poleMat = new THREE.MeshLambertMaterial({ color: 0x666666 });
const lampMat = new THREE.MeshLambertMaterial({ color: 0xffee88, emissive: 0xffdd44, emissiveIntensity: 0.15 });
for (let i = 0; i < 30; i++) {
const x = (Math.random() - 0.5) * MAP_SIZE * 0.85;
const z = (Math.random() - 0.5) * MAP_SIZE * 0.85;
if (Math.abs(x) < 40 && Math.abs(z) < 40) continue;
let overlap = false;
for (const obs of obstacles) {
if (Math.abs(x - obs.position.x) < 3 + obs.width / 2 &&
Math.abs(z - obs.position.z) < 3 + obs.depth / 2) { overlap = true; break; }
}
if (overlap) continue;
const pole = new THREE.Mesh(new THREE.CylinderGeometry(0.3, 0.5, 7, 6), poleMat);
pole.position.set(x, 3.5, z);
pole.castShadow = true;
pole.receiveShadow = true;
scene.add(pole);
const arm = new THREE.Mesh(new THREE.BoxGeometry(1.0, 0.15, 0.15), poleMat);
arm.position.set(x + 0.7, 7.2, z);
scene.add(arm);
const lamp = new THREE.Mesh(new THREE.SphereGeometry(0.6, 6, 6), lampMat);
lamp.position.set(x + 1.0, 7.0, z);
scene.add(lamp);
const light = new THREE.PointLight(0xffdd44, 0.2, 15);
light.position.set(x + 1.0, 7.0, z);
scene.add(light);
}
}
// ---- Finalize grid ----
initGrid();
// ---- Apply visual style ----
const vis = config.visual;
// 在 buildTerrain 函数中,原有代码:
// scene.background = new THREE.Color(vis.bg);
// 替换为:
const skyTex = createSkyTexture(waveIdx);
const skyMat = new THREE.MeshBasicMaterial({ map: skyTex, side: THREE.BackSide });
const skyGeo = new THREE.SphereGeometry(4000, 32, 16);
const skyMesh = new THREE.Mesh(skyGeo, skyMat);
// 如果已有旧天空,移除
if (scene._skyMesh) scene.remove(scene._skyMesh);
scene._skyMesh = skyMesh;
scene.add(skyMesh);
// 同时保留背景色作为 fallback(当纹理未加载时)
scene.background = new THREE.Color(vis.bg);
// 确保 ambientLight 存在
if (!scene.ambientLight) {
scene.ambientLight = new THREE.AmbientLight(0xffffff, 0.5);
}
scene.ambientLight.color.setHex(vis.ambient);
}
// ============================================================
// PLAYER
// ============================================================
function createPlayer() {
const geo = new THREE.BoxGeometry(PLAYER_RADIUS * 2, PLAYER_HEIGHT, PLAYER_RADIUS * 2);
const mat = new THREE.MeshLambertMaterial({ color: 0x4682B4 });
player = new THREE.Mesh(geo, mat);
player.position.set(0, PLAYER_HEIGHT / 2, 0);
scene.add(player);
}
// ============================================================
// ENEMY (WW2 themed: Japanese soldiers)
// ============================================================
// ============================================================
// ENEMY FACE TEXTURES (16x16 Canvas)
// ============================================================
function createFaceTexture(type) {
const size = 16;
const canvas = document.createElement('canvas');
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext('2d');
// 基础肤色 (轴心国军装风格,偏黄)
const skinColor = '#d4a574';
if (type === 'him') {
// HIM: 白眼史蒂夫
ctx.fillStyle = skinColor;
ctx.fillRect(0, 0, size, size);
// 头发 (深棕色)
ctx.fillStyle = '#5a3a1a';
ctx.fillRect(2, 0, 12, 4);
ctx.fillRect(0, 2, 4, 3);
ctx.fillRect(12, 2, 4, 3);
// 眼睛 (全白)
ctx.fillStyle = '#ffffff';
ctx.fillRect(3, 6, 3, 3);
ctx.fillRect(10, 6, 3, 3);
// 嘴巴 (简单线条)
ctx.fillStyle = '#8a6a4a';
ctx.fillRect(5, 12, 6, 1);
} else if (type === 'entity303') {
// 实体303: 黑色面孔,红色发光眼睛
ctx.fillStyle = '#000000';
ctx.fillRect(0, 0, size, size);
// 眼睛 (红色,带发光效果)
ctx.fillStyle = '#ff2200';
ctx.fillRect(3, 5, 3, 4);
ctx.fillRect(10, 5, 3, 4);
// 瞳孔 (更亮的红)
ctx.fillStyle = '#ff4444';
ctx.fillRect(4, 6, 1, 2);
ctx.fillRect(11, 6, 1, 2);
// 嘴巴 (暗红色)
ctx.fillStyle = '#881111';
ctx.fillRect(5, 12, 6, 1);
} else if (type === 'spider') {
// 跳蛛: 模仿现实跳蛛面部
ctx.fillStyle = skinColor;
ctx.fillRect(0, 0, size, size);
// 绒毛/斑纹 (浅色斑点)
ctx.fillStyle = '#c49a6c';
for (let i = 0; i < 20; i++) {
ctx.fillRect(Math.floor(Math.random() * 16), Math.floor(Math.random() * 16), 1, 1);
}
// 两颗标志性大眼 (黑色)
ctx.fillStyle = '#111111';
ctx.fillRect(2, 4, 5, 6);
ctx.fillRect(9, 4, 5, 6);
// 眼睛高光 (白色)
ctx.fillStyle = '#ffffff';
ctx.fillRect(3, 5, 2, 2);
ctx.fillRect(10, 5, 2, 2);
// 小眼睛 (两侧)
ctx.fillStyle = '#222222';
ctx.fillRect(0, 6, 1, 2);
ctx.fillRect(15, 6, 1, 2);
// 小颚 (下方)
ctx.fillStyle = '#8a6a4a';
ctx.fillRect(6, 13, 4, 1);
}
const texture = new THREE.CanvasTexture(canvas);
texture.minFilter = THREE.NearestFilter;
texture.magFilter = THREE.NearestFilter;
return texture;
}
// 预生成三种面部纹理
const FACE_TEXTURES = {
him: createFaceTexture('him'),
entity303: createFaceTexture('entity303'),
spider: createFaceTexture('spider')
};
const FACE_TYPES = ['him', 'entity303', 'spider'];
function createUniformTexture() {
const size = 32;
const canvas = document.createElement('canvas');
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext('2d');
// 基础卡其色
ctx.fillStyle = '#c4a56a';
ctx.fillRect(0, 0, size, size);
// 添加一些纹理噪声(布料颗粒)
for (let i = 0; i < 200; i++) {
const x = Math.floor(Math.random() * size);
const y = Math.floor(Math.random() * size);
const v = 160 + Math.random() * 60;
ctx.fillStyle = `rgb(${v}, ${v-20}, ${v-40})`;
ctx.fillRect(x, y, 1, 1);
}
// 添加一些缝线或口袋暗示(仅在中心区域,对应躯干正面,但每个面都会显示,不过效果还行)
ctx.fillStyle = '#a08050';
// 横向腰带线
for (let i = 0; i < size; i++) {
ctx.fillRect(i, 16, 1, 1);
ctx.fillRect(i, 17, 1, 1);
}
// 口袋方块
ctx.fillStyle = '#b09060';
ctx.fillRect(6, 10, 6, 6);
ctx.fillRect(20, 10, 6, 6);
// 纽扣
ctx.fillStyle = '#6a5a3a';
ctx.fillRect(14, 8, 2, 2);
ctx.fillRect(14, 14, 2, 2);
// 稍微变暗,模拟旧化
// 不做了
const texture = new THREE.CanvasTexture(canvas);
texture.minFilter = THREE.NearestFilter;
texture.magFilter = THREE.NearestFilter;
texture.wrapS = THREE.RepeatWrapping;
texture.wrapT = THREE.RepeatWrapping;
texture.repeat.set(1, 1); // 每个面显示整个纹理,不重复
return texture;
}
function buildEnemy(x, z) {
const group = new THREE.Group();
group.position.set(x, ENEMY_HEIGHT / 2, z);
group.castShadow = true;
group.receiveShadow = true;
const colorPool = [0xffffff, 0x000000, 0x0000ff, 0xff0000];
const mainColor = colorPool[Math.floor(Math.random() * colorPool.length)];
const mainMat = new THREE.MeshLambertMaterial({ color: mainColor });
const scale = 6.5 / 32;
const headSize = [8, 8, 8].map(v => v * scale);
const bodySize = [8, 12, 4].map(v => v * scale);
const armSize = [4, 12, 4].map(v => v * scale);
const legSize = [7, 12, 7].map(v => v * scale);
const shoeSize = [4, 4, 4].map(v => v * scale);
// ---- 随机选择面部纹理 ----
const faceType = FACE_TYPES[Math.floor(Math.random() * FACE_TYPES.length)];
const faceTexture = FACE_TEXTURES[faceType];
const skinMat = new THREE.MeshLambertMaterial({ color: 0xd4a574 }); // 皮肤色,与纹理底色一致
const faceMat = new THREE.MeshLambertMaterial({ map: faceTexture });
// 材质数组:顺序 +x, -x, +y, -y, +z, -z
const headMaterials = [
skinMat, // +x
skinMat, // -x
skinMat, // +y
skinMat, // -y
faceMat, // +z (正面)
skinMat // -z
];
const head = new THREE.Mesh(new THREE.BoxGeometry(...headSize), headMaterials);
head.position.set(0, 6.5 - headSize[1] / 2, 0);
head.castShadow = true;
head.receiveShadow = true;
group.add(head);
const uniformMat = new THREE.MeshLambertMaterial({ map: createUniformTexture() });
const body = new THREE.Mesh(new THREE.BoxGeometry(...bodySize), uniformMat);
body.position.set(0, 4.875 - bodySize[1] / 2, 0);
body.castShadow = true;
body.receiveShadow = true;
group.add(body);
const leftArm = new THREE.Mesh(new THREE.BoxGeometry(...armSize), uniformMat);
leftArm.position.set(-(bodySize[0] / 2 + armSize[0] / 2), 4.875 - armSize[1] / 2, 0);
leftArm.castShadow = true;
leftArm.receiveShadow = true;
group.add(leftArm);
const rightArm = new THREE.Mesh(new THREE.BoxGeometry(...armSize), uniformMat);
rightArm.position.set((bodySize[0] / 2 + armSize[0] / 2), 4.875 - armSize[1] / 2, 0);
rightArm.castShadow = true;
rightArm.receiveShadow = true;
group.add(rightArm);
const leftLeg = new THREE.Mesh(new THREE.BoxGeometry(...legSize), uniformMat);
leftLeg.position.set(-0.65, legSize[1] / 2, 0);
leftLeg.castShadow = true;
leftLeg.receiveShadow = true;
group.add(leftLeg);
const rightLeg = new THREE.Mesh(new THREE.BoxGeometry(...legSize), uniformMat);
rightLeg.position.set(0.65, legSize[1] / 2, 0);
rightLeg.castShadow = true;
rightLeg.receiveShadow = true;
group.add(rightLeg);
// 对于鞋子,我们可以使用深色材质
const bootMat = new THREE.MeshLambertMaterial({ color: 0x3a2a1a }); // 棕色靴子
const leftShoe = new THREE.Mesh(new THREE.BoxGeometry(...shoeSize), bootMat);
leftShoe.position.set(-0.65, 0.25, 0);
leftShoe.castShadow = true;
leftShoe.receiveShadow = true;
group.add(leftShoe);
const rightShoe = new THREE.Mesh(new THREE.BoxGeometry(...shoeSize), bootMat);
rightShoe.position.set(0.65, 0.25, 0);
rightShoe.castShadow = true;
rightShoe.receiveShadow = true;
group.add(rightShoe);
// 随机二战枪模
const ww2GunMap = {
'LeeEnfield': createLeeEnfield,
'Thompson': createThompson,
'M1Garand': createM1Garand,
'STG44': createSTG44,
'Colt1911': createColt1911
};
const gunNames = Object.keys(ww2GunMap);
const chosenName = gunNames[Math.floor(Math.random() * gunNames.length)];
const gunModel = ww2GunMap[chosenName]();
gunModel.scale.set(3, 2, 2); // 与原版缩放一致
gunModel.rotation.y = -Math.PI / 2; // 与原版旋转一致
const rightArmPos = rightArm.position.clone();
gunModel.position.set(rightArmPos.x + 0.5, rightArmPos.y - 0.1, 0.6);
group.add(gunModel);
// 视野锥(调试用,默认隐藏)
const visionCone = new THREE.Mesh(
new THREE.ConeGeometry(ENEMY_VISION_RANGE * Math.tan(ENEMY_VISION_ANGLE / 2), ENEMY_VISION_RANGE, 8),
new THREE.MeshBasicMaterial({ color: 0xff0000, transparent: true, opacity: 0.12, side: THREE.DoubleSide })
);
visionCone.rotation.x = Math.PI / 2;
visionCone.position.y = ENEMY_HEIGHT / 2;
visionCone.visible = false;
group.add(visionCone);
// 血条
const canvas = document.createElement('canvas');
canvas.width = 64;
canvas.height = 10;
const texture = new THREE.CanvasTexture(canvas);
const spriteMat = new THREE.SpriteMaterial({ map: texture, depthTest: false, depthWrite: false });
const healthBarSprite = new THREE.Sprite(spriteMat);
healthBarSprite.position.set(0, ENEMY_HEIGHT + 3.0, 0);
healthBarSprite.scale.set(5, 0.9, 1);
group.add(healthBarSprite);
// 返回敌人对象
const enemy = {
group: group,
position: new THREE.Vector3(x, ENEMY_HEIGHT / 2, z),
velocity: new THREE.Vector3(0, 0, 0),
velY: 0,
onGround: true,
rotationY: Math.random() * Math.PI * 2,
health: 100,
maxHealth: 100,
isAlive: true,
state: 'patrol',
detection: 0,
lastKnownPos: null,
stateTime: 0,
targetPos: new THREE.Vector3(x + (Math.random() - 0.5) * 100, ENEMY_HEIGHT / 2, z + (Math.random() - 0.5) * 100),
patrolCenter: new THREE.Vector3(x, ENEMY_HEIGHT / 2, z),
speed: 7,
runSpeed: 10,
attackRange: 0,
attackDamage: 8,
attackCooldown: 1,
lastAttack: 0,
lastRanged: 0,
rangedRange: 500,
healthCanvas: canvas,
healthTex: texture,
healthSpr: healthBarSprite,
visionCone: visionCone,
path: [],
pathIndex: 0,
pathTimer: 0,
pathUpdateInterval: 0.5,
lastPathUpdate: 0,
prevPosition: new THREE.Vector3(x, ENEMY_HEIGHT / 2, z),
// 在 buildEnemy 返回对象中增加
stuck: false, // 是否处于脱困状态
escapeDirection: null, // 当前逃逸方向向量 (THREE.Vector3)
escapeTarget: null, // 当前目标点(用于判断到达与否)
escapeTimer: 0, // 当前方向已用时间(秒)
animationTime: 0,
stopDistance: 20,
head: head,
body: body,
leftArm: leftArm,
rightArm: rightArm,
leftLeg: leftLeg,
rightLeg: rightLeg,
leftShoe: leftShoe,
rightShoe: rightShoe,
gun: gunModel,
mainMat: mainMat,
mainColor: mainColor,
aabb: aabbFromPosSize(x, ENEMY_HEIGHT / 2, z, ENEMY_RADIUS * 2, ENEMY_HEIGHT, ENEMY_RADIUS * 2),
isShooting: false,
shootAnimTime: 0,
gunRecoil: 0,
muzzleFlash: null,
};
updateEnemyBar(enemy);
return enemy;
}
function updateEnemyBar(e) {
const c = e.healthCanvas;
const ctx = c.getContext('2d');
ctx.clearRect(0, 0, c.width, c.height);
ctx.fillStyle = '#550000';
ctx.fillRect(0, 0, c.width, c.height);
const pct = Math.max(0, e.health / e.maxHealth);
const w = Math.max(0, (c.width - 2) * pct);
const grad = ctx.createLinearGradient(0, 0, w, 0);
grad.addColorStop(0, '#00aa00');
grad.addColorStop(1, '#44ff44');
ctx.fillStyle = grad;
ctx.fillRect(1, 1, w, c.height - 2);
e.healthTex.needsUpdate = true;
e.healthSpr.visible = e.isAlive;
}
function spawnEnemiesForWave(count) {
const placed = [];
for (let i = 0; i < count; i++) {
let ok = false,
tries = 0;
while (!ok && tries < 800) {
tries++;
const x = (Math.random() - 0.5) * MAP_SIZE * 0.8;
const z = (Math.random() - 0.5) * MAP_SIZE * 0.8;
if (Math.abs(x) < 40 && Math.abs(z) < 40) continue;
let overlap = false;
for (const p of placed) {
if (Math.abs(x - p.x) < ENEMY_RADIUS * 5 && Math.abs(z - p.z) < ENEMY_RADIUS * 5) { overlap =
true; break; }
}
if (!overlap) {
const e = buildEnemy(x, z);
scene.add(e.group);
enemies.push(e);
placed.push({ x, z });
ok = true;
}
}
}
waveTotalEnemies = enemies.length;
enemiesSpawned = enemies.length;
waveKills = 0;
updateHUD();
}
// ============================================================
// PLAYER MOVEMENT
// ============================================================
function updatePlayer(delta) {
const clamped = Math.min(delta, MAX_DELTA);
const sub = clamped / SUBSTEPS;
for (let s = 0; s < SUBSTEPS; s++) {
playerVel.y -= GRAVITY * sub;
const fwd = new THREE.Vector3(0, 0, -1).applyAxisAngle(new THREE.Vector3(0, 1, 0), mouseX);
const right = new THREE.Vector3(1, 0, 0).applyAxisAngle(new THREE.Vector3(0, 1, 0), mouseX);
const move = new THREE.Vector3();
if (moveF) move.add(fwd);
if (moveB) move.add(fwd.clone().multiplyScalar(-1));
if (moveL) move.add(right.clone().multiplyScalar(-1));
if (moveR) move.add(right);
if (move.length() > 0) {
const speed = isShift ? PLAYER_SPEED * RUN_MULT : PLAYER_SPEED;
move.normalize().multiplyScalar(speed * sub);
const oldX = player.position.x;
const oldZ = player.position.z;
player.position.x += move.x;
player.position.z += move.z;
resolvePlayerAABB(oldX, oldZ);
}
player.position.y += playerVel.y * sub;
resolvePlayerVerticalAABB();
}
const margin = 15;
player.position.x = THREE.MathUtils.clamp(player.position.x, -HALF + margin, HALF - margin);
player.position.z = THREE.MathUtils.clamp(player.position.z, -HALF + margin, HALF - margin);
camera.position.copy(player.position);
camera.position.y += 2;
camera.rotation.set(mouseY, mouseX, 0, 'YXZ');
}
function resolvePlayerAABB(oldX, oldZ) {
const pa = aabbFromPlayer();
for (const obs of obstacles) {
const oa = aabbFromObstacle(obs);
if (!(pa.minY < oa.maxY && pa.maxY > oa.minY)) continue;
if (aabbOverlap(pa, oa)) {
player.position.x = oldX;
player.position.z = oldZ;
return;
}
}
}
function resolvePlayerVerticalAABB() {
const pa = aabbFromPlayer();
let landed = false;
if (pa.minY <= 0 && playerVel.y <= 0) {
player.position.y = PLAYER_HEIGHT / 2;
playerVel.y = 0;
canJump = true;
isJumping = false;
onGround = true;
landed = true;
}
for (const obs of obstacles) {
const oa = aabbFromObstacle(obs);
const hOverlap = pa.minX < oa.maxX && pa.maxX > oa.minX &&
pa.minZ < oa.maxZ && pa.maxZ > oa.minZ;
if (hOverlap) {
const bottom = pa.minY;
const top = oa.maxY;
if (bottom <= top + 0.5 && bottom >= top - 0.5 && playerVel.y <= 0) {
player.position.y = top + PLAYER_HEIGHT / 2;
playerVel.y = 0;
canJump = true;
isJumping = false;
onGround = true;
landed = true;
break;
}
}
}
for (const obs of obstacles) {
const oa = aabbFromObstacle(obs);
const hOverlap = pa.minX < oa.maxX && pa.maxX > oa.minX &&
pa.minZ < oa.maxZ && pa.maxZ > oa.minZ;
if (hOverlap) {
const head = pa.maxY;
const bottom = oa.minY;
if (head >= bottom - 0.5 && head <= bottom + 0.5 && playerVel.y > 0) {
player.position.y = bottom - PLAYER_HEIGHT / 2 - 0.1;
playerVel.y = 0;
}
}
}
if (!landed && playerVel.y <= 0) {
const pa2 = aabbFromPlayer();
if (pa2.minY <= 0.05) {
player.position.y = PLAYER_HEIGHT / 2;
playerVel.y = 0;
canJump = true;
isJumping = false;
onGround = true;
} else {
onGround = false;
}
}
}
// ============================================================
// SHOOTING
// ============================================================
// ============================================================
// AUDIO SYSTEM (Web Audio)
// ============================================================
let sharedAudioCtx = null;
function getAudioContext() {
if (!sharedAudioCtx) {
sharedAudioCtx = new (window.AudioContext || window.webkitAudioContext)();
}
if (sharedAudioCtx.state === 'suspended') {
sharedAudioCtx.resume();
}
return sharedAudioCtx;
}
// ---------- 爆炸/重击音效(你提供的) ----------
function playExplosionSynth(volume = 0.7) {
try {
const ctx = getAudioContext();
const totalTime = 0.7;
const now = ctx.currentTime;
const VOL_IMPACT = 0.85;
const VOL_CRACKLE = 0.65;
const SEND_REVERB = 0.25;
const masterGain = ctx.createGain();
masterGain.gain.value = volume;
masterGain.connect(ctx.destination);
const convolver = ctx.createConvolver();
const impulseLen = ctx.sampleRate * 0.3;
const impulseBuf = ctx.createBuffer(1, impulseLen, ctx.sampleRate);
const impData = impulseBuf.getChannelData(0);
for (let i = 0; i < impulseLen; i++) {
impData[i] = (Math.random() * 2 - 1) * Math.exp(-i / (impulseLen * 0.15));
}
convolver.buffer = impulseBuf;
const reverbSend = ctx.createGain();
reverbSend.gain.value = SEND_REVERB;
reverbSend.connect(convolver);
convolver.connect(masterGain);
const oscImpact = ctx.createOscillator();
oscImpact.type = 'sawtooth';
oscImpact.frequency.setValueAtTime(130, now);
oscImpact.frequency.exponentialRampToValueAtTime(28, now + 0.12);
const gainImpact = ctx.createGain();
gainImpact.gain.setValueAtTime(0, now);
gainImpact.gain.linearRampToValueAtTime(VOL_IMPACT, now + 0.005);
gainImpact.gain.exponentialRampToValueAtTime(0.001, now + 0.22);
oscImpact.connect(gainImpact);
gainImpact.connect(masterGain);
gainImpact.connect(reverbSend);
const noiseBufSize = ctx.sampleRate * totalTime;
const noiseBuf = ctx.createBuffer(1, noiseBufSize, ctx.sampleRate);
const nData = noiseBuf.getChannelData(0);
for (let i = 0; i < noiseBufSize; i++) {
const t = i / noiseBufSize;
const env = Math.pow(Math.max(0, 1 - t / totalTime), 1.5);
nData[i] = (Math.random() * 2 - 1) * env;
}
const noiseSrc = ctx.createBufferSource();
noiseSrc.buffer = noiseBuf;
const noiseGain = ctx.createGain();
noiseGain.gain.value = VOL_CRACKLE;
const filterHigh = ctx.createBiquadFilter();
filterHigh.type = 'bandpass';
filterHigh.frequency.setValueAtTime(2400, now);
filterHigh.frequency.exponentialRampToValueAtTime(400, now + 0.45);
filterHigh.Q.value = 2.0;
noiseSrc.connect(filterHigh);
filterHigh.connect(noiseGain);
noiseGain.connect(masterGain);
noiseGain.connect(reverbSend);
oscImpact.start(now);
oscImpact.stop(now + 0.25);
noiseSrc.start(now);
noiseSrc.stop(now + totalTime);
console.log('💥爆炸音效');
} catch (e) {
console.warn('爆炸音效合成失败', e);
}
}
// ---------- 轻量级枪声 ----------
function playGunshotSound(volume = 0.25, pitch = 200) {
try {
const ctx = getAudioContext();
const now = ctx.currentTime;
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = 'sawtooth';
osc.frequency.setValueAtTime(pitch, now);
osc.frequency.exponentialRampToValueAtTime(pitch * 0.25, now + 0.08);
gain.gain.setValueAtTime(volume, now);
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.06);
osc.connect(gain);
gain.connect(ctx.destination);
osc.start(now);
osc.stop(now + 0.08);
} catch (e) {
// 静默失败
}
}
function playHitSound(volume = 0.15) {
try {
const ctx = getAudioContext();
const now = ctx.currentTime;
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = 'square';
osc.frequency.setValueAtTime(900, now);
osc.frequency.exponentialRampToValueAtTime(500, now + 0.04);
gain.gain.setValueAtTime(volume, now);
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.05);
osc.connect(gain);
gain.connect(ctx.destination);
osc.start(now);
osc.stop(now + 0.05);
} catch (e) {}
}
function playPickupSound(volume = 0.12) {
try {
const ctx = getAudioContext();
const now = ctx.currentTime;
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = 'sine';
osc.frequency.setValueAtTime(500, now);
osc.frequency.exponentialRampToValueAtTime(1000, now + 0.1);
gain.gain.setValueAtTime(volume, now);
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.12);
osc.connect(gain);
gain.connect(ctx.destination);
osc.start(now);
osc.stop(now + 0.12);
} catch (e) {}
}
function playReloadSound(volume = 0.1) {
try {
const ctx = getAudioContext();
const now = ctx.currentTime;
// 两个短促的"咔嗒"声模拟装弹
for (let i = 0; i < 2; i++) {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = 'square';
osc.frequency.setValueAtTime(300 + i * 150, now + i * 0.15);
gain.gain.setValueAtTime(volume * (1 - i * 0.3), now + i * 0.15);
gain.gain.exponentialRampToValueAtTime(0.001, now + i * 0.15 + 0.03);
osc.connect(gain);
gain.connect(ctx.destination);
osc.start(now + i * 0.15);
osc.stop(now + i * 0.15 + 0.03);
}
} catch (e) {}
}
function shoot() {
const def = getCurrentDef();
const state = getCurrentState();
const now = Date.now();
const interval = 60 / def.fireRate * 1000;
if (now - lastShot < interval) return;
if (state.currentAmmo <= 0) {
if (state.totalAmmo > 0 && !state.isReloading) startReload();
return;
}
if (def.needsBolt && !state.boltReady) { showBoltHint(); return; }
if (!infAmmo) state.currentAmmo--;
lastShot = now;
playExplosionSynth(1);
// 连发武器后坐力更小,半自动/栓动武器后坐力较大
const recoilScale = def.auto ? 0.35 : 1.0;
// 水平后坐力(左右随机晃动)
const recoilAngle = (Math.random() - 0.5) * 0.2 * recoilScale;
mouseX += recoilAngle;
// 垂直后坐力(上跳)
const recoilY = (Math.random() - 0.5) * 0.02 * (def.auto ? 0.5 : 1.0);
mouseY += recoilY;
mouseY = THREE.MathUtils.clamp(mouseY, -Math.PI / 2, Math.PI / 2);
if (def.needsBolt) {
state.boltReady = false;
showBoltHint();
}
// 射线起点和方向
const origin = camera.position.clone();
const dir = new THREE.Vector3();
camera.getWorldDirection(dir);
if (def.spread > 0) {
dir.x += (Math.random() - 0.5) * def.spread;
dir.y += (Math.random() - 0.5) * def.spread;
dir.z += (Math.random() - 0.5) * def.spread;
dir.normalize();
}
const MAX_DIST = def.range || 1000;
// ---- 预筛选阈值(点线距离) ----
const PROXIMITY_THRESHOLD = 20; // 可根据需要调整
// 1. 寻找最近的敌人(先粗筛,再精确 Slab 检测)
let closestEnemy = null;
let closestEnemyDist = Infinity;
for (const e of enemies) {
if (!e.isAlive) continue;
// 快速粗筛:计算敌人中心到射线的距离
const toEnemy = new THREE.Vector3().subVectors(e.position, origin);
const projLength = toEnemy.dot(dir);
if (projLength < 0 || projLength > MAX_DIST) continue; // 在射线后方或超出射程
// 垂直距离 = |toEnemy × dir|
const perpDist = new THREE.Vector3().crossVectors(toEnemy, dir).length();
if (perpDist > PROXIMITY_THRESHOLD) continue; // 离射线太远,跳过
// 粗筛通过,执行精确 AABB Slab 检测
const ea = aabbFromEnemy(e);
const dist = getRayAABBDist(origin, dir, ea);
if (dist > 0 && dist < closestEnemyDist && dist <= MAX_DIST) {
closestEnemy = e;
closestEnemyDist = dist;
}
}
// 2. 检测障碍物阻挡(同样先粗筛?这里保留原逻辑,因为障碍物数量通常较少,直接检测)
let blocked = false;
let hitPoint = null;
let hitObstacle = false;
for (const obs of obstacles) {
// 粗略判断障碍物是否在子弹路径附近(例如:距离射线起点 80 单位以内)
const dx = obs.position.x - origin.x;
const dz = obs.position.z - origin.z;
if (dx*dx + dz*dz > 10000) continue; // 跳过太远的障碍物
const oa = aabbFromObstacle(obs);
const dist = getRayAABBDist(origin, dir, oa);
if (dist > 0 && dist < closestEnemyDist) {
blocked = true;
hitObstacle = true;
hitPoint = origin.clone().add(dir.clone().multiplyScalar(dist));
break;
}
}
// 3. 处理命中
if (blocked) {
shotsFired++;
createHitEffect(hitPoint, 0x888888);
notifyGunshot(origin);
updateHUD();
updateWeaponUI();
return;
}
if (closestEnemy) {
shotsHit++;
const hitPoint = origin.clone().add(dir.clone().multiplyScalar(closestEnemyDist));
let dmg = def.damage;
if (hitPoint.y > closestEnemy.position.y + ENEMY_HEIGHT * 0.65) dmg *= 2;
closestEnemy.health -= dmg;
updateEnemyBar(closestEnemy);
showHitMarker();
createHitEffect(hitPoint, 0xffaa00);
if (closestEnemy.health <= 0) {
killEnemy(closestEnemy);
} else {
closestEnemy.state = 'chase';
closestEnemy.detection = 1.0;
closestEnemy.lastKnownPos = player.position.clone();
closestEnemy.stateTime = 0;
}
notifyGunshot(origin);
} else {
const farPoint = origin.clone().add(dir.clone().multiplyScalar(MAX_DIST));
createHitEffect(farPoint, 0xcccccc);
notifyGunshot(origin);
}
shotsFired++;
updateHUD();
updateWeaponUI();
if (def.auto && mouseDown && state.currentAmmo > 0 && !state.isReloading && !gameOver) {
setTimeout(() => {
if (mouseDown && !state.isReloading && !gameOver) shoot();
}, interval);
}
}
let particles = []; // 活跃碎片数组
function createHitParticles(position, color, count = 30, speed = 5, life = 1.5) {
// 使用 Points 方式,生成粒子位置和颜色
const positions = new Float32Array(count * 3);
const colors = new Float32Array(count * 3);
const velocities = [];
const baseColor = new THREE.Color(color);
for (let i = 0; i < count; i++) {
// 位置从中心偏移,随机方向
const theta = Math.random() * Math.PI * 2;
const phi = Math.random() * Math.PI * 2;
const spd = speed * (0.3 + Math.random() * 0.7);
const vx = Math.sin(theta) * Math.cos(phi) * spd;
const vy = Math.sin(phi) * spd * 0.5 + 1.0; // 轻微上冲
const vz = Math.cos(theta) * Math.cos(phi) * spd;
positions[i*3] = position.x + (Math.random() - 0.5) * 0.5;
positions[i*3+1] = position.y + (Math.random() - 0.5) * 0.5;
positions[i*3+2] = position.z + (Math.random() - 0.5) * 0.5;
// 颜色轻微随机
const c = baseColor.clone().multiplyScalar(0.6 + Math.random() * 0.4);
colors[i*3] = c.r;
colors[i*3+1] = c.g;
colors[i*3+2] = c.b;
velocities.push({ x: vx, y: vy, z: vz });
}
const geometry = new THREE.BufferGeometry();
geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3));
const material = new THREE.PointsMaterial({
size: 0.8,
vertexColors: true,
transparent: true,
opacity: 1.0,
blending: THREE.AdditiveBlending,
depthWrite: false,
sizeAttenuation: true
});
const points = new THREE.Points(geometry, material);
scene.add(points);
// 存储粒子数据
const particleData = {
points: points,
velocities: velocities,
age: 0,
life: life * (0.6 + Math.random() * 0.4),
isAlive: true
};
particles.push(particleData);
}function updateParticles(delta) {
const clamped = Math.min(delta, 0.05);
for (let i = particles.length - 1; i >= 0; i--) {
const p = particles[i];
if (!p.isAlive) {
// 清理
scene.remove(p.points);
p.points.geometry.dispose();
p.points.material.dispose();
particles.splice(i, 1);
continue;
}
p.age += clamped;
if (p.age > p.life) {
p.isAlive = false;
continue;
}
// 更新位置
const pos = p.points.geometry.attributes.position.array;
const vel = p.velocities;
for (let j = 0; j < vel.length; j++) {
pos[j*3] += vel[j].x * clamped;
pos[j*3+1] += vel[j].y * clamped;
pos[j*3+2] += vel[j].z * clamped;
// 简单空气阻力
vel[j].x *= 0.99;
vel[j].y *= 0.99;
vel[j].z *= 0.99;
}
p.points.geometry.attributes.position.needsUpdate = true;
// 逐渐缩小和淡出
const lifeRatio = p.age / p.life;
p.points.material.opacity = 1.0 - lifeRatio;
p.points.material.size = 0.8 * (1.0 - lifeRatio * 0.7);
}
}
// ============================================================
// ENEMY SHOOT
// ============================================================
function enemyShoot(enemy) {
if (!enemy.isAlive) return;
const now = Date.now();
const cooldown = 1200 + Math.random() * 800;
if (now - enemy.lastRanged < cooldown) return;
const dir = new THREE.Vector3().subVectors(player.position, enemy.position).normalize();
const spread = 0.08;
const ddistToPlayer = enemy.position.distanceTo(player.position);
const volume = 0.2 + 0.4 * Math.min(1, ddistToPlayer / 200);
playExplosionSynth(volume);
dir.x += (Math.random() - 0.5) * spread;
dir.y += (Math.random() - 0.5) * spread;
dir.z += (Math.random() - 0.5) * spread;
dir.normalize();
const start = enemy.position.clone();
start.y += ENEMY_HEIGHT * 0.7;
const pa = aabbFromPlayer();
const distToPlayer = getRayAABBDist(start, dir, pa);
if (!isFinite(distToPlayer) || distToPlayer <= 0) {
enemy.lastRanged = now;
triggerEnemyShootAnim(enemy);
return;
}
let blocked = false;
for (const obs of obstacles) {
const oa = aabbFromObstacle(obs);
if (!rayAABBIntersect(start, dir, oa)) continue;
const hitDist = getRayAABBDist(start, dir, oa);
if (hitDist > 0.5 && hitDist < distToPlayer) { blocked = true; break; }
}
if (blocked) {
enemy.lastRanged = now;
triggerEnemyShootAnim(enemy);
return;
}
if (!godMode) {
playerHP -= 4;
updateHUD();
showDamage();
const angle = (Math.random() - 0.5) * Math.PI / 3;
mouseX += angle;
mouseY += (Math.random() - 0.5) * 0.15;
mouseY = THREE.MathUtils.clamp(mouseY, -Math.PI / 2, Math.PI / 2);
if (playerHP <= 0) { playerHP = 0;
endGame(); }
}
createHitEffect(player.position.clone().add(new THREE.Vector3(0, PLAYER_HEIGHT / 2, 0)), 0xff4444);
enemy.lastRanged = now;
triggerEnemyShootAnim(enemy);
}
function triggerEnemyShootAnim(enemy) {
if (!enemy.isAlive) return;
enemy.isShooting = true;
enemy.shootAnimTime = 0;
enemy.gunRecoil = 0.6;
if (enemy.muzzleFlash) { scene.remove(enemy.muzzleFlash); }
const flashGeo = new THREE.SphereGeometry(0.5, 6, 6);
const flashMat = new THREE.MeshBasicMaterial({ color: 0xff8800, transparent: true, opacity: 1.0 });
const flash = new THREE.Mesh(flashGeo, flashMat);
const gunPos = enemy.gun.position.clone();
gunPos.x += 2.0;
gunPos.y += 0.2;
flash.position.copy(gunPos);
flash.position.x += 1.0;
enemy.group.add(flash);
enemy.muzzleFlash = flash;
setTimeout(() => {
if (enemy.muzzleFlash) { scene.remove(enemy.muzzleFlash);
enemy.muzzleFlash = null; }
}, 150);
const particleCount = 12;
const pGeo = new THREE.BufferGeometry();
const pPos = new Float32Array(particleCount * 3);
for (let i = 0; i < particleCount; i++) {
const gp = gunPos.clone();
gp.x += 1.0 + (Math.random() - 0.5) * 0.6;
gp.y += (Math.random() - 0.5) * 0.6;
gp.z += (Math.random() - 0.5) * 0.6;
pPos[i * 3] = gp.x;
pPos[i * 3 + 1] = gp.y;
pPos[i * 3 + 2] = gp.z;
}
pGeo.setAttribute('position', new THREE.BufferAttribute(pPos, 3));
const pMat = new THREE.PointsMaterial({ color: 0xff8800, size: 0.25, transparent: true, opacity: 0.9,
blending: THREE.AdditiveBlending });
const particles = new THREE.Points(pGeo, pMat);
enemy.group.add(particles);
let age = 0;
const life = 0.3;
const updParticles = () => {
age += 0.016;
if (age < life) {
const posAttr = particles.geometry.attributes.position;
const arr = posAttr.array;
for (let i = 0; i < arr.length; i += 3) {
arr[i] += (Math.random() - 0.5) * 0.4;
arr[i + 1] += (Math.random() - 0.5) * 0.4 + 0.2;
arr[i + 2] += (Math.random() - 0.5) * 0.4;
}
posAttr.needsUpdate = true;
particles.material.opacity = 0.9 * (1 - age / life);
requestAnimationFrame(updParticles);
} else { enemy.group.remove(particles); }
};
updParticles();
}
function rayAABBIntersect(origin, dir, aabb) {
const invDir = new THREE.Vector3(1 / dir.x, 1 / dir.y, 1 / dir.z);
let t1 = (aabb.minX - origin.x) * invDir.x;
let t2 = (aabb.maxX - origin.x) * invDir.x;
let tmin = Math.min(t1, t2);
let tmax = Math.max(t1, t2);
t1 = (aabb.minY - origin.y) * invDir.y;
t2 = (aabb.maxY - origin.y) * invDir.y;
tmin = Math.max(tmin, Math.min(t1, t2));
tmax = Math.min(tmax, Math.max(t1, t2));
t1 = (aabb.minZ - origin.z) * invDir.z;
t2 = (aabb.maxZ - origin.z) * invDir.z;
tmin = Math.max(tmin, Math.min(t1, t2));
tmax = Math.min(tmax, Math.max(t1, t2));
return tmax >= tmin && tmax > 0;
}
// ============================================================
// ENEMY AI
// ============================================================
function updateEnemies(delta) {
const now = Date.now();
let playerOnBuilding = false;
let stairTarget = null;
const playerFeet = player.position.y - PLAYER_HEIGHT / 2;
for (const obs of obstacles) {
if (obs.height > 10) {
const oa = aabbFromObstacle(obs);
if (player.position.x > oa.minX && player.position.x < oa.maxX &&
player.position.z > oa.minZ && player.position.z < oa.maxZ &&
playerFeet > oa.maxY - 2 && playerFeet < oa.maxY + 2) {
playerOnBuilding = true;
let bestStair = null;
for (const stair of obstacles) {
if (stair.isStair) {
const dx = stair.position.x - obs.position.x;
const dz = stair.position.z - obs.position.z;
const halfW = obs.width / 2 + 8;
const halfD = obs.depth / 2 + 8;
if (Math.abs(dx) < halfW && Math.abs(dz) < halfD) {
if (!bestStair || stair.position.y > bestStair.position.y) { bestStair = stair; }
}
}
}
if (bestStair) { stairTarget = bestStair.position.clone();
stairTarget.y += ENEMY_HEIGHT / 2; }
break;
}
}
}
for (const e of enemies) {
if (!e.isAlive) continue;
// ============================================================
// 脱困检测:如果当前位置不可通行,进入随机移动模式
// ============================================================
const gPos = worldToGrid(e.position.x, e.position.z);
const walkable = isWalkable(gPos.gx, gPos.gz);
if (!walkable) {
e.stuck = true;
e.escapeTimer += delta;
// 每10秒 或 到达目标点 → 重新选择方向
if (!e.escapeDirection || e.escapeTimer >= 5.0 ||
(e.escapeTarget && e.position.distanceTo(e.escapeTarget) < 5)) {
const angle = Math.random() * Math.PI * 2;
const dist = 30 + Math.random() * 80;
const dir = new THREE.Vector3(Math.cos(angle), 0, Math.sin(angle));
e.escapeDirection = dir.clone();
e.escapeTarget = e.position.clone().add(dir.clone().multiplyScalar(dist));
// 限制目标在地图边界内
e.escapeTarget.x = THREE.MathUtils.clamp(e.escapeTarget.x, -HALF + 20, HALF - 20);
e.escapeTarget.z = THREE.MathUtils.clamp(e.escapeTarget.z, -HALF + 20, HALF - 20);
e.escapeTimer = 0;
}
// 向逃逸目标直线移动
const dirToTarget = new THREE.Vector3().subVectors(e.escapeTarget, e.position);
if (dirToTarget.length() > 0.5) {
dirToTarget.normalize();
const speed = e.speed * 1.2; // 略快于步行
e.position.x += dirToTarget.x * speed * delta;
e.position.z += dirToTarget.z * speed * delta;
}
// 边界限制
e.position.x = THREE.MathUtils.clamp(e.position.x, -HALF + 20, HALF - 20);
e.position.z = THREE.MathUtils.clamp(e.position.z, -HALF + 20, HALF - 20);
// 重力与垂直碰撞
e.velY -= GRAVITY * delta;
e.position.y += e.velY * delta;
if (e.position.y - ENEMY_HEIGHT / 2 <= 0) {
e.position.y = ENEMY_HEIGHT / 2;
e.velY = 0;
}
// 碰撞解析(防止卡墙)
resolveEnemyAABB(e);
// 旋转朝向移动方向
if (e.escapeDirection && e.escapeDirection.length() > 0.01) {
e.rotationY = Math.atan2(e.escapeDirection.x, e.escapeDirection.z);
e.group.rotation.y = e.rotationY;
}
// 更新组位置和包围盒
e.group.position.copy(e.position);
e.aabb = aabbFromEnemy(e);
// 跳过后续所有正常 AI 逻辑(状态机、射击、寻路等)
continue;
} else {
// 如果之前 stuck,现在可通行了,恢复正常
if (e.stuck) {
e.stuck = false;
e.escapeDirection = null;
e.escapeTarget = null;
e.escapeTimer = 0;
e.path = []; // 清空旧路径,强制重新规划
e.lastPathUpdate = 0;
}
}
// ============================================================
// 以下是原有的状态机和寻路逻辑(保持不变)
// ============================================================
e.stateTime += delta;
if (e.isShooting) {
e.shootAnimTime += delta;
e.gunRecoil *= 0.92;
if (e.gunRecoil < 0.01) e.gunRecoil = 0;
if (e.shootAnimTime > 0.4) { e.isShooting = false; e.gunRecoil = 0; }
if (e.gun) e.gun.rotation.x = -e.gunRecoil * 0.5;
} else {
if (e.gun) { e.gun.rotation.x *= 0.9; if (Math.abs(e.gun.rotation.x) < 0.005) e.gun.rotation.x = 0; }
}
const distToPlayer = e.position.distanceTo(player.position);
if (distToPlayer < 900) {
if (e.state !== 'chase') { e.state = 'chase'; e.detection = 1.0; e.stateTime = 0; }
e.lastKnownPos = player.position.clone();
} else {
const det = detectPlayer(e);
if (det.detected && det.type === 'sight') {
e.state = 'chase';
e.detection = 1.0;
e.lastKnownPos = player.position.clone();
e.stateTime = 0;
notifyNearby(e, player.position.clone(), 200);
} else if (det.detected && det.type === 'sound') {
if (e.state === 'patrol') { e.state = 'search'; e.lastKnownPos = player.position.clone(); e.stateTime = 0; }
}
}
switch (e.state) {
case 'patrol':
const d2 = e.position.distanceTo(e.targetPos);
if (d2 < 10 || e.stateTime > 8) pickNewTarget(e);
break;
case 'chase':
if (distToPlayer >= 200) {
e.detection -= delta / ENEMY_LOST_TIME;
if (e.detection <= 0) { e.state = 'patrol'; e.detection = 0; e.lastKnownPos = null; e.stateTime = 0; }
else if (e.stateTime > ENEMY_LOST_TIME) { e.state = 'search'; e.stateTime = 0; }
} else { e.detection = 1.0; e.stateTime = 0; }
break;
case 'search':
if (distToPlayer < 200) { e.state = 'chase'; e.detection = 1.0; e.lastKnownPos = player.position.clone(); e.stateTime = 0; }
else if (e.lastKnownPos) {
e.targetPos.copy(e.lastKnownPos);
if (e.position.distanceTo(e.lastKnownPos) < 15 || e.stateTime > 5) { e.state = 'patrol'; e.lastKnownPos = null; e.stateTime = 0; }
} else { e.state = 'patrol'; e.stateTime = 0; }
break;
}
let target;
if (e.state === 'chase') {
if (playerOnBuilding && stairTarget) {
const distToStair = e.position.distanceTo(stairTarget);
if (distToStair > 5) { target = stairTarget.clone(); e.useDirectMove = false; }
else { target = player.position.clone(); e.useDirectMove = true; e.path = []; }
} else { target = e.lastKnownPos || player.position; e.useDirectMove = false; }
} else if (e.state === 'search') { target = e.lastKnownPos || e.targetPos; e.useDirectMove = false; }
else { target = e.targetPos; e.useDirectMove = false; }
if (e.state === 'chase' && distToPlayer < e.stopDistance) {
enemyShoot(e);
const dirToPlayer = new THREE.Vector3().subVectors(player.position, e.position);
if (dirToPlayer.length() > 0.01) { e.rotationY = Math.atan2(dirToPlayer.x, dirToPlayer.z); e.group.rotation.y = e.rotationY; }
e.group.position.copy(e.position);
e.aabb = aabbFromEnemy(e);
for (let i = 0; i < enemies.length; i++) {
const a = enemies[i];
if (!a.isAlive) continue;
for (let j = i + 1; j < enemies.length; j++) {
const b = enemies[j];
if (!b.isAlive) continue;
const aabbA = aabbFromEnemy(a);
const aabbB = aabbFromEnemy(b);
if (aabbOverlap(aabbA, aabbB)) {
const overlapX = Math.min(aabbA.maxX, aabbB.maxX) - Math.max(aabbA.minX, aabbB.minX);
const overlapZ = Math.min(aabbA.maxZ, aabbB.maxZ) - Math.max(aabbA.minZ, aabbB.minZ);
if (overlapX < overlapZ) {
const sign = (a.position.x < b.position.x) ? -1 : 1;
const push = sign * overlapX / 2;
a.position.x += push;
b.position.x -= push;
} else {
const sign = (a.position.z < b.position.z) ? -1 : 1;
const push = sign * overlapZ / 2;
a.position.z += push;
b.position.z -= push;
}
resolveEnemyAABB(a);
resolveEnemyAABB(b);
a.aabb = aabbFromEnemy(a);
b.aabb = aabbFromEnemy(b);
}
}
}
continue;
}
if (!e.useDirectMove && (e.state === 'chase' || e.state === 'search')) {
if (now - e.lastPathUpdate > e.pathUpdateInterval * 1000 || !e.path || e.path.length === 0) {
const path = calculateEnemyPath(e, target);
if (path && path.length > 0) { e.path = path; e.pathIndex = 0; }
else { e.path = []; }
e.lastPathUpdate = now;
}
} else { e.path = []; }
if (e.path && e.path.length > 0) {
let waypoint = e.path[e.pathIndex];
if (!waypoint) waypoint = { wx: target.x, wz: target.z };
const dx = waypoint.wx - e.position.x;
const dz = waypoint.wz - e.position.z;
const distToWaypoint = Math.sqrt(dx * dx + dz * dz);
if (distToWaypoint < 3) {
e.pathIndex++;
if (e.pathIndex >= e.path.length) {
const dirToTarget = new THREE.Vector3().subVectors(target, e.position);
if (dirToTarget.length() > 0.1) {
const speed = e.state === 'chase' ? e.runSpeed : e.speed;
const move = dirToTarget.normalize().multiplyScalar(speed * delta);
e.position.x += move.x;
e.position.z += move.z;
}
e.path = [];
}
} else {
const dirToWaypoint = new THREE.Vector3(waypoint.wx - e.position.x, 0, waypoint.wz - e.position.z).normalize();
const speed = e.state === 'chase' ? e.runSpeed : e.speed;
const move = dirToWaypoint.clone().multiplyScalar(speed * delta);
e.position.x += move.x;
e.position.z += move.z;
}
} else {
const dirToTarget = new THREE.Vector3().subVectors(target, e.position);
if (dirToTarget.length() > 0.1) {
const speed = e.state === 'chase' ? e.runSpeed : e.speed;
const move = dirToTarget.normalize().multiplyScalar(speed * delta);
e.position.x += move.x;
e.position.z += move.z;
}
}
resolveEnemyAABB(e);
e.velY -= GRAVITY * delta;
e.position.y += e.velY * delta;
if (e.position.y - ENEMY_HEIGHT / 2 <= 0) { e.position.y = ENEMY_HEIGHT / 2; e.velY = 0; e.onGround = true; }
else { e.onGround = false; }
for (const obs of obstacles) {
const oa = aabbFromObstacle(obs);
const ea = aabbFromEnemy(e);
if (aabbOverlap(ea, oa)) {
if (e.position.y - ENEMY_HEIGHT / 2 <= obs.maxY + 0.5 &&
e.position.y - ENEMY_HEIGHT / 2 >= obs.maxY - 0.5 && e.velY <= 0) {
e.position.y = obs.maxY + ENEMY_HEIGHT / 2;
e.velY = 0;
e.onGround = true;
} else {
const dx = Math.abs(e.position.x - obs.position.x);
const dz = Math.abs(e.position.z - obs.position.z);
const ox = ENEMY_RADIUS + obs.width / 2 - dx;
const oz = ENEMY_RADIUS + obs.depth / 2 - dz;
if (ox < oz) {
if (e.position.x < obs.position.x) e.position.x = obs.minX - ENEMY_RADIUS;
else e.position.x = obs.maxX + ENEMY_RADIUS;
} else {
if (e.position.z < obs.position.z) e.position.z = obs.minZ - ENEMY_RADIUS;
else e.position.z = obs.maxZ + ENEMY_RADIUS;
}
}
}
}
const m = 20;
e.position.x = THREE.MathUtils.clamp(e.position.x, -HALF + m, HALF - m);
e.position.z = THREE.MathUtils.clamp(e.position.z, -HALF + m, HALF - m);
const dirToTarget3 = new THREE.Vector3().subVectors(target, e.position);
if (dirToTarget3.length() > 0.01) { e.rotationY = Math.atan2(dirToTarget3.x, dirToTarget3.z); e.group.rotation.y = e.rotationY; }
e.group.position.copy(e.position);
const currentPos = e.position.clone();
const sspeed = currentPos.distanceTo(e.prevPosition || currentPos) / (delta || 0.001);
e.prevPosition = currentPos.clone();
updateParticles(delta);
updateEnemyAnimation(e, sspeed, delta);
if (e.state === 'chase' && distToPlayer < 50) { enemyShoot(e); }
e.aabb = aabbFromEnemy(e);
}
const alive = enemies.filter(e => e.isAlive).length;
document.getElementById('enemyCount').textContent = alive;
}
function updateEnemyAnimation(enemy, speed, delta) {
if (!enemy.isAlive) return;
const isMoving = speed > 0.5;
if (isMoving) {
enemy.animationTime = (enemy.animationTime || 0) + delta * speed * 2;
const swing = Math.sin(enemy.animationTime) * 0.8;
if (enemy.leftArm) { enemy.leftArm.rotation.x = swing * 0.8; }
if (enemy.rightArm) { enemy.rightArm.rotation.x = -swing * 0.8; }
if (enemy.leftLeg) { enemy.leftLeg.rotation.x = -swing * 0.6; }
if (enemy.rightLeg) { enemy.rightLeg.rotation.x = swing * 0.6; }
} else {
if (enemy.leftArm) enemy.leftArm.rotation.x = 0.5;
if (enemy.rightArm) enemy.rightArm.rotation.x = -0.5;
if (enemy.leftLeg) enemy.leftLeg.rotation.x = 0.1;
if (enemy.rightLeg) enemy.rightLeg.rotation.x = -0.1;
}
}
function resolveEnemyAABB(e) {
const ea = aabbFromEnemy(e);
for (const obs of obstacles) {
const oa = aabbFromObstacle(obs);
if (aabbOverlap(ea, oa)) {
const dx = Math.abs(e.position.x - obs.position.x);
const dz = Math.abs(e.position.z - obs.position.z);
const ox = ENEMY_RADIUS + obs.width / 2 - dx;
const oz = ENEMY_RADIUS + obs.depth / 2 - dz;
if (ox < oz) {
if (e.position.x < obs.position.x) e.position.x = obs.minX - ENEMY_RADIUS;
else e.position.x = obs.maxX + ENEMY_RADIUS;
} else {
if (e.position.z < obs.position.z) e.position.z = obs.minZ - ENEMY_RADIUS;
else e.position.z = obs.maxZ + ENEMY_RADIUS;
}
const newEa = aabbFromEnemy(e);
if (aabbOverlap(newEa, oa)) {
if (e.position.x < obs.position.x) e.position.x = obs.minX - ENEMY_RADIUS;
else e.position.x = obs.maxX + ENEMY_RADIUS;
if (e.position.z < obs.position.z) e.position.z = obs.minZ - ENEMY_RADIUS;
else e.position.z = obs.maxZ + ENEMY_RADIUS;
}
}
}
}
function detectPlayer(e) {
const p = player.position;
const dist = e.position.distanceTo(p);
if (dist > ENEMY_VISION_RANGE) return { detected: false };
const dir = new THREE.Vector3().subVectors(p, e.position).normalize();
const fwd = new THREE.Vector3(0, 0, 1).applyAxisAngle(new THREE.Vector3(0, 1, 0), e.rotationY);
const angle = fwd.angleTo(dir);
if (angle < ENEMY_VISION_ANGLE / 2) {
if (!lineOfSightBlocked(e.position, p)) { return { detected: true, type: 'sight' }; }
}
if (dist < ENEMY_HEARING && isPlayerMoving()) { return { detected: true, type: 'sound' }; }
return { detected: false };
}
function lineOfSightBlocked(start, end) {
const dir = new THREE.Vector3().subVectors(end, start).normalize();
const dist = start.distanceTo(end);
for (const obs of obstacles) {
const oa = aabbFromObstacle(obs);
if (rayAABBIntersect(start, dir, oa)) {
const hitDist = getRayAABBDist(start, dir, oa);
if (hitDist < dist && hitDist > 0.5) return true;
}
}
return false;
}
function getRayAABBDist(origin, dir, aabb) {
const inv = new THREE.Vector3(1 / dir.x, 1 / dir.y, 1 / dir.z);
let t1 = (aabb.minX - origin.x) * inv.x;
let t2 = (aabb.maxX - origin.x) * inv.x;
let tmin = Math.min(t1, t2);
let tmax = Math.max(t1, t2);
t1 = (aabb.minY - origin.y) * inv.y;
t2 = (aabb.maxY - origin.y) * inv.y;
tmin = Math.max(tmin, Math.min(t1, t2));
tmax = Math.min(tmax, Math.max(t1, t2));
t1 = (aabb.minZ - origin.z) * inv.z;
t2 = (aabb.maxZ - origin.z) * inv.z;
tmin = Math.max(tmin, Math.min(t1, t2));
tmax = Math.min(tmax, Math.max(t1, t2));
if (tmax >= tmin && tmax > 0) return tmin > 0 ? tmin : tmax;
return Infinity;
}
function isPlayerMoving() { return playerVel.length() > 2; }
function notifyNearby(source, pos, range) {
for (const e of enemies) {
if (!e.isAlive || e === source) continue;
if (e.position.distanceTo(pos) < range) { e.state = 'chase';
e.detection = 1.0;
e.lastKnownPos = pos.clone();
e.stateTime = 0; }
}
}
function notifyGunshot(pos) {
for (const e of enemies) {
if (!e.isAlive) continue;
if (e.position.distanceTo(pos) < 200) {
if (e.state !== 'chase') { e.state = 'chase';
e.detection = 1.0;
e.lastKnownPos = pos.clone();
e.stateTime = 0; }
}
}
}
function pickNewTarget(e) {
const angle = Math.random() * Math.PI * 2;
const dist = 50 + Math.random() * ENEMY_PATROL_RADIUS;
e.targetPos.x = e.patrolCenter.x + Math.cos(angle) * dist;
e.targetPos.z = e.patrolCenter.z + Math.sin(angle) * dist;
e.targetPos.y = ENEMY_HEIGHT / 2;
e.targetPos.x = THREE.MathUtils.clamp(e.targetPos.x, -HALF + 40, HALF - 40);
e.targetPos.z = THREE.MathUtils.clamp(e.targetPos.z, -HALF + 40, HALF - 40);
e.stateTime = 0;
}
// ============================================================
// KILL & DROPS
// ============================================================
function killEnemy(e) {
e.isAlive = false;
e.health = 0;
waveKills++;
kills++;
e.healthSpr.visible = false;
if (e.visionCone) e.visionCone.visible = false;
addKillFeed();
updateHUD();
spawnPickup(e.position.clone());
createHitParticles(e.position.clone(), e.mainColor, 10, 7, 20.5);
// 倒地动画
const dir = new THREE.Vector3().subVectors(player.position, e.position);
dir.y = 0;
if (dir.length() > 0.01) {
e.rotationY = Math.atan2(dir.x, dir.z);
e.group.rotation.y = e.rotationY;
e.group.rotation.x = -Math.PI / 2;
} else {
e.group.rotation.x = -Math.PI / 2;
}
e.group.position.y = ENEMY_HEIGHT / 2 - 0.2;
setTimeout(() => {
scene.remove(e.group);
}, 300);
// ---- 延迟3秒检测波次完成 ----
if (!window._waveCheckPending) {
window._waveCheckPending = true;
setTimeout(() => {
window._waveCheckPending = false;
const alive = enemies.filter(en => en.isAlive).length;
const total = waveTotalEnemies;
const killed = total - alive;
const ratio = total > 0 ? killed / total : 0;
if (ratio >= 7 / 8 && !waveComplete) {
waveComplete = true;
if (currentWave < 3) {
showWaveTransition(currentWave + 1);
} else {
gameVictory = true;
document.getElementById('gameOverTitle').textContent = '🏆 VICTORY!';
document.getElementById('gameOver').style.display = 'block';
document.exitPointerLock();
}
}
}, 3000);
}
}
function spawnPickup(pos) {
const rand = Math.random();
let type, data = {};
if (rand < 0.25) {
const locked = WEAPON_IDS.filter(id => !unlockedWeapons.includes(id));
if (locked.length > 0) {
const id = locked[Math.floor(Math.random() * locked.length)];
type = 'weapon';
data = { weaponId: id };
} else { type = 'ammo';
data = { amount: 20 }; }
} else if (rand < 0.65) { type = 'health';
data = { amount: 30 }; } else if (rand < 1.0) {
type = 'ammo';
const def = getCurrentDef();
data = { amount: Math.ceil(def.maxAmmo * 0.5), weaponId: currentWeaponId };
} else { return; }
const pickupGroup = new THREE.Group();
pickupGroup.position.copy(pos);
pickupGroup.position.y += 0.5;
let color, label;
if (type === 'weapon') {
const def = WEAPON_DEFS[data.weaponId];
color = 0x00ff88;
const modelMap = {
lee_enfield: createLeeEnfield,
thompson: createThompson,
m1garand: createM1Garand,
stg44: createSTG44,
colt1911: createColt1911
};
const model = modelMap[data.weaponId]();
model.scale.set(1.5, 1.5, 2.0);
model.rotation.y = Math.random() * Math.PI * 2;
pickupGroup.add(model);
label = def.displayName;
} else if (type === 'health') {
color = 0xff4444;
const cross = new THREE.Mesh(new THREE.BoxGeometry(1.2, 0.3, 0.3), new THREE
.MeshLambertMaterial({ color: 0xff4444 }));
const cross2 = new THREE.Mesh(new THREE.BoxGeometry(0.3, 1.2, 0.3), new THREE
.MeshLambertMaterial({ color: 0xff4444 }));
pickupGroup.add(cross);
pickupGroup.add(cross2);
const ring = new THREE.Mesh(new THREE.RingGeometry(0.8, 1.2, 16),
new THREE.MeshBasicMaterial({ color: 0xff4444, transparent: true, opacity: 0.4, side: THREE
.DoubleSide }));
ring.rotation.x = -Math.PI / 2;
ring.position.y = -0.2;
pickupGroup.add(ring);
label = 'Medkit +' + data.amount;
} else if (type === 'ammo') {
color = 0xffdd44;
const box = new THREE.Mesh(new THREE.BoxGeometry(0.8, 0.5, 0.6),
new THREE.MeshLambertMaterial({ color: 0xccaa44 }));
pickupGroup.add(box);
const bullet = new THREE.Mesh(new THREE.CylinderGeometry(0.08, 0.08, 0.3, 6),
new THREE.MeshLambertMaterial({ color: 0xffdd44 }));
bullet.rotation.x = Math.PI / 2;
bullet.position.set(0, 0.3, 0);
pickupGroup.add(bullet);
label = 'Ammo +' + data.amount;
}
const glowGeo = new THREE.SphereGeometry(0.6, 8, 8);
const glowMat = new THREE.MeshBasicMaterial({ color: color, transparent: true, opacity: 0.25 });
const glow = new THREE.Mesh(glowGeo, glowMat);
pickupGroup.add(glow);
const light = new THREE.PointLight(color, 0.5, 8);
light.position.set(0, 1, 0);
pickupGroup.add(light);
scene.add(pickupGroup);
const pickup = {
id: pickupIdCounter++,
group: pickupGroup,
position: pos.clone(),
type: type,
data: data,
label: label,
color: color,
alive: true,
bobPhase: Math.random() * Math.PI * 2,
};
pickups.push(pickup);
showPickupMsg('📦 Dropped: ' + label + ' (Press E)');
}
function updatePickups(delta) {
const playerPos = player.position;
const pickupDist = 8;
let nearest = null;
let nearestDist = pickupDist;
for (const p of pickups) {
if (!p.alive) continue;
p.bobPhase += delta * 1.5;
p.group.position.y = p.position.y + 0.5 + Math.sin(p.bobPhase) * 0.3;
p.group.rotation.y += delta * 0.5;
if (p.group.children.length > 0) {
const glow = p.group.children.find(c => c.isMesh && c.material && c.material.opacity !== undefined &&
c.material.opacity < 0.5);
if (glow) { glow.material.opacity = 0.2 + Math.sin(p.bobPhase) * 0.1; }
}
const dist = playerPos.distanceTo(p.position);
if (dist < pickupDist) { if (dist < nearestDist) { nearest = p;
nearestDist = dist; } }
}
const prompt = document.getElementById('pickupPrompt');
if (nearest) {
prompt.style.display = 'block';
prompt.textContent = 'Press E to pickup ' + nearest.label;
if (nearest.group.children.length > 0) {
const glow = nearest.group.children.find(c => c.isMesh && c.material && c.material.opacity !==
undefined && c.material.opacity < 0.5);
if (glow) { glow.material.opacity = 0.5; }
}
} else { prompt.style.display = 'none'; }
window._nearestPickup = nearest;
}
function doPickup() {
const nearest = window._nearestPickup;
if (!nearest || !nearest.alive) return;
const p = nearest;
p.alive = false;
if (p.type === 'weapon') {
const id = p.data.weaponId;
if (!unlockedWeapons.includes(id)) {
unlockedWeapons.push(id);
const def = WEAPON_DEFS[id];
const state = weaponStates[id];
state.currentAmmo = def.maxAmmo;
state.totalAmmo = def.totalAmmo;
state.boltReady = true;
showPickupMsg('🎯 Unlocked: ' + def.displayName + '!');
} else {
const def = WEAPON_DEFS[id];
const state = weaponStates[id];
state.totalAmmo += def.maxAmmo * 2;
showPickupMsg('📦 ' + def.displayName + ' ammo');
}
} else if (p.type === 'health') {
const heal = p.data.amount || 30;
playerHP = Math.min(maxHP, playerHP + 80);
updateHUD();
showPickupMsg('❤️ +' + heal + ' HP (' + Math.round(playerHP) + '/' + maxHP + ')');
} else if (p.type === 'ammo') {
const amt = p.data.amount || 10;
if (p.data.weaponId) {
const state = weaponStates[p.data.weaponId];
state.totalAmmo += amt;
showPickupMsg('📦 +' + amt + ' ' + WEAPON_DEFS[p.data.weaponId].name + ' ammo');
} else { addAmmoForAll();
showPickupMsg('📦 All weapons ammo'); }
updateHUD();
updateWeaponUI();
}
scene.remove(p.group);
const idx = pickups.indexOf(p);
if (idx > -1) pickups.splice(idx, 1);
document.getElementById('pickupPrompt').style.display = 'none';
window._nearestPickup = null;
createPickupEffect(p.position, p.color, p.label);
}
function addAmmoForAll() {
for (const id of WEAPON_IDS) {
const def = WEAPON_DEFS[id];
const state = weaponStates[id];
state.totalAmmo += def.maxAmmo * 2;
}
}
function createPickupEffect(pos, color, label) {
const count = 40;
const geo = new THREE.BufferGeometry();
const positions = new Float32Array(count * 3);
const colors = new Float32Array(count * 3);
for (let i = 0; i < count; i++) {
positions[i * 3] = pos.x + (Math.random() - 0.5) * 4;
positions[i * 3 + 1] = pos.y + (Math.random() - 0.5) * 4 + 1;
positions[i * 3 + 2] = pos.z + (Math.random() - 0.5) * 4;
const c = new THREE.Color(color);
c.multiplyScalar(0.6 + Math.random() * 0.4);
colors[i * 3] = c.r;
colors[i * 3 + 1] = c.g;
colors[i * 3 + 2] = c.b;
}
geo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
geo.setAttribute('color', new THREE.BufferAttribute(colors, 3));
const mat = new THREE.PointsMaterial({ size: 0.8, vertexColors: true, transparent: true, opacity: 0.9,
blending: THREE.AdditiveBlending });
const points = new THREE.Points(geo, mat);
scene.add(points);
let age = 0;
const life = 1.0;
const upd = () => {
age += 0.016;
if (age < life) {
const posAttr = points.geometry.attributes.position;
const arr = posAttr.array;
for (let i = 0; i < arr.length; i += 3) {
arr[i] += (Math.random() - 0.5) * 0.2;
arr[i + 1] += (Math.random() - 0.5) * 0.2 + 0.1;
arr[i + 2] += (Math.random() - 0.5) * 0.2;
}
posAttr.needsUpdate = true;
points.material.opacity = 0.9 * (1 - age / life);
requestAnimationFrame(upd);
} else { scene.remove(points); }
};
upd();
}
function showPickupMsg(msg) {
const el = document.getElementById('pickupMsg');
el.textContent = msg;
el.style.display = 'block';
el.style.opacity = '1';
clearTimeout(el._timeout);
el._timeout = setTimeout(() => {
el.style.opacity = '0';
setTimeout(() => { el.style.display = 'none'; }, 300);
}, 2500);
}
// ============================================================
// RELOAD & BOLT
// ============================================================
function startReload() {
const def = getCurrentDef();
const state = getCurrentState();
if (state.isReloading || state.currentAmmo === def.maxAmmo || state.totalAmmo === 0) return;
state.isReloading = true;
state.reloadTimer = Date.now();
document.getElementById('reloadIndicator').style.display = 'block';
updateWeaponUI();
setTimeout(() => {
if (!state.isReloading) return;
const need = def.maxAmmo - state.currentAmmo;
const avail = infAmmo ? 9999 : state.totalAmmo;
const toLoad = Math.min(need, avail);
if (!infAmmo) state.totalAmmo -= toLoad;
state.currentAmmo += toLoad;
if (def.needsBolt) state.boltReady = true;
state.isReloading = false;
document.getElementById('reloadIndicator').style.display = 'none';
updateWeaponUI();
updateHUD();
}, def.reloadTime * 1000);
}
let boltAnim = null; // { startTime, duration, weaponId }
function doBoltAction() {
const def = getCurrentDef();
const state = getCurrentState();
// 只有在需要拉栓、未就绪、未换弹、未在动画中、未结束游戏时执行
if (def.needsBolt && !state.boltReady && !state.isReloading && !gameOver && !boltAnim) {
// 播放拉栓音效
playReloadSound(0.08);
// 启动动画
boltAnim = {
startTime: Date.now(),
duration: 1000,
weaponId: currentWeaponId
};
// 更新UI提示
const boltEl = document.getElementById('boltIndicator');
boltEl.textContent = '⏳ Bolting...';
boltEl.style.display = 'block';
// 状态未就绪(已在条件中确认)
state.boltReady = false;
updateWeaponUI();
}
}
function showBoltHint() {
const def = getCurrentDef();
if (def.needsBolt && !getCurrentState().boltReady) {
document.getElementById('boltIndicator').style.display = 'block';
}
}
// ============================================================
// WEAPON SWITCH
// ============================================================
function switchWeapon(direction) {
if (gameOver) return;
const currentIdx = WEAPON_IDS.indexOf(currentWeaponId);
let newIdx = (currentIdx + direction + WEAPON_IDS.length) % WEAPON_IDS.length;
let tries = 0;
while (!unlockedWeapons.includes(WEAPON_IDS[newIdx]) && tries < WEAPON_IDS.length) {
newIdx = (newIdx + direction + WEAPON_IDS.length) % WEAPON_IDS.length;
tries++;
}
if (!unlockedWeapons.includes(WEAPON_IDS[newIdx])) { showPickupMsg('🔒 Weapon not unlocked'); return; }
const newId = WEAPON_IDS[newIdx];
if (newId === currentWeaponId) return;
const oldState = getCurrentState();
if (oldState.isReloading) { oldState.isReloading = false;
document.getElementById('reloadIndicator').style.display = 'none'; }
currentWeaponId = newId;
const def = getCurrentDef();
const state = getCurrentState();
if (!infAmmo) {
const need = def.maxAmmo - state.currentAmmo;
const avail = state.totalAmmo;
const toLoad = Math.min(need, avail);
state.currentAmmo += toLoad;
state.totalAmmo -= toLoad;
} else { state.currentAmmo = def.maxAmmo; }
state.boltReady = true;
state.isReloading = false;
document.getElementById('reloadIndicator').style.display = 'none';
document.getElementById('boltIndicator').style.display = 'none';
for (const id of WEAPON_IDS) {
if (weaponModels[id]) weaponModels[id].visible = (id === currentWeaponId);
}
updateHUD();
updateWeaponUI();
showPickupMsg('🔫 Switched to ' + def.displayName);
}
function onWheel(e) {
e.preventDefault();
if (!isLocked) return;
const direction = e.deltaY > 0 ? 1 : -1;
switchWeapon(direction);
}
// ============================================================
// UI HELPERS
// ============================================================
// ============================================================
// RADAR SYSTEM
// ============================================================
const radarCanvas = document.getElementById('radarCanvas');
const radarCtx = radarCanvas.getContext('2d');
const RADAR_SIZE = 540; // 与 CSS 尺寸一致
radarCanvas.width = RADAR_SIZE;
radarCanvas.height = RADAR_SIZE;
const RADAR_CENTER = RADAR_SIZE / 2;
const RADAR_MAX_RADIUS = RADAR_SIZE / 2 - 6;
const RADAR_MAX_DIST = 250; // 雷达显示的最大距离(单位:游戏世界单位)
const RADAR_ROTATION = 180; // 顺时针旋转角度(度),0=前方在顶部,90=右侧,180=底部,270=左侧
function updateRadar() {
radarCtx.clearRect(0, 0, RADAR_SIZE, RADAR_SIZE);
const cx = RADAR_CENTER;
const cy = RADAR_CENTER;
const radius = RADAR_MAX_RADIUS;
// ---- 绘制背景圆 ----
radarCtx.beginPath();
radarCtx.arc(cx, cy, radius, 0, Math.PI * 2);
radarCtx.fillStyle = 'rgba(0,0,0,0.6)';
radarCtx.fill();
radarCtx.strokeStyle = 'rgba(255,255,255,0.3)';
radarCtx.lineWidth = 1.5;
radarCtx.stroke();
// ---- 绘制距离环(50, 150, 250 单位) ----
const rings = [50, 150, 250];
for (const r of rings) {
const ringRadius = (r / RADAR_MAX_DIST) * radius;
radarCtx.beginPath();
radarCtx.arc(cx, cy, ringRadius, 0, Math.PI * 2);
radarCtx.strokeStyle = 'rgba(255,255,255,0.15)';
radarCtx.lineWidth = 1;
radarCtx.stroke();
}
// ---- 绘制方向十字线 ----
radarCtx.beginPath();
radarCtx.moveTo(cx, cy - radius);
radarCtx.lineTo(cx, cy + radius);
radarCtx.moveTo(cx - radius, cy);
radarCtx.lineTo(cx + radius, cy);
radarCtx.strokeStyle = 'rgba(255,255,255,0.1)';
radarCtx.lineWidth = 1;
radarCtx.stroke();
// ---- 玩家朝向箭头(上方) ----
const arrowLen = 12;
const arrowBase = radius - 4;
radarCtx.beginPath();
radarCtx.moveTo(cx, cy - arrowBase);
radarCtx.lineTo(cx - 8, cy - arrowBase + arrowLen);
radarCtx.lineTo(cx + 8, cy - arrowBase + arrowLen);
radarCtx.closePath();
radarCtx.fillStyle = 'rgba(100,255,100,0.8)';
radarCtx.fill();
// 玩家中心点
radarCtx.beginPath();
radarCtx.arc(cx, cy, 34, 0, Math.PI * 2);
radarCtx.fillStyle = '#ffffff';
radarCtx.fill();
// ---- 绘制敌人 ----
const px = player.position.x;
const pz = player.position.z;
const angle = -mouseX; // 玩家朝向角(弧度),看向 -Z 方向
// ---- 绘制敌人 ----
for (const e of enemies) {
if (!e.isAlive) continue;
const dx = e.position.x - px;
const dz = e.position.z - pz;
const dist = Math.sqrt(dx * dx + dz * dz);
if (dist > RADAR_MAX_DIST || dist < 0.5) continue;
const cosA = Math.cos(angle);
const sinA = Math.sin(angle);
const localX = dx * cosA + dz * sinA;
const localZ = -dx * sinA + dz * cosA;
const norm = Math.sqrt(localX * localX + localZ * localZ);
if (norm < 0.01) continue;
const ratio = Math.min(dist / RADAR_MAX_DIST, 1) * (radius - 5) / norm;
// 基础位置(无旋转,前方在顶部)
const baseX = cx + localX * ratio;
const baseY = cy - localZ * ratio;
// 应用整体旋转
const rad = RADAR_ROTATION * Math.PI / 180;
const dx2 = baseX - cx;
const dy2 = baseY - cy;
const cosR = Math.cos(rad);
const sinR = Math.sin(rad);
const screenX = cx - dx2 * cosR + dy2 * sinR;
const screenY = cy - dx2 * sinR + dy2 * cosR;
// 绘制红点
radarCtx.beginPath();
radarCtx.arc(screenX, screenY, 14 + 2 * (1 - Math.min(dist / RADAR_MAX_DIST, 1)), 0, Math.PI * 2);
radarCtx.fillStyle = '#ff4444';
radarCtx.shadowColor = '#ff4444';
radarCtx.shadowBlur = 12;
radarCtx.fill();
radarCtx.shadowBlur = 0;
}
}
function updateHUD() {
const hpPct = Math.max(0, Math.round((playerHP / maxHP) * 100));
const hpEl = document.getElementById('hpText');
hpEl.textContent = hpPct;
hpEl.className = 'hp-fill' + (hpPct < 30 ? ' low' : '');
const def = getCurrentDef();
const state = getCurrentState();
document.getElementById('weaponName').textContent = `${def.icon} ${def.displayName}`;
document.getElementById('ammoText').textContent = `${state.currentAmmo} / ${infAmmo ? '∞' : state.totalAmmo}`;
const alive = enemies.filter(e => e.isAlive).length;
document.getElementById('enemyCount').textContent = alive;
document.getElementById('waveNum').textContent = currentWave + 1;
document.getElementById('waveNum2').textContent = currentWave + 1;
}
function updateWeaponUI() {
const def = getCurrentDef();
const state = getCurrentState();
document.getElementById('weaponName').textContent = `${def.icon} ${def.displayName}`;
document.getElementById('ammoText').textContent = `${state.currentAmmo} / ${infAmmo ? '∞' : state.totalAmmo}`;
if (state.isReloading) { document.getElementById('reloadIndicator').style.display = 'block'; } else { document
.getElementById('reloadIndicator').style.display = 'none'; }
if (def.needsBolt && !state.boltReady && state.currentAmmo > 0) { document.getElementById('boltIndicator')
.style.display = 'block'; } else { document.getElementById('boltIndicator').style.display = 'none'; }
}
function showHitMarker() {
const el = document.getElementById('hitMarker');
el.classList.remove('active');
void el.offsetWidth;
el.classList.add('active');
}
function showDamage() {
const el = document.getElementById('damageOverlay');
el.classList.add('show');
setTimeout(() => el.classList.remove('show'), 150);
}
function addKillFeed() {
const feed = document.getElementById('killFeed');
const entry = document.createElement('div');
entry.className = 'entry';
entry.textContent = '💀 Enemy eliminated';
feed.prepend(entry);
setTimeout(() => { if (entry.parentNode) entry.remove(); }, 3000);
if (feed.children.length > 6) feed.lastChild.remove();
}
function createHitEffect(pos, color) {
const count = 25;
const geo = new THREE.BufferGeometry();
const positions = new Float32Array(count * 3);
const colors = new Float32Array(count * 3);
for (let i = 0; i < count; i++) {
positions[i * 3] = pos.x + (Math.random() - 0.5) * 2;
positions[i * 3 + 1] = pos.y + (Math.random() - 0.5) * 2;
positions[i * 3 + 2] = pos.z + (Math.random() - 0.5) * 2;
const c = new THREE.Color(color);
c.multiplyScalar(0.6 + Math.random() * 0.4);
colors[i * 3] = c.r;
colors[i * 3 + 1] = c.g;
colors[i * 3 + 2] = c.b;
}
geo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
geo.setAttribute('color', new THREE.BufferAttribute(colors, 3));
const mat = new THREE.PointsMaterial({ size: 0.6, vertexColors: true, transparent: true, opacity: 0.9,
blending: THREE.AdditiveBlending });
const points = new THREE.Points(geo, mat);
scene.add(points);
let age = 0;
const life = 0.8;
const upd = () => {
age += 0.016;
if (age < life) {
const posAttr = points.geometry.attributes.position;
const arr = posAttr.array;
for (let i = 0; i < arr.length; i += 3) {
arr[i] += (Math.random() - 0.5) * 0.3;
arr[i + 1] += (Math.random() - 0.5) * 0.3 + 0.05;
arr[i + 2] += (Math.random() - 0.5) * 0.3;
}
posAttr.needsUpdate = true;
points.material.opacity = 0.9 * (1 - age / life);
requestAnimationFrame(upd);
} else { scene.remove(points); }
};
upd();
}
// ============================================================
// WAVE TRANSITION
// ============================================================
function showWaveTransition(nextWaveIdx) {
const config = WAVE_CONFIG[nextWaveIdx];
const el = document.getElementById('waveTransition');
document.getElementById('waveTitle').textContent = '🌊 Wave ' + (nextWaveIdx + 1);
document.getElementById('waveDesc').textContent = config.desc;
document.getElementById('waveSub').textContent = 'Kill 7/8 to advance';
el.style.display = 'block';
setTimeout(() => {
el.style.display = 'none';
startWave(nextWaveIdx);
}, 2500);
}
function startWave(waveIdx) {
currentWave = waveIdx;
waveComplete = false;
waveKills = 0;
// Clear old enemies, pickups, bullets
for (const e of enemies) { scene.remove(e.group); }
enemies = [];
for (const p of pickups) { scene.remove(p.group); }
pickups = [];
for (const b of bullets) { scene.remove(b.mesh); if (b.trail) scene.remove(b.trail); }
bullets = [];
// Clear obstacles and terrain meshes
for (const obs of obstacles) {
if (obs.mesh) scene.remove(obs.mesh);
}
obstacles = [];
for (const t of treeMeshes) { scene.remove(t); }
treeMeshes = [];
trees = [];
grid = [];
// Reset player position
player.position.set(0, PLAYER_HEIGHT / 2, 0);
playerVel.set(0, 0, 0);
mouseX = 0;
mouseY = 0;
canJump = true;
isJumping = false;
onGround = false;
// Build terrain
buildTerrain(waveIdx);
// Spawn enemies
spawnEnemiesForWave(WAVE_CONFIG[waveIdx].enemyCount);
// Update HUD
updateHUD();
updateWeaponUI();
// Reset weapon states for ammo balance
for (const id of WEAPON_IDS) {
const def = WEAPON_DEFS[id];
const state = weaponStates[id];
if (unlockedWeapons.includes(id)) {
state.currentAmmo = def.maxAmmo;
state.totalAmmo = def.totalAmmo;
state.boltReady = true;
state.isReloading = false;
}
}
document.getElementById('reloadIndicator').style.display = 'none';
document.getElementById('boltIndicator').style.display = 'none';
// Show wave message
// 清理粒子
for (const p of particles) {
scene.remove(p.mesh);
p.mesh.geometry.dispose();
p.mesh.material.dispose();
}
particles = [];
showPickupMsg('🌊 Wave ' + (waveIdx + 1) + ' started!');
gameStart = Date.now();
}
// ============================================================
// GAME OVER / RESTART
// ============================================================
function endGame() {
if (gameOver) return;
gameOver = true;
document.exitPointerLock();
const surv = Math.floor((Date.now() - gameStart) / 1000);
const acc = shotsFired > 0 ? Math.round((shotsHit / shotsFired) * 100) : 0;
document.getElementById('survivedTime').textContent = surv;
document.getElementById('killsCount').textContent = kills;
document.getElementById('finalAcc').textContent = acc + '%';
if (!gameVictory) {
document.getElementById('gameOverTitle').textContent = '💀 GAME OVER';
}
document.getElementById('gameOver').style.display = 'block';
}
function restart() {
gameOver = false;
gameVictory = false;
document.getElementById('gameOver').style.display = 'none';
// 恢复血量
playerHP = maxHP;
// 重置玩家位置到原点
player.position.set(0, PLAYER_HEIGHT / 2, 0);
playerVel.set(0, 0, 0);
// 重置视角
mouseX = 0;
mouseY = 0;
// 重置跳跃状态
canJump = true;
isJumping = false;
onGround = false;
// 更新HUD
updateHUD();
// 重新锁定鼠标?
// 不需要,因为游戏结束已经退出锁定了,但用户需要点击屏幕重新锁定?当前逻辑:游戏结束界面出现,点击restart按钮后,需要重新锁定鼠标?通常点击restart后,应自动锁定鼠标(如果浏览器允许)或让用户点击屏幕锁定。我们可以在restart后调用 renderer.domElement.requestPointerLock()? 但可能被浏览器阻止,所以最好让用户点击屏幕。
// 但为了体验,我们可以尝试请求锁定。
// 更安全:不自动锁定,用户点击屏幕后会锁定。
// 可以保留。
// 另外,需要重置游戏开始时间?可能不需要。
// 还要确保敌人AI继续运行,因为gameOver为false了。
// 显示复活提示。
showPickupMsg('💪 已复活!');
}
// ============================================================
// EVENTS
// ============================================================
function onKey(e) {
switch (e.code) {
case 'KeyW':
moveF = true;
break;
case 'KeyA':
moveL = true;
break;
case 'KeyS':
moveB = true;
break;
case 'KeyD':
moveR = true;
break;
case 'ShiftLeft':
case 'ShiftRight':
isShift = true;
break;
case 'Space':
e.preventDefault();
if (canJump && !gameOver) {
const force = 20;
playerVel.y = force;
canJump = false;
isJumping = true;
onGround = false;
}
break;
case 'KeyR':
if (!gameOver) startReload();
break;
case 'KeyG':
if (!gameOver) doBoltAction();
break;
case 'KeyE':
if (!gameOver) doPickup();
break;
case 'Escape':
document.exitPointerLock();
break;
}
}
function onKeyUp(e) {
switch (e.code) {
case 'KeyW':
moveF = false;
break;
case 'KeyA':
moveL = false;
break;
case 'KeyS':
moveB = false;
break;
case 'KeyD':
moveR = false;
break;
case 'ShiftLeft':
case 'ShiftRight':
isShift = false;
break;
}
}
// 新函数:基于鼠标位置(相对于屏幕中心)控制视角
function onMouseMove(e) {
// 计算鼠标在屏幕上的位置偏移(相对于中心)
const centerX = window.innerWidth / 2;
const centerY = window.innerHeight / 2;
const deltaX = (e.clientX - centerX) * MOUSE_SENS;
const deltaY = (e.clientY - centerY) * MOUSE_SENS;
// 累加到视角(注意方向)
mouseX += deltaX;
mouseY += -deltaY; // 注意负号,因为 Y 轴向上
mouseY = THREE.MathUtils.clamp(mouseY, -Math.PI / 2, Math.PI / 2);
}
function onMouseDown(e) {
if (e.button !== 0) return;
if (!isLocked) { // 先让画布获得焦点(某些浏览器需要)
renderer.domElement.focus();
document.body.requestPointerLock(); }
if (gameOver) return;
mouseDown = true;
const state = getCurrentState();
if (state.isReloading) return;
if (state.currentAmmo <= 0) { if (state.totalAmmo > 0) startReload(); return; }
shoot();
}
function onMouseUp(e) { if (e.button === 0) mouseDown = false; }
function onResize() {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
}
// ============================================================
// INIT
// ============================================================
function init() {
scene = new THREE.Scene();
camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 10000);
camera.position.set(0, 12, 0);
renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
renderer.shadowMap.bias = 0.0001;
document.getElementById('gameContainer').appendChild(renderer.domElement);
const ambient = new THREE.AmbientLight(0xffffff, 0.5);
scene.add(ambient);
const hemi = new THREE.HemisphereLight(0x87CEEB, 0x3a7c3a, 0.6);
scene.add(hemi);
const dirLight = new THREE.DirectionalLight(0xffffff, 1.0);
dirLight.position.set(80, 150, 60);
dirLight.castShadow = true;
dirLight.shadow.camera.left = -200;
dirLight.shadow.camera.right = 200;
dirLight.shadow.camera.top = 200;
dirLight.shadow.camera.bottom = -200;
dirLight.shadow.mapSize.width = 4096;
dirLight.shadow.mapSize.height = 4096;
scene.add(dirLight);
const fillLight = new THREE.DirectionalLight(0xffeedd, 0.3);
fillLight.position.set(-60, 80, -40);
scene.add(fillLight);
createPlayer();
// Weapon display
weaponDisplay = new THREE.Group();
scene.add(weaponDisplay);
const modelMap = {
lee_enfield: createLeeEnfield,
thompson: createThompson,
m1garand: createM1Garand,
stg44: createSTG44,
colt1911: createColt1911
};
for (const id of WEAPON_IDS) {
const model = modelMap[id]();
// 添加手部
const handMat = new THREE.MeshLambertMaterial({ color: 0xd4a574 });
model.rotation.y = Math.PI / 2;
const skinMat = new THREE.MeshLambertMaterial({ color: 0xd4a574 });
const sleeveMat = new THREE.MeshLambertMaterial({ color: 0x2d5a27 }); // 深军绿
// ===== 左手(X↔Z 互换) =====
// 原来: (-2.2, -0.2, 0.6) → 现在: (0.6, -0.2, -2.2)
const leftHand = new THREE.Mesh(new THREE.BoxGeometry(0.8, 0.8, 0.8), skinMat);
leftHand.position.set(0.6, -0.2, -1.2);
leftHand.castShadow = true;
model.add(leftHand);
// ===== 右手(对称互换) =====
// 原来: (2.2, -0.2, 0.6) → 现在: (-0.6, -0.2, 2.2)
const rightHand = new THREE.Mesh(new THREE.BoxGeometry(0.8, 0.8, 0.8), skinMat);
rightHand.position.set(-0.6, -0.2, 1.2);
rightHand.castShadow = true;
model.add(rightHand);
// ===== 左衣袖(跟随左手互换) =====
// 原来: (-2.5, -0.6, 0.6) → 现在: (0.6, -0.6, -2.5)
const leftSleeve = new THREE.Mesh(new THREE.CylinderGeometry(0.5, 0.5, 12, 6), sleeveMat);
leftSleeve.position.set(-5.6, -0.6, -1.5);
leftSleeve.rotation.z = Math.PI / 2;
leftSleeve.castShadow = true;
model.add(leftSleeve);
// ===== 右衣袖(跟随右手互换) =====
// 原来: (2.5, -0.6, 0.6) → 现在: (-0.6, -0.6, 2.5)
const rightSleeve = new THREE.Mesh(new THREE.CylinderGeometry(0.5, 0.5, 12, 6), sleeveMat);
rightSleeve.position.set(-5.6, -0.6, 0.5);
rightSleeve.rotation.z = Math.PI / 2;
rightSleeve.castShadow = true;
model.add(rightSleeve);
model.scale.set(2.3, 2.3, 3.3);
model.visible = false;
weaponDisplay.add(model);
weaponHands[id] = {
leftHand,
rightHand,
leftSleeve,
rightSleeve,
leftHandInitPos: leftHand.position.clone(), // (0.6, -0.2, -2.2)
leftSleeveInitPos: leftSleeve.position.clone() // (0.6, -0.6, -2.5)
};
weaponModels[id] = model;
}
weaponModels.lee_enfield.visible = true;
// Start wave 1
buildTerrain(0);
spawnEnemiesForWave(WAVE_CONFIG[0].enemyCount);
// Events
document.addEventListener('keydown', onKey);
document.addEventListener('keyup', onKeyUp);
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mousedown', onMouseDown);
document.addEventListener('mouseup', onMouseUp);
document.addEventListener('wheel', onWheel, { passive: false });
window.addEventListener('resize', onResize);
document.getElementById('lockOverlay').addEventListener('click', () => {
if (!isLocked) {
renderer.domElement.focus(); // 确保画布焦点
document.body.requestPointerLock();
}
}); document.addEventListener('pointerlockchange', () => {
isLocked = !!document.pointerLockElement;
document.getElementById('lockOverlay').style.display = isLocked ? 'none' : 'block';
if (!isLocked) mouseDown = false;
});
document.getElementById('restartBtn').addEventListener('click', restart);
document.getElementById('loading').classList.add('hidden');
gameStart = Date.now();
updateHUD();
updateWeaponUI();
showPickupMsg('🌊 Wave 1: Jungle');
animate();
}
// ============================================================
// ANIMATION LOOP
// ============================================================
let weaponHands = {};
let lastFootstepTime = 0;
let footstepInterval = 0;
function playFootstepSound(volume = 0.08, pitch = 120) {
try {
const ctx = getAudioContext();
const now = ctx.currentTime;
console.log(1);
// 噪声发生器(模拟踩踏)
const bufferSize = ctx.sampleRate * 0.06; // 60ms
const buffer = ctx.createBuffer(1, bufferSize, ctx.sampleRate);
const data = buffer.getChannelData(0);
for (let i = 0; i < bufferSize; i++) {
const env = Math.exp(-i / (bufferSize * 0.3));
data[i] = (Math.random() * 2 - 1) * env * 0.8;
}
const noise = ctx.createBufferSource();
noise.buffer = buffer;
const gain = ctx.createGain();
gain.gain.setValueAtTime(volume, now);
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.06);
// 低通滤波让声音变闷(更像脚步声)
const filter = ctx.createBiquadFilter();
filter.type = 'lowpass';
filter.frequency.value = 300 + Math.random() * 200;
// 增加一个低频振荡器加强“重踏”感
const osc = ctx.createOscillator();
osc.type = 'sine';
osc.frequency.setValueAtTime(pitch, now);
osc.frequency.exponentialRampToValueAtTime(pitch * 0.5, now + 0.04);
const oscGain = ctx.createGain();
oscGain.gain.setValueAtTime(volume * 0.6, now);
oscGain.gain.exponentialRampToValueAtTime(0.001, now + 0.04);
noise.connect(filter);
filter.connect(gain);
gain.connect(ctx.destination);
osc.connect(oscGain);
oscGain.connect(ctx.destination);
noise.start(now);
noise.stop(now + 0.06);
osc.start(now);
osc.stop(now + 0.04);
} catch (e) {}
}
let prevPlayerPos = new THREE.Vector3();
function animate() {
requestAnimationFrame(animate);
const delta = clock.getDelta();
if (isLocked && !gameOver) {
updatePlayer(delta);
// ---- 脚步声 ----
if (isLocked && !gameOver && onGround) {
// 计算水平速度(通过位置变化)
const pos = player.position;
const dx = pos.x - prevPlayerPos.x;
const dz = pos.z - prevPlayerPos.z;
const speed = Math.sqrt(dx*dx + dz*dz) / (delta || 0.016); // 单位/秒
// 更新上一帧位置
prevPlayerPos.copy(pos);
const isMoving = speed > 0.5;
const isRunning = speed > 8;
if (isMoving) {
const baseInterval = isRunning ? 0.28 : 0.45;
const jitter = 0.8 + Math.random() * 0.4;
const interval = baseInterval * jitter;
const now = Date.now();
if (now - lastFootstepTime > interval * 1000) {
const vol = isRunning ? 0.12 : 0.08;
const pitch = isRunning ? 160 : 120;
playFootstepSound(vol * 3, pitch);
lastFootstepTime = now;
}
} else {
lastFootstepTime = 0; // 停止后重置计时器
}
} updatePickups(delta);
if (weaponDisplay) {
weaponDisplay.quaternion.copy(camera.quaternion);
const right = new THREE.Vector3(2, 0, 0).applyQuaternion(camera.quaternion);
const up = new THREE.Vector3(0, 10, 0).applyQuaternion(camera.quaternion);
const forward = new THREE.Vector3(0, 0, -42).applyQuaternion(camera.quaternion);
const offset = right.clone().multiplyScalar(1.5).add(up.clone().multiplyScalar(-0.3)).add(forward
.clone().multiplyScalar(0.2));
weaponDisplay.position.copy(camera.position).add(offset);
}
updateEnemies(delta);
// ---- 拉栓动画更新 ----
if (boltAnim) {
const now = Date.now();
const elapsed = now - boltAnim.startTime;
const progress = Math.min(elapsed / boltAnim.duration, 1);
let angle = 0;
if (progress < 0.5) {
const t = progress / 0.5;
angle = -90 * t * Math.PI / 180;
} else {
const t = (progress - 0.5) / 0.5;
angle = -90 * (1 - t) * Math.PI / 180;
}
const model = weaponModels[currentWeaponId];
if (model) {
const baseAngle = Math.PI / 2;
const currentAngle = -angle + baseAngle;
model.rotation.y = currentAngle;
const hands = weaponHands[currentWeaponId];
if (hands) {
const deltaAngle = currentAngle - baseAngle;
const quat = new THREE.Quaternion().setFromAxisAngle(
new THREE.Vector3(0, 1, 0),
-deltaAngle
);
// ---- 计算 Z 轴后退偏移 ----
let zOffset = 0;
const maxOffset = -0.5; // 后退距离,可调整
if (progress < 0.5) {
const t = progress / 0.5;
zOffset = maxOffset * t; // 0 -> maxOffset
} else {
const t = (progress - 0.5) / 0.5;
zOffset = maxOffset * (1 - t); // maxOffset -> 0
}
// 左手:位置补偿 + Z偏移
if (hands.leftHand) {
const pos = hands.leftHandInitPos.clone();
pos.z += zOffset;
hands.leftHand.position.copy(pos.applyQuaternion(quat));
hands.leftHand.rotation.y = -deltaAngle;
}
// 左衣袖:同样处理
if (hands.leftSleeve) {
const pos = hands.leftSleeveInitPos.clone();
pos.z += zOffset;
hands.leftSleeve.position.copy(pos.applyQuaternion(quat));
hands.leftSleeve.rotation.y = -deltaAngle;
}
}
}
// 动画完成
if (progress >= 1) {
if (model) model.rotation.y = Math.PI / 2;
const hands = weaponHands[currentWeaponId];
if (hands) {
if (hands.leftHand) {
hands.leftHand.position.copy(hands.leftHandInitPos);
hands.leftHand.rotation.y = 0;
}
if (hands.leftSleeve) {
hands.leftSleeve.position.copy(hands.leftSleeveInitPos);
hands.leftSleeve.rotation.y = 0;
}
}
const state = getCurrentState();
state.boltReady = true;
document.getElementById('boltIndicator').style.display = 'none';
boltAnim = null;
updateWeaponUI();
}
}
}
// 更新雷达
if (isLocked && !gameOver) {
updateRadar();
}
renderer.render(scene, camera);
}
// ============================================================
// START
// ============================================================
init();
//password:12348888
window.__debug = {
enemies,
player,
weaponStates,
unlockedWeapons,
currentWeaponId,
WEAPON_DEFS,
switchWeapon,
pickups,
doPickup,
currentWave,
waveComplete,
waveKills,
waveTotalEnemies
};
})();
</script>
</body>
</html>Game Source: Burma Expedition · 4 Waves
Creator: UltraNinja80
Libraries: three
Complexity: complex (4745 lines, 197.2 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: burma-expedition-4-waves-ultraninja80-mth9eila" to link back to the original. Then publish at arcadelab.ai/publish.