|
import * as THREE from 'three'; |
|
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js'; |
|
import { PointerLockControls } from 'three/addons/controls/PointerLockControls.js'; |
|
|
|
|
|
const GAME_DURATION = 180; |
|
const MAP_SIZE = 500; |
|
const TANK_HEIGHT = 0.5; |
|
const ENEMY_GROUND_HEIGHT = 0; |
|
const ENEMY_SCALE = 1; |
|
const MAX_HEALTH = 1000; |
|
const ENEMY_MOVE_SPEED = 0.1; |
|
const ENEMY_COUNT_MAX = 3; |
|
const PARTICLE_COUNT = 15; |
|
const BUILDING_COUNT = 25; |
|
const ENEMY_CONFIG = { |
|
ATTACK_RANGE: 100, |
|
ATTACK_INTERVAL: 2000, |
|
BULLET_SPEED: 2 |
|
}; |
|
|
|
|
|
class TankPlayer { |
|
constructor() { |
|
this.body = null; |
|
this.turret = null; |
|
this.position = new THREE.Vector3(0, 0, 0); |
|
this.rotation = new THREE.Euler(0, 0, 0); |
|
this.turretRotation = 0; |
|
this.moveSpeed = 0.5; |
|
this.turnSpeed = 0.03; |
|
this.turretGroup = new THREE.Group(); |
|
this.health = MAX_HEALTH; |
|
this.isLoaded = false; |
|
this.ammo = 1; |
|
this.maxAmmo = 1; |
|
this.isReloading = false; |
|
this.reloadTime = 3000; |
|
this.lastShootTime = 0; |
|
this.bullets = []; |
|
this.obstacles = []; |
|
this.scene = new THREE.Scene(); |
|
this.camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000); |
|
this.renderer = new THREE.WebGLRenderer({ antialias: true }); |
|
} |
|
|
|
createExplosionEffect(scene, position) { |
|
|
|
const flashGeometry = new THREE.SphereGeometry(3); |
|
const flashMaterial = new THREE.MeshBasicMaterial({ |
|
color: 0xffff00, |
|
transparent: true, |
|
opacity: 1 |
|
}); |
|
const flash = new THREE.Mesh(flashGeometry, flashMaterial); |
|
flash.position.copy(position); |
|
scene.add(flash); |
|
|
|
|
|
for (let i = 0; i < 30; i++) { |
|
const size = Math.random() * 0.5 + 0.3; |
|
const geometry = new THREE.SphereGeometry(size); |
|
|
|
|
|
const colors = [0xff4500, 0xff8c00, 0xff0000, 0xffd700]; |
|
const material = new THREE.MeshBasicMaterial({ |
|
color: colors[Math.floor(Math.random() * colors.length)], |
|
transparent: true, |
|
opacity: 1 |
|
}); |
|
|
|
const particle = new THREE.Mesh(geometry, material); |
|
particle.position.copy(position); |
|
|
|
|
|
const speed = Math.random() * 0.5 + 0.3; |
|
const angle = Math.random() * Math.PI * 2; |
|
const elevation = Math.random() * Math.PI - Math.PI / 2; |
|
|
|
particle.velocity = new THREE.Vector3( |
|
Math.cos(angle) * Math.cos(elevation) * speed, |
|
Math.sin(elevation) * speed, |
|
Math.sin(angle) * Math.cos(elevation) * speed |
|
); |
|
|
|
particle.gravity = -0.015; |
|
particle.life = Math.random() * 30 + 30; |
|
particle.fadeRate = 0.97; |
|
|
|
scene.add(particle); |
|
window.gameInstance.particles.push({ |
|
mesh: particle, |
|
velocity: particle.velocity, |
|
gravity: particle.gravity, |
|
life: particle.life, |
|
fadeRate: particle.fadeRate |
|
}); |
|
} |
|
|
|
|
|
const ringGeometry = new THREE.RingGeometry(0.1, 2, 32); |
|
const ringMaterial = new THREE.MeshBasicMaterial({ |
|
color: 0xff8c00, |
|
transparent: true, |
|
opacity: 1, |
|
side: THREE.DoubleSide |
|
}); |
|
const ring = new THREE.Mesh(ringGeometry, ringMaterial); |
|
ring.position.copy(position); |
|
ring.lookAt(new THREE.Vector3(0, 1, 0)); |
|
scene.add(ring); |
|
|
|
|
|
const expandRing = () => { |
|
ring.scale.x += 0.2; |
|
ring.scale.y += 0.2; |
|
ring.material.opacity *= 0.95; |
|
|
|
if (ring.material.opacity > 0.01) { |
|
requestAnimationFrame(expandRing); |
|
} else { |
|
scene.remove(ring); |
|
} |
|
}; |
|
expandRing(); |
|
|
|
|
|
const explosionSound = new Audio('sounds/bang.ogg'); |
|
explosionSound.volume = 0.4; |
|
explosionSound.play(); |
|
|
|
|
|
if (window.gameInstance && window.gameInstance.camera) { |
|
const camera = window.gameInstance.camera; |
|
const originalPosition = camera.position.clone(); |
|
let shakeTime = 0; |
|
const shakeIntensity = 0.3; |
|
const shakeDuration = 500; |
|
|
|
const shakeCamera = () => { |
|
if (shakeTime < shakeDuration) { |
|
camera.position.x = originalPosition.x + (Math.random() - 0.5) * shakeIntensity; |
|
camera.position.y = originalPosition.y + (Math.random() - 0.5) * shakeIntensity; |
|
camera.position.z = originalPosition.z + (Math.random() - 0.5) * shakeIntensity; |
|
|
|
shakeTime += 16; |
|
requestAnimationFrame(shakeCamera); |
|
} else { |
|
camera.position.copy(originalPosition); |
|
} |
|
}; |
|
shakeCamera(); |
|
} |
|
|
|
|
|
setTimeout(() => { |
|
scene.remove(flash); |
|
}, 100); |
|
} |
|
|
|
async initialize(scene, loader) { |
|
try { |
|
const bodyResult = await loader.loadAsync('/models/abramsBody.glb'); |
|
this.body = bodyResult.scene; |
|
|
|
const turretResult = await loader.loadAsync('/models/abramsTurret.glb'); |
|
this.turret = turretResult.scene; |
|
|
|
this.turretGroup.position.y = 0.2; |
|
this.turretGroup.add(this.turret); |
|
this.body.add(this.turretGroup); |
|
|
|
|
|
this.body.traverse((child) => { |
|
if (child.isMesh) { |
|
child.castShadow = true; |
|
child.receiveShadow = true; |
|
child.material.shadowSide = THREE.BackSide; |
|
child.material.needsUpdate = true; |
|
} |
|
}); |
|
|
|
this.turret.traverse((child) => { |
|
if (child.isMesh) { |
|
child.castShadow = true; |
|
child.receiveShadow = true; |
|
child.material.shadowSide = THREE.BackSide; |
|
child.material.needsUpdate = true; |
|
} |
|
}); |
|
|
|
|
|
const shadowPlaneGeometry = new THREE.PlaneGeometry(8, 8); |
|
const shadowPlaneMaterial = new THREE.ShadowMaterial({ |
|
opacity: 0.3 |
|
}); |
|
this.shadowPlane = new THREE.Mesh(shadowPlaneGeometry, shadowPlaneMaterial); |
|
this.shadowPlane.receiveShadow = true; |
|
this.shadowPlane.rotation.x = -Math.PI / 2; |
|
this.shadowPlane.position.y = 0.1; |
|
this.body.add(this.shadowPlane); |
|
|
|
|
|
const spawnPosition = new THREE.Vector3( |
|
(Math.random() - 0.5) * (MAP_SIZE * 0.8), |
|
TANK_HEIGHT, |
|
(Math.random() - 0.5) * (MAP_SIZE * 0.8) |
|
); |
|
|
|
this.body.position.copy(spawnPosition); |
|
|
|
|
|
this.createExplosionEffect = (scene, position) => { |
|
|
|
for (let i = 0; i < 15; i++) { |
|
const size = Math.random() * 0.2 + 0.1; |
|
const geometry = new THREE.SphereGeometry(size); |
|
const material = new THREE.MeshBasicMaterial({ |
|
color: Math.random() < 0.5 ? 0xff4500 : 0xff8c00 |
|
}); |
|
const particle = new THREE.Mesh(geometry, material); |
|
particle.position.copy(position); |
|
|
|
const speed = Math.random() * 0.3 + 0.2; |
|
const angle = Math.random() * Math.PI * 2; |
|
const elevation = Math.random() * Math.PI - Math.PI / 2; |
|
|
|
particle.velocity = new THREE.Vector3( |
|
Math.cos(angle) * Math.cos(elevation) * speed, |
|
Math.sin(elevation) * speed, |
|
Math.sin(angle) * Math.cos(elevation) * speed |
|
); |
|
|
|
particle.gravity = -0.01; |
|
particle.life = Math.random() * 20 + 20; |
|
particle.fadeRate = 1 / particle.life; |
|
|
|
scene.add(particle); |
|
window.gameInstance.particles.push({ |
|
mesh: particle, |
|
velocity: particle.velocity, |
|
gravity: particle.gravity, |
|
life: particle.life, |
|
fadeRate: particle.fadeRate |
|
}); |
|
} |
|
|
|
|
|
const explosionSound = new Audio('sounds/explosion.ogg'); |
|
explosionSound.volume = 0.3; |
|
explosionSound.play(); |
|
}; |
|
|
|
scene.add(this.body); |
|
this.isLoaded = true; |
|
this.updateAmmoDisplay(); |
|
|
|
} catch (error) { |
|
console.error('Error loading tank models:', error); |
|
this.isLoaded = false; |
|
} |
|
} |
|
|
|
shoot(scene) { |
|
|
|
if (this.isReloading || this.ammo <= 0) { |
|
return null; |
|
} |
|
|
|
|
|
const currentTime = Date.now(); |
|
if (currentTime - this.lastShootTime < 100) { |
|
return null; |
|
} |
|
this.lastShootTime = currentTime; |
|
|
|
|
|
const sounds = ['sounds/mbtfire1.ogg', 'sounds/mbtfire2.ogg', 'sounds/mbtfire3.ogg', 'sounds/mbtfire4.ogg']; |
|
const randomSound = sounds[Math.floor(Math.random() * sounds.length)]; |
|
const audio = new Audio(randomSound); |
|
audio.volume = 0.5; |
|
|
|
|
|
if (this.lastAudio) { |
|
this.lastAudio.pause(); |
|
this.lastAudio.currentTime = 0; |
|
} |
|
|
|
this.lastAudio = audio; |
|
audio.play(); |
|
|
|
|
|
this.createMuzzleFlash(scene); |
|
|
|
|
|
const bullet = this.createBullet(scene); |
|
if (bullet) { |
|
this.ammo--; |
|
this.updateAmmoDisplay(); |
|
|
|
|
|
if (this.ammo <= 0) { |
|
this.startReload(); |
|
} |
|
} |
|
return bullet; |
|
} |
|
|
|
startReload() { |
|
if (this.isReloading) return; |
|
|
|
this.isReloading = true; |
|
const reloadingText = document.getElementById('reloadingText'); |
|
reloadingText.style.display = 'block'; |
|
|
|
setTimeout(() => { |
|
this.ammo = this.maxAmmo; |
|
this.isReloading = false; |
|
reloadingText.style.display = 'none'; |
|
this.updateAmmoDisplay(); |
|
}, this.reloadTime); |
|
} |
|
|
|
createMuzzleFlash(scene) { |
|
if (!this.turret) return; |
|
const flashGroup = new THREE.Group(); |
|
|
|
|
|
const flameGeometry = new THREE.SphereGeometry(1.0, 8, 8); |
|
const flameMaterial = new THREE.MeshBasicMaterial({ |
|
color: 0xffa500, |
|
transparent: true, |
|
opacity: 0.8 |
|
}); |
|
const flame = new THREE.Mesh(flameGeometry, flameMaterial); |
|
flame.scale.set(2, 2, 3); |
|
flashGroup.add(flame); |
|
|
|
|
|
const smokeGeometry = new THREE.SphereGeometry(0.8, 8, 8); |
|
const smokeMaterial = new THREE.MeshBasicMaterial({ |
|
color: 0x555555, |
|
transparent: true, |
|
opacity: 0.5 |
|
}); |
|
|
|
for (let i = 0; i < 5; i++) { |
|
const smoke = new THREE.Mesh(smokeGeometry, smokeMaterial); |
|
smoke.position.set( |
|
Math.random() * 1 - 0.5, |
|
Math.random() * 1 - 0.5, |
|
-1 - Math.random() |
|
); |
|
smoke.scale.set(1.5, 1.5, 1.5); |
|
flashGroup.add(smoke); |
|
} |
|
|
|
|
|
const muzzleOffset = new THREE.Vector3(0, 0.5, 4); |
|
const muzzlePosition = new THREE.Vector3(); |
|
const turretWorldQuaternion = new THREE.Quaternion(); |
|
|
|
this.turret.getWorldPosition(muzzlePosition); |
|
this.turret.getWorldQuaternion(turretWorldQuaternion); |
|
|
|
muzzleOffset.applyQuaternion(turretWorldQuaternion); |
|
muzzlePosition.add(muzzleOffset); |
|
|
|
flashGroup.position.copy(muzzlePosition); |
|
flashGroup.quaternion.copy(turretWorldQuaternion); |
|
|
|
scene.add(flashGroup); |
|
|
|
|
|
setTimeout(() => { |
|
scene.remove(flashGroup); |
|
}, 500); |
|
} |
|
|
|
|
|
|
|
createBullet(scene) { |
|
if (!this.turret) return null; |
|
|
|
const bulletGeometry = new THREE.CylinderGeometry(0.2, 0.2, 2, 8); |
|
const bulletMaterial = new THREE.MeshBasicMaterial({ color: 0xffd700 }); |
|
const bullet = new THREE.Mesh(bulletGeometry, bulletMaterial); |
|
|
|
|
|
const muzzleOffset = new THREE.Vector3(0, 0.5, 4); |
|
const muzzlePosition = new THREE.Vector3(); |
|
const turretWorldQuaternion = new THREE.Quaternion(); |
|
|
|
|
|
this.turret.getWorldPosition(muzzlePosition); |
|
this.turret.getWorldQuaternion(turretWorldQuaternion); |
|
|
|
|
|
muzzleOffset.applyQuaternion(turretWorldQuaternion); |
|
muzzlePosition.add(muzzleOffset); |
|
|
|
|
|
bullet.position.copy(muzzlePosition); |
|
bullet.quaternion.copy(turretWorldQuaternion); |
|
|
|
|
|
const direction = new THREE.Vector3(0, 0, 1); |
|
direction.applyQuaternion(turretWorldQuaternion); |
|
bullet.velocity = direction.multiplyScalar(5); |
|
|
|
scene.add(bullet); |
|
this.bullets.push(bullet); |
|
|
|
return bullet; |
|
} |
|
|
|
|
|
update(mouseX, mouseY, scene) { |
|
if (!this.body || !this.turretGroup) return; |
|
|
|
const absoluteTurretRotation = mouseX; |
|
this.turretGroup.rotation.y = absoluteTurretRotation - this.body.rotation.y; |
|
this.turretRotation = absoluteTurretRotation; |
|
|
|
|
|
for (let i = this.bullets.length - 1; i >= 0; i--) { |
|
const bullet = this.bullets[i]; |
|
const oldPosition = bullet.position.clone(); |
|
bullet.position.add(bullet.velocity); |
|
|
|
|
|
const terrainHeight = window.gameInstance.getHeightAtPosition( |
|
bullet.position.x, |
|
bullet.position.z |
|
); |
|
|
|
if (bullet.position.y < terrainHeight || |
|
Math.abs(bullet.position.x) > MAP_SIZE / 2 || |
|
Math.abs(bullet.position.z) > MAP_SIZE / 2) { |
|
|
|
|
|
this.createExplosionEffect(scene, bullet.position); |
|
|
|
scene.remove(bullet); |
|
this.bullets.splice(i, 1); |
|
} |
|
} |
|
} |
|
|
|
move(direction) { |
|
if (!this.body) return; |
|
|
|
const moveVector = new THREE.Vector3(); |
|
moveVector.x = direction.x * this.moveSpeed; |
|
moveVector.z = direction.z * this.moveSpeed; |
|
|
|
|
|
const newPosition = this.body.position.clone().add(moveVector); |
|
|
|
|
|
const heightAtNewPos = window.gameInstance.getHeightAtPosition(newPosition.x, newPosition.z); |
|
|
|
|
|
newPosition.y = heightAtNewPos + TANK_HEIGHT; |
|
|
|
|
|
const currentHeight = this.body.position.y; |
|
const heightDifference = Math.abs(newPosition.y - currentHeight); |
|
const maxClimbAngle = 0.5; |
|
|
|
if (heightDifference / this.moveSpeed < maxClimbAngle) { |
|
this.body.position.copy(newPosition); |
|
|
|
|
|
const forwardVector = new THREE.Vector3(0, 0, 1).applyQuaternion(this.body.quaternion); |
|
const rightVector = new THREE.Vector3(1, 0, 0).applyQuaternion(this.body.quaternion); |
|
|
|
|
|
const frontHeight = window.gameInstance.getHeightAtPosition( |
|
newPosition.x + forwardVector.x, |
|
newPosition.z + forwardVector.z |
|
); |
|
const backHeight = window.gameInstance.getHeightAtPosition( |
|
newPosition.x - forwardVector.x, |
|
newPosition.z - forwardVector.z |
|
); |
|
const rightHeight = window.gameInstance.getHeightAtPosition( |
|
newPosition.x + rightVector.x, |
|
newPosition.z + rightVector.z |
|
); |
|
const leftHeight = window.gameInstance.getHeightAtPosition( |
|
newPosition.x - rightVector.x, |
|
newPosition.z - rightVector.z |
|
); |
|
|
|
|
|
const pitch = Math.atan2(frontHeight - backHeight, 2); |
|
const roll = Math.atan2(rightHeight - leftHeight, 2); |
|
|
|
|
|
const currentYRotation = this.body.rotation.y; |
|
this.body.rotation.set(pitch, currentYRotation, roll); |
|
} |
|
} |
|
|
|
rotate(angle) { |
|
if (!this.body) return; |
|
|
|
|
|
this.body.rotation.y += angle * this.turnSpeed; |
|
|
|
|
|
const position = this.body.position; |
|
const forwardVector = new THREE.Vector3(0, 0, 1).applyQuaternion(this.body.quaternion); |
|
const rightVector = new THREE.Vector3(1, 0, 0).applyQuaternion(this.body.quaternion); |
|
|
|
|
|
const frontHeight = window.gameInstance.getHeightAtPosition( |
|
position.x + forwardVector.x, |
|
position.z + forwardVector.z |
|
); |
|
const backHeight = window.gameInstance.getHeightAtPosition( |
|
position.x - forwardVector.x, |
|
position.z - forwardVector.z |
|
); |
|
const rightHeight = window.gameInstance.getHeightAtPosition( |
|
position.x + rightVector.x, |
|
position.z + rightVector.z |
|
); |
|
const leftHeight = window.gameInstance.getHeightAtPosition( |
|
position.x - rightVector.x, |
|
position.z - rightVector.z |
|
); |
|
|
|
|
|
const pitch = Math.atan2(frontHeight - backHeight, 2); |
|
const roll = Math.atan2(rightHeight - leftHeight, 2); |
|
|
|
|
|
const currentYRotation = this.body.rotation.y; |
|
this.body.rotation.set(pitch, currentYRotation, roll); |
|
} |
|
|
|
getPosition() { |
|
return this.body ? this.body.position : new THREE.Vector3(); |
|
} |
|
|
|
takeDamage(damage) { |
|
this.health -= damage; |
|
|
|
|
|
if (this.health <= 0) { |
|
const deathSound = new Audio('sounds/bang.ogg'); |
|
deathSound.play(); |
|
return true; |
|
} |
|
return false; |
|
} |
|
startReload() { |
|
this.isReloading = true; |
|
const reloadingText = document.getElementById('reloadingText'); |
|
reloadingText.style.display = 'block'; |
|
setTimeout(() => { |
|
this.ammo = this.maxAmmo; |
|
this.isReloading = false; |
|
reloadingText.style.display = 'none'; |
|
this.updateAmmoDisplay(); |
|
}, this.reloadTime); |
|
} |
|
|
|
updateAmmoDisplay() { |
|
document.getElementById('ammoDisplay').textContent = `APFSDS: ${this.ammo}/${this.maxAmmo}`; |
|
} |
|
} |
|
|
|
|
|
class Enemy { |
|
constructor(scene, position, type = 'tank') { |
|
|
|
this.scene = scene; |
|
this.position = position; |
|
this.mesh = null; |
|
this.type = type; |
|
this.health = type === 'tank' ? 100 : 200; |
|
this.lastAttackTime = 0; |
|
this.bullets = []; |
|
this.isLoaded = false; |
|
this.alternativePath = null; |
|
this.pathFindingTimeout = 0; |
|
this.lastPathUpdateTime = 0; |
|
this.pathUpdateInterval = 3000; |
|
this.moveSpeed = type === 'tank' ? ENEMY_MOVE_SPEED : ENEMY_MOVE_SPEED * 0.7; |
|
|
|
|
|
this.aiState = { |
|
mode: 'pursue', |
|
lastStateChange: 0, |
|
stateChangeCooldown: 3000, |
|
lastVisibilityCheck: 0, |
|
visibilityCheckInterval: 500, |
|
canSeePlayer: false, |
|
lastKnownPlayerPosition: null, |
|
searchStartTime: null, |
|
targetRotation: 0, |
|
currentRotation: 0, |
|
isAiming: false, |
|
aimingTime: 0, |
|
requiredAimTime: 1000 |
|
}; |
|
|
|
|
|
this.pathfinding = { |
|
currentPath: [], |
|
pathUpdateInterval: 1000, |
|
lastPathUpdate: 0, |
|
isAvoidingObstacle: false, |
|
avoidanceDirection: null, |
|
obstacleCheckDistance: 10, |
|
avoidanceTime: 0, |
|
maxAvoidanceTime: 3000, |
|
sensorAngles: [-45, 0, 45], |
|
sensorDistance: 15 |
|
}; |
|
|
|
|
|
this.combat = { |
|
minEngagementRange: 30, |
|
maxEngagementRange: 150, |
|
optimalRange: 80, |
|
aimThreshold: 0.1, |
|
lastShotAccuracy: 0, |
|
consecutiveHits: 0, |
|
maxConsecutiveHits: 3 |
|
}; |
|
} |
|
|
|
detectObstacles() { |
|
const obstacles = []; |
|
const position = this.mesh.position.clone(); |
|
position.y += 1; |
|
|
|
this.pathfinding.sensorAngles.forEach(angle => { |
|
const direction = new THREE.Vector3(0, 0, 1) |
|
.applyQuaternion(this.mesh.quaternion) |
|
.applyAxisAngle(new THREE.Vector3(0, 1, 0), angle * Math.PI / 180); |
|
|
|
const raycaster = new THREE.Raycaster(position, direction, 0, this.pathfinding.sensorDistance); |
|
const intersects = raycaster.intersectObjects(window.gameInstance.obstacles, true); |
|
|
|
if (intersects.length > 0) { |
|
obstacles.push({ |
|
angle: angle, |
|
distance: intersects[0].distance, |
|
point: intersects[0].point |
|
}); |
|
} |
|
}); |
|
|
|
return obstacles; |
|
} |
|
|
|
|
|
calculateAvoidanceDirection(obstacles) { |
|
if (obstacles.length === 0) return null; |
|
|
|
|
|
const avoidanceVector = new THREE.Vector3(); |
|
obstacles.forEach(obstacle => { |
|
const avoidDir = new THREE.Vector3() |
|
.subVectors(this.mesh.position, obstacle.point) |
|
.normalize() |
|
.multiplyScalar(1 / obstacle.distance); |
|
avoidanceVector.add(avoidDir); |
|
}); |
|
|
|
return avoidanceVector.normalize(); |
|
} |
|
|
|
|
|
updateAiming(playerPosition) { |
|
const targetDirection = new THREE.Vector3() |
|
.subVectors(playerPosition, this.mesh.position) |
|
.normalize(); |
|
|
|
|
|
this.aiState.targetRotation = Math.atan2(targetDirection.x, targetDirection.z); |
|
|
|
|
|
const rotationDiff = this.aiState.targetRotation - this.aiState.currentRotation; |
|
let rotationStep = Math.sign(rotationDiff) * Math.min(Math.abs(rotationDiff), 0.05); |
|
this.aiState.currentRotation += rotationStep; |
|
|
|
|
|
this.mesh.rotation.y = this.aiState.currentRotation; |
|
|
|
|
|
const aimAccuracy = 1 - Math.abs(rotationDiff) / Math.PI; |
|
return aimAccuracy > this.combat.aimThreshold; |
|
} |
|
|
|
|
|
maintainCombatDistance(playerPosition) { |
|
const distanceToPlayer = this.mesh.position.distanceTo(playerPosition); |
|
let moveDirection = new THREE.Vector3(); |
|
|
|
if (distanceToPlayer < this.combat.minEngagementRange) { |
|
|
|
moveDirection.subVectors(this.mesh.position, playerPosition).normalize(); |
|
} else if (distanceToPlayer > this.combat.maxEngagementRange) { |
|
|
|
moveDirection.subVectors(playerPosition, this.mesh.position).normalize(); |
|
} else if (Math.abs(distanceToPlayer - this.combat.optimalRange) > 10) { |
|
|
|
const targetDistance = this.combat.optimalRange; |
|
moveDirection.subVectors(playerPosition, this.mesh.position).normalize(); |
|
if (distanceToPlayer > targetDistance) { |
|
moveDirection.multiplyScalar(1); |
|
} else { |
|
moveDirection.multiplyScalar(-1); |
|
} |
|
} |
|
|
|
return moveDirection; |
|
} |
|
|
|
|
|
canShoot(playerPosition) { |
|
const distance = this.mesh.position.distanceTo(playerPosition); |
|
const hasLineOfSight = this.checkLineOfSight(playerPosition); |
|
const isAimed = this.updateAiming(playerPosition); |
|
|
|
return distance <= this.combat.maxEngagementRange && |
|
distance >= this.combat.minEngagementRange && |
|
hasLineOfSight && |
|
isAimed; |
|
} |
|
|
|
|
|
update(playerPosition) { |
|
if (!this.mesh || !this.isLoaded) return; |
|
|
|
|
|
this.updateAIState(playerPosition); |
|
|
|
|
|
const obstacles = this.detectObstacles(); |
|
const currentTime = Date.now(); |
|
const hasLineOfSight = this.checkLineOfSight(playerPosition); |
|
const distanceToPlayer = this.mesh.position.distanceTo(playerPosition); |
|
|
|
|
|
if (currentTime - this.lastPathUpdateTime > this.pathUpdateInterval) { |
|
if (!hasLineOfSight) { |
|
this.alternativePath = this.findAlternativePath(playerPosition); |
|
} |
|
this.lastPathUpdateTime = currentTime; |
|
} |
|
|
|
|
|
if (obstacles.length > 0 && !this.pathfinding.isAvoidingObstacle) { |
|
this.pathfinding.isAvoidingObstacle = true; |
|
this.pathfinding.avoidanceDirection = this.calculateAvoidanceDirection(obstacles); |
|
this.pathfinding.avoidanceTime = 0; |
|
} |
|
|
|
|
|
if (this.pathfinding.isAvoidingObstacle) { |
|
|
|
this.pathfinding.avoidanceTime += 16; |
|
if (this.pathfinding.avoidanceTime >= this.pathfinding.maxAvoidanceTime) { |
|
this.pathfinding.isAvoidingObstacle = false; |
|
} else { |
|
const avoidMove = this.pathfinding.avoidanceDirection.multiplyScalar(this.moveSpeed); |
|
this.mesh.position.add(avoidMove); |
|
} |
|
} else if (!hasLineOfSight) { |
|
|
|
if (this.alternativePath) { |
|
const pathDirection = new THREE.Vector3() |
|
.subVectors(this.alternativePath, this.mesh.position) |
|
.normalize(); |
|
this.mesh.position.add(pathDirection.multiplyScalar(this.moveSpeed)); |
|
|
|
const targetRotation = Math.atan2(pathDirection.x, pathDirection.z); |
|
this.mesh.rotation.y = this.smoothRotation(this.mesh.rotation.y, targetRotation, 0.1); |
|
} |
|
} else { |
|
|
|
this.alternativePath = null; |
|
|
|
|
|
switch (this.aiState.mode) { |
|
case 'pursue': |
|
if (distanceToPlayer > ENEMY_CONFIG.ATTACK_RANGE * 0.7) { |
|
const moveDirection = new THREE.Vector3() |
|
.subVectors(playerPosition, this.mesh.position) |
|
.normalize(); |
|
this.mesh.position.add(moveDirection.multiplyScalar(this.moveSpeed)); |
|
} |
|
break; |
|
|
|
case 'flank': |
|
const flankPosition = this.calculateFlankPosition(playerPosition); |
|
this.findPathToTarget(flankPosition); |
|
this.moveAlongPath(); |
|
break; |
|
|
|
case 'retreat': |
|
if (distanceToPlayer < ENEMY_CONFIG.ATTACK_RANGE * 0.3) { |
|
const retreatDirection = new THREE.Vector3() |
|
.subVectors(this.mesh.position, playerPosition) |
|
.normalize(); |
|
this.mesh.position.add(retreatDirection.multiplyScalar(this.moveSpeed)); |
|
} |
|
break; |
|
} |
|
|
|
|
|
const directionToPlayer = new THREE.Vector3() |
|
.subVectors(playerPosition, this.mesh.position) |
|
.normalize(); |
|
const targetRotation = Math.atan2(directionToPlayer.x, directionToPlayer.z); |
|
this.mesh.rotation.y = this.smoothRotation(this.mesh.rotation.y, targetRotation, 0.1); |
|
} |
|
|
|
|
|
const combatMove = this.maintainCombatDistance(playerPosition); |
|
if (combatMove.length() > 0) { |
|
this.mesh.position.add(combatMove.multiplyScalar(this.moveSpeed)); |
|
} |
|
|
|
|
|
if (hasLineOfSight && distanceToPlayer <= ENEMY_CONFIG.ATTACK_RANGE && this.canShoot(playerPosition)) { |
|
this.shoot(playerPosition); |
|
} |
|
|
|
|
|
this.updateBullets(); |
|
|
|
|
|
this.adjustTankTilt(); |
|
} |
|
|
|
checkLineOfSight(targetPosition) { |
|
if (!this.mesh) return false; |
|
|
|
const startPos = this.mesh.position.clone(); |
|
startPos.y += 2; |
|
const direction = new THREE.Vector3() |
|
.subVectors(targetPosition, startPos) |
|
.normalize(); |
|
const distance = startPos.distanceTo(targetPosition); |
|
|
|
const raycaster = new THREE.Raycaster(startPos, direction, 0, distance); |
|
const intersects = raycaster.intersectObjects(window.gameInstance.obstacles, true); |
|
|
|
return intersects.length === 0; |
|
} |
|
|
|
findAlternativePath(playerPosition) { |
|
const currentPos = this.mesh.position.clone(); |
|
const directionToPlayer = new THREE.Vector3() |
|
.subVectors(playerPosition, currentPos) |
|
.normalize(); |
|
|
|
|
|
const leftDirection = new THREE.Vector3() |
|
.copy(directionToPlayer) |
|
.applyAxisAngle(new THREE.Vector3(0, 1, 0), Math.PI / 2); |
|
const rightDirection = new THREE.Vector3() |
|
.copy(directionToPlayer) |
|
.applyAxisAngle(new THREE.Vector3(0, 1, 0), -Math.PI / 2); |
|
|
|
|
|
const checkDistance = 30; |
|
const leftPoint = currentPos.clone().add(leftDirection.multiplyScalar(checkDistance)); |
|
const rightPoint = currentPos.clone().add(rightDirection.multiplyScalar(checkDistance)); |
|
|
|
|
|
const leftClear = this.checkPathClear(currentPos, leftPoint); |
|
const rightClear = this.checkPathClear(currentPos, rightPoint); |
|
|
|
if (leftClear && rightClear) { |
|
|
|
return Math.random() < 0.5 ? leftPoint : rightPoint; |
|
} else if (leftClear) { |
|
return leftPoint; |
|
} else if (rightClear) { |
|
return rightPoint; |
|
} |
|
|
|
return null; |
|
} |
|
|
|
checkPathClear(start, end) { |
|
const direction = new THREE.Vector3().subVectors(end, start).normalize(); |
|
const distance = start.distanceTo(end); |
|
const raycaster = new THREE.Raycaster(start, direction, 0, distance); |
|
const intersects = raycaster.intersectObjects(window.gameInstance.obstacles, true); |
|
return intersects.length === 0; |
|
} |
|
|
|
async initialize(loader) { |
|
try { |
|
const modelPath = this.type === 'tank' ? '/models/t90.glb' : '/models/t90.glb'; |
|
const result = await loader.loadAsync(modelPath); |
|
this.mesh = result.scene; |
|
this.mesh.position.copy(this.position); |
|
this.mesh.scale.set(ENEMY_SCALE, ENEMY_SCALE, ENEMY_SCALE); |
|
|
|
this.mesh.traverse((child) => { |
|
if (child.isMesh) { |
|
child.castShadow = true; |
|
child.receiveShadow = true; |
|
} |
|
}); |
|
|
|
this.scene.add(this.mesh); |
|
this.isLoaded = true; |
|
} catch (error) { |
|
console.error('Error loading enemy model:', error); |
|
this.isLoaded = false; |
|
} |
|
} |
|
|
|
|
|
checkLineOfSight(playerPosition) { |
|
if (!this.mesh) return false; |
|
|
|
const startPos = this.mesh.position.clone(); |
|
startPos.y += 2; |
|
const direction = new THREE.Vector3() |
|
.subVectors(playerPosition, startPos) |
|
.normalize(); |
|
const distance = startPos.distanceTo(playerPosition); |
|
|
|
const raycaster = new THREE.Raycaster(startPos, direction, 0, distance); |
|
const intersects = raycaster.intersectObjects(window.gameInstance.obstacles, true); |
|
|
|
|
|
return intersects.length === 0; |
|
} |
|
|
|
findAlternativePath(playerPosition) { |
|
const currentPos = this.mesh.position.clone(); |
|
const directionToPlayer = new THREE.Vector3() |
|
.subVectors(playerPosition, currentPos) |
|
.normalize(); |
|
|
|
|
|
const leftDirection = new THREE.Vector3() |
|
.copy(directionToPlayer) |
|
.applyAxisAngle(new THREE.Vector3(0, 1, 0), Math.PI / 2); |
|
const rightDirection = new THREE.Vector3() |
|
.copy(directionToPlayer) |
|
.applyAxisAngle(new THREE.Vector3(0, 1, 0), -Math.PI / 2); |
|
|
|
|
|
const checkDistance = 30; |
|
const leftPoint = currentPos.clone().add(leftDirection.multiplyScalar(checkDistance)); |
|
const rightPoint = currentPos.clone().add(rightDirection.multiplyScalar(checkDistance)); |
|
|
|
|
|
const leftClear = this.checkPathClear(currentPos, leftPoint); |
|
const rightClear = this.checkPathClear(currentPos, rightPoint); |
|
|
|
if (leftClear && rightClear) { |
|
|
|
return Math.random() < 0.5 ? leftPoint : rightPoint; |
|
} else if (leftClear) { |
|
return leftPoint; |
|
} else if (rightClear) { |
|
return rightPoint; |
|
} |
|
|
|
return null; |
|
} |
|
|
|
checkPathClear(start, end) { |
|
const direction = new THREE.Vector3().subVectors(end, start).normalize(); |
|
const distance = start.distanceTo(end); |
|
const raycaster = new THREE.Raycaster(start, direction, 0, distance); |
|
const intersects = raycaster.intersectObjects(window.gameInstance.obstacles, true); |
|
return intersects.length === 0; |
|
} |
|
|
|
|
|
smoothRotation(current, target, factor) { |
|
let delta = target - current; |
|
|
|
|
|
while (delta > Math.PI) delta -= Math.PI * 2; |
|
while (delta < -Math.PI) delta += Math.PI * 2; |
|
|
|
return current + delta * factor; |
|
} |
|
|
|
|
|
updateAIState(playerPosition) { |
|
const currentTime = Date.now(); |
|
const distanceToPlayer = this.mesh.position.distanceTo(playerPosition); |
|
|
|
if (currentTime - this.aiState.lastVisibilityCheck > this.aiState.visibilityCheckInterval) { |
|
this.aiState.canSeePlayer = this.checkLineOfSight(playerPosition); |
|
this.aiState.lastVisibilityCheck = currentTime; |
|
|
|
if (this.aiState.canSeePlayer) { |
|
this.aiState.lastKnownPlayerPosition = playerPosition.clone(); |
|
this.aiState.searchStartTime = null; |
|
} |
|
} |
|
|
|
const stateChangeCooldown = 2000; |
|
|
|
if (currentTime - this.aiState.lastStateChange > this.aiState.stateChangeCooldown) { |
|
if (this.health < 30) { |
|
this.aiState.mode = 'retreat'; |
|
} else if (distanceToPlayer < 30 && this.aiState.canSeePlayer) { |
|
this.aiState.mode = 'flank'; |
|
} else { |
|
this.aiState.mode = 'pursue'; |
|
} |
|
this.aiState.lastStateChange = currentTime; |
|
} |
|
} |
|
|
|
findPathToTarget(targetPosition) { |
|
const currentTime = Date.now(); |
|
if (currentTime - this.pathfinding.lastPathUpdate < this.pathfinding.pathUpdateInterval) { |
|
return; |
|
} |
|
|
|
this.pathfinding.currentPath = this.generatePathPoints(this.mesh.position.clone(), targetPosition); |
|
this.pathfinding.lastPathUpdate = currentTime; |
|
} |
|
|
|
generatePathPoints(start, end) { |
|
const points = []; |
|
const direction = new THREE.Vector3().subVectors(end, start).normalize(); |
|
const distance = start.distanceTo(end); |
|
const steps = Math.ceil(distance / 10); |
|
|
|
for (let i = 0; i <= steps; i++) { |
|
const point = start.clone().add(direction.multiplyScalar(i * 10)); |
|
points.push(point); |
|
} |
|
|
|
return points; |
|
} |
|
|
|
moveAlongPath() { |
|
if (this.pathfinding.currentPath.length === 0) return; |
|
|
|
const targetPoint = this.pathfinding.currentPath[0]; |
|
const direction = new THREE.Vector3() |
|
.subVectors(targetPoint, this.mesh.position) |
|
.normalize(); |
|
|
|
const moveVector = direction.multiplyScalar(this.moveSpeed); |
|
this.mesh.position.add(moveVector); |
|
|
|
if (this.mesh.position.distanceTo(targetPoint) < 2) { |
|
this.pathfinding.currentPath.shift(); |
|
} |
|
} |
|
|
|
calculateFlankPosition(playerPosition) { |
|
const angle = Math.random() * Math.PI * 2; |
|
const radius = 40; |
|
return new THREE.Vector3( |
|
playerPosition.x + Math.cos(angle) * radius, |
|
playerPosition.y, |
|
playerPosition.z + Math.sin(angle) * radius |
|
); |
|
} |
|
|
|
calculateRetreatPosition(playerPosition) { |
|
const direction = new THREE.Vector3() |
|
.subVectors(this.mesh.position, playerPosition) |
|
.normalize(); |
|
return this.mesh.position.clone().add(direction.multiplyScalar(50)); |
|
} |
|
|
|
adjustTankTilt() { |
|
const forwardVector = new THREE.Vector3(0, 0, 1).applyQuaternion(this.mesh.quaternion); |
|
const rightVector = new THREE.Vector3(1, 0, 0).applyQuaternion(this.mesh.quaternion); |
|
|
|
const frontHeight = window.gameInstance.getHeightAtPosition( |
|
this.mesh.position.x + forwardVector.x, |
|
this.mesh.position.z + forwardVector.z |
|
); |
|
const backHeight = window.gameInstance.getHeightAtPosition( |
|
this.mesh.position.x - forwardVector.x, |
|
this.mesh.position.z - forwardVector.z |
|
); |
|
const rightHeight = window.gameInstance.getHeightAtPosition( |
|
this.mesh.position.x + rightVector.x, |
|
this.mesh.position.z + rightVector.z |
|
); |
|
const leftHeight = window.gameInstance.getHeightAtPosition( |
|
this.mesh.position.x - rightVector.x, |
|
this.mesh.position.z - rightVector.z |
|
); |
|
|
|
const pitch = Math.atan2(frontHeight - backHeight, 2); |
|
const roll = Math.atan2(rightHeight - leftHeight, 2); |
|
|
|
const currentRotation = this.mesh.rotation.y; |
|
this.mesh.rotation.set(pitch, currentRotation, roll); |
|
} |
|
|
|
updateBullets() { |
|
for (let i = this.bullets.length - 1; i >= 0; i--) { |
|
const bullet = this.bullets[i]; |
|
bullet.position.add(bullet.velocity); |
|
|
|
if (Math.abs(bullet.position.x) > MAP_SIZE / 2 || |
|
Math.abs(bullet.position.z) > MAP_SIZE / 2) { |
|
this.scene.remove(bullet); |
|
this.bullets.splice(i, 1); |
|
continue; |
|
} |
|
|
|
const bulletBox = new THREE.Box3().setFromObject(bullet); |
|
for (const obstacle of window.gameInstance.obstacles) { |
|
const obstacleBox = new THREE.Box3().setFromObject(obstacle); |
|
if (bulletBox.intersectsBox(obstacleBox)) { |
|
this.scene.remove(bullet); |
|
this.bullets.splice(i, 1); |
|
break; |
|
} |
|
} |
|
} |
|
} |
|
|
|
createMuzzleFlash() { |
|
if (!this.mesh) return; |
|
|
|
const flashGroup = new THREE.Group(); |
|
|
|
const flameGeometry = new THREE.SphereGeometry(1.0, 8, 8); |
|
const flameMaterial = new THREE.MeshBasicMaterial({ |
|
color: 0xffa500, |
|
transparent: true, |
|
opacity: 0.8 |
|
}); |
|
const flame = new THREE.Mesh(flameGeometry, flameMaterial); |
|
flame.scale.set(2, 2, 3); |
|
flashGroup.add(flame); |
|
|
|
const smokeGeometry = new THREE.SphereGeometry(0.8, 8, 8); |
|
const smokeMaterial = new THREE.MeshBasicMaterial({ |
|
color: 0x555555, |
|
transparent: true, |
|
opacity: 0.5 |
|
}); |
|
|
|
for (let i = 0; i < 5; i++) { |
|
const smoke = new THREE.Mesh(smokeGeometry, smokeMaterial); |
|
smoke.position.set( |
|
Math.random() * 1 - 0.5, |
|
Math.random() * 1 - 0.5, |
|
-1 - Math.random() |
|
); |
|
smoke.scale.set(1.5, 1.5, 1.5); |
|
flashGroup.add(smoke); |
|
} |
|
|
|
const muzzleOffset = new THREE.Vector3(0, 0.5, 4); |
|
const muzzlePosition = new THREE.Vector3(); |
|
const meshWorldQuaternion = new THREE.Quaternion(); |
|
|
|
this.mesh.getWorldPosition(muzzlePosition); |
|
this.mesh.getWorldQuaternion(meshWorldQuaternion); |
|
|
|
muzzleOffset.applyQuaternion(meshWorldQuaternion); |
|
muzzlePosition.add(muzzleOffset); |
|
|
|
flashGroup.position.copy(muzzlePosition); |
|
flashGroup.quaternion.copy(meshWorldQuaternion); |
|
|
|
this.scene.add(flashGroup); |
|
|
|
setTimeout(() => { |
|
this.scene.remove(flashGroup); |
|
}, 500); |
|
} |
|
createMassiveExplosion() { |
|
|
|
const explosionGroup = new THREE.Group(); |
|
|
|
|
|
const flashGeometry = new THREE.SphereGeometry(4); |
|
const flashMaterial = new THREE.MeshBasicMaterial({ |
|
color: 0xffff00, |
|
transparent: true, |
|
opacity: 1 |
|
}); |
|
const flash = new THREE.Mesh(flashGeometry, flashMaterial); |
|
flash.position.copy(this.mesh.position); |
|
this.scene.add(flash); |
|
|
|
|
|
for (let i = 0; i < 50; i++) { |
|
const size = Math.random() * 1 + 0.5; |
|
const geometry = new THREE.SphereGeometry(size); |
|
|
|
|
|
const colors = [0xff4500, 0xff8c00, 0xff0000, 0xffd700]; |
|
const material = new THREE.MeshBasicMaterial({ |
|
color: colors[Math.floor(Math.random() * colors.length)], |
|
transparent: true, |
|
opacity: 1 |
|
}); |
|
|
|
const particle = new THREE.Mesh(geometry, material); |
|
particle.position.copy(this.mesh.position); |
|
|
|
|
|
const speed = Math.random() * 1 + 0.5; |
|
const angle = Math.random() * Math.PI * 2; |
|
const elevation = Math.random() * Math.PI - Math.PI / 2; |
|
|
|
particle.velocity = new THREE.Vector3( |
|
Math.cos(angle) * Math.cos(elevation) * speed, |
|
Math.sin(elevation) * speed, |
|
Math.sin(angle) * Math.cos(elevation) * speed |
|
); |
|
|
|
particle.gravity = -0.05; |
|
particle.life = Math.random() * 30 + 30; |
|
particle.fadeRate = 0.98; |
|
|
|
this.scene.add(particle); |
|
window.gameInstance.particles.push({ |
|
mesh: particle, |
|
velocity: particle.velocity, |
|
gravity: particle.gravity, |
|
life: particle.life, |
|
fadeRate: particle.fadeRate |
|
}); |
|
} |
|
|
|
|
|
for (let i = 0; i < 2; i++) { |
|
const ringGeometry = new THREE.RingGeometry(0.1, 2, 32); |
|
const ringMaterial = new THREE.MeshBasicMaterial({ |
|
color: 0xff8c00, |
|
transparent: true, |
|
opacity: 1, |
|
side: THREE.DoubleSide |
|
}); |
|
const ring = new THREE.Mesh(ringGeometry, ringMaterial); |
|
ring.position.copy(this.mesh.position); |
|
ring.rotation.x = Math.random() * Math.PI; |
|
ring.rotation.y = Math.random() * Math.PI; |
|
this.scene.add(ring); |
|
|
|
|
|
const expandRing = () => { |
|
ring.scale.x += 0.15; |
|
ring.scale.y += 0.15; |
|
ring.material.opacity *= 0.96; |
|
|
|
if (ring.material.opacity > 0.01) { |
|
requestAnimationFrame(expandRing); |
|
} else { |
|
this.scene.remove(ring); |
|
} |
|
}; |
|
expandRing(); |
|
} |
|
|
|
|
|
const fireColumn = new THREE.Group(); |
|
for (let i = 0; i < 10; i++) { |
|
const fireGeometry = new THREE.ConeGeometry(1, 4, 8); |
|
const fireMaterial = new THREE.MeshBasicMaterial({ |
|
color: 0xff4500, |
|
transparent: true, |
|
opacity: 0.8 |
|
}); |
|
const fire = new THREE.Mesh(fireGeometry, fireMaterial); |
|
fire.position.y = i * 0.3; |
|
fire.rotation.x = Math.random() * Math.PI; |
|
fire.rotation.z = Math.random() * Math.PI; |
|
fireColumn.add(fire); |
|
} |
|
fireColumn.position.copy(this.mesh.position); |
|
this.scene.add(fireColumn); |
|
|
|
|
|
const animateFireColumn = () => { |
|
fireColumn.scale.y += 0.05; |
|
fireColumn.children.forEach(fire => { |
|
fire.material.opacity *= 0.95; |
|
}); |
|
|
|
if (fireColumn.children[0].material.opacity > 0.01) { |
|
requestAnimationFrame(animateFireColumn); |
|
} else { |
|
this.scene.remove(fireColumn); |
|
} |
|
}; |
|
animateFireColumn(); |
|
|
|
|
|
const explosionSounds = [ |
|
new Audio('sounds/explosion.ogg'), |
|
new Audio('sounds/bang.ogg') |
|
]; |
|
explosionSounds.forEach(sound => { |
|
sound.volume = 0.5; |
|
sound.play(); |
|
}); |
|
|
|
|
|
if (window.gameInstance && window.gameInstance.camera) { |
|
const camera = window.gameInstance.camera; |
|
const originalPosition = camera.position.clone(); |
|
let shakeTime = 0; |
|
const shakeIntensity = 0.5; |
|
const shakeDuration = 500; |
|
|
|
const shakeCamera = () => { |
|
if (shakeTime < shakeDuration) { |
|
camera.position.x = originalPosition.x + (Math.random() - 0.5) * shakeIntensity; |
|
camera.position.y = originalPosition.y + (Math.random() - 0.5) * shakeIntensity; |
|
camera.position.z = originalPosition.z + (Math.random() - 0.5) * shakeIntensity; |
|
|
|
shakeTime += 16; |
|
requestAnimationFrame(shakeCamera); |
|
} else { |
|
camera.position.copy(originalPosition); |
|
} |
|
}; |
|
shakeCamera(); |
|
} |
|
|
|
|
|
setTimeout(() => { |
|
this.scene.remove(flash); |
|
}, 100); |
|
} |
|
|
|
|
|
shoot(playerPosition) { |
|
const currentTime = Date.now(); |
|
const attackInterval = this.type === 'tank' ? |
|
ENEMY_CONFIG.ATTACK_INTERVAL : |
|
ENEMY_CONFIG.ATTACK_INTERVAL * 1.5; |
|
|
|
if (currentTime - this.lastAttackTime < attackInterval) return; |
|
|
|
|
|
const directionToPlayer = new THREE.Vector3() |
|
.subVectors(playerPosition, this.mesh.position) |
|
.normalize(); |
|
const forwardDirection = new THREE.Vector3(0, 0, 1) |
|
.applyQuaternion(this.mesh.quaternion) |
|
.normalize(); |
|
|
|
const dotProduct = forwardDirection.dot(directionToPlayer); |
|
const angleToPlayer = Math.acos(dotProduct); |
|
|
|
|
|
const attackAngleThreshold = Math.PI / 8; |
|
if (angleToPlayer > attackAngleThreshold) return; |
|
|
|
this.createMuzzleFlash(); |
|
|
|
const enemyFireSound = new Audio('sounds/mbtfire5.ogg'); |
|
enemyFireSound.volume = 0.3; |
|
enemyFireSound.play(); |
|
|
|
const bulletGeometry = new THREE.CylinderGeometry(0.2, 0.2, 2, 8); |
|
const bulletMaterial = new THREE.MeshBasicMaterial({ |
|
color: 0xff0000, |
|
emissive: 0xff0000, |
|
emissiveIntensity: 0.5 |
|
}); |
|
const bullet = new THREE.Mesh(bulletGeometry, bulletMaterial); |
|
|
|
const muzzleOffset = new THREE.Vector3(0, 0.5, 4); |
|
const muzzlePosition = new THREE.Vector3(); |
|
this.mesh.getWorldPosition(muzzlePosition); |
|
muzzleOffset.applyQuaternion(this.mesh.quaternion); |
|
muzzlePosition.add(muzzleOffset); |
|
|
|
bullet.position.copy(muzzlePosition); |
|
bullet.quaternion.copy(this.mesh.quaternion); |
|
|
|
const direction = new THREE.Vector3() |
|
.subVectors(playerPosition, muzzlePosition) |
|
.normalize(); |
|
|
|
const bulletSpeed = this.type === 'tank' ? |
|
ENEMY_CONFIG.BULLET_SPEED : |
|
ENEMY_CONFIG.BULLET_SPEED * 0.8; |
|
|
|
bullet.velocity = direction.multiplyScalar(bulletSpeed); |
|
|
|
const trailGeometry = new THREE.CylinderGeometry(0.1, 0.1, 1, 8); |
|
const trailMaterial = new THREE.MeshBasicMaterial({ |
|
color: 0xff4444, |
|
transparent: true, |
|
opacity: 0.5 |
|
}); |
|
|
|
const trail = new THREE.Mesh(trailGeometry, trailMaterial); |
|
trail.position.z = -1; |
|
bullet.add(trail); |
|
|
|
this.scene.add(bullet); |
|
this.bullets.push(bullet); |
|
this.lastAttackTime = currentTime; |
|
} |
|
|
|
takeDamage(damage) { |
|
this.health -= damage; |
|
|
|
|
|
if (this.health <= 0) { |
|
const deathSound = new Audio('sounds/bang.ogg'); |
|
deathSound.play(); |
|
return true; |
|
} |
|
return false; |
|
} |
|
destroy() { |
|
if (this.mesh) { |
|
this.scene.remove(this.mesh); |
|
this.bullets.forEach(bullet => this.scene.remove(bullet)); |
|
this.bullets = []; |
|
this.isLoaded = false; |
|
} |
|
} |
|
} |
|
|
|
|
|
class Particle { |
|
constructor(scene, position) { |
|
const geometry = new THREE.SphereGeometry(0.1); |
|
const material = new THREE.MeshBasicMaterial({ color: 0xff0000 }); |
|
this.mesh = new THREE.Mesh(geometry, material); |
|
this.mesh.position.copy(position); |
|
|
|
this.velocity = new THREE.Vector3( |
|
(Math.random() - 0.5) * 0.3, |
|
Math.random() * 0.2, |
|
(Math.random() - 0.5) * 0.3 |
|
); |
|
|
|
this.gravity = -0.01; |
|
this.lifetime = 60; |
|
this.age = 0; |
|
|
|
scene.add(this.mesh); |
|
} |
|
|
|
update() { |
|
this.velocity.y += this.gravity; |
|
this.mesh.position.add(this.velocity); |
|
this.age++; |
|
return this.age < this.lifetime; |
|
} |
|
|
|
destroy(scene) { |
|
scene.remove(this.mesh); |
|
} |
|
} |
|
|
|
|
|
class Game { |
|
constructor() { |
|
|
|
this.isStarted = false; |
|
|
|
this.bgmPlaying = false; |
|
this.bgm = null; |
|
this.engineSound = null; |
|
this.engineStopSound = null; |
|
this.isEngineRunning = false; |
|
this.scene = new THREE.Scene(); |
|
this.camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000); |
|
this.renderer = new THREE.WebGLRenderer({ antialias: true }); |
|
this.renderer.setSize(window.innerWidth, window.innerHeight); |
|
this.renderer.shadowMap.enabled = true; |
|
this.renderer.shadowMap.type = THREE.PCFSoftShadowMap; |
|
this.renderer.outputColorSpace = THREE.SRGBColorSpace; |
|
this.enemyLabels = new Map(); |
|
this.raycaster = new THREE.Raycaster(); |
|
this.crosshair = document.getElementById('crosshair'); |
|
document.getElementById('gameContainer').appendChild(this.renderer.domElement); |
|
|
|
|
|
|
|
this.radarUpdateInterval = 100; |
|
this.lastRadarUpdate = 0; |
|
this.radarRange = 200; |
|
|
|
this.tank = new TankPlayer(); |
|
this.enemies = []; |
|
this.particles = []; |
|
this.buildings = []; |
|
this.loader = new GLTFLoader(); |
|
this.controls = null; |
|
this.gameTime = GAME_DURATION; |
|
this.score = 0; |
|
this.isGameOver = false; |
|
this.isLoading = true; |
|
this.previousTankPosition = new THREE.Vector3(); |
|
this.lastTime = performance.now(); |
|
this.gameTimer = null; |
|
this.animationFrameId = null; |
|
this.lastAudio = null; |
|
|
|
this.mouse = { x: 0, y: 0 }; |
|
this.keys = { |
|
forward: false, |
|
backward: false, |
|
left: false, |
|
right: false |
|
}; |
|
|
|
this.setupEventListeners(); |
|
this.initialize(); |
|
this.obstacles = []; |
|
} |
|
setupScene() { |
|
|
|
this.scene.background = new THREE.Color(0x87CEEB); |
|
|
|
|
|
this.scene.fog = new THREE.FogExp2(0x87CEEB, 0.0008); |
|
|
|
|
|
this.renderer.shadowMap.enabled = true; |
|
this.renderer.shadowMap.type = THREE.PCFSoftShadowMap; |
|
this.renderer.setPixelRatio(window.devicePixelRatio); |
|
|
|
|
|
this.camera.position.set(0, 15, -30); |
|
this.camera.lookAt(0, 0, 0); |
|
|
|
|
|
const mapBoundary = MAP_SIZE / 2; |
|
this.sceneBounds = { |
|
minX: -mapBoundary, |
|
maxX: mapBoundary, |
|
minZ: -mapBoundary, |
|
maxZ: mapBoundary |
|
}; |
|
} |
|
|
|
async initialize() { |
|
try { |
|
|
|
if (!this.bgmPlaying && !this.bgm) { |
|
this.bgm = new Audio('sounds/BGM.ogg'); |
|
this.bgm.volume = 0.5; |
|
this.bgm.loop = true; |
|
this.bgm.play(); |
|
this.bgmPlaying = true; |
|
} |
|
|
|
|
|
const startSounds = ['sounds/start1.ogg', 'sounds/start2.ogg', 'sounds/start3.ogg']; |
|
const randomStartSound = startSounds[Math.floor(Math.random() * startSounds.length)]; |
|
const startAudio = new Audio(randomStartSound); |
|
startAudio.volume = 0.5; |
|
startAudio.play(); |
|
|
|
|
|
this.renderer.shadowMap.enabled = true; |
|
this.renderer.shadowMap.type = THREE.PCFSoftShadowMap; |
|
this.renderer.outputColorSpace = THREE.SRGBColorSpace; |
|
|
|
|
|
this.setupScene(); |
|
|
|
|
|
this.scene.fog = new THREE.FogExp2(0x87CEEB, 0.0008); |
|
this.scene.background = new THREE.Color(0x87CEEB); |
|
|
|
|
|
|
|
const ambientLight = new THREE.AmbientLight(0xffffff, 0.4); |
|
this.scene.add(ambientLight); |
|
|
|
|
|
const mainLight = new THREE.DirectionalLight(0xffffff, 1.0); |
|
mainLight.position.set(MAP_SIZE/2, MAP_SIZE/2, MAP_SIZE/2); |
|
mainLight.castShadow = true; |
|
|
|
|
|
mainLight.shadow.mapSize.width = 4096; |
|
mainLight.shadow.mapSize.height = 4096; |
|
mainLight.shadow.camera.near = 0.5; |
|
mainLight.shadow.camera.far = MAP_SIZE * 2; |
|
mainLight.shadow.camera.left = -MAP_SIZE; |
|
mainLight.shadow.camera.right = MAP_SIZE; |
|
mainLight.shadow.camera.top = MAP_SIZE; |
|
mainLight.shadow.camera.bottom = -MAP_SIZE; |
|
mainLight.shadow.bias = -0.001; |
|
mainLight.shadow.radius = 2; |
|
mainLight.shadow.normalBias = 0.02; |
|
|
|
this.scene.add(mainLight); |
|
|
|
|
|
const secondaryLight = new THREE.DirectionalLight(0xffffff, 0.3); |
|
secondaryLight.position.set(-50, 50, -50); |
|
this.scene.add(secondaryLight); |
|
|
|
|
|
const hemisphereLight = new THREE.HemisphereLight( |
|
0x87CEEB, |
|
0xFFE87C, |
|
0.3 |
|
); |
|
this.scene.add(hemisphereLight); |
|
|
|
const groundGeometry = new THREE.PlaneGeometry(MAP_SIZE, MAP_SIZE, 100, 100); |
|
const groundMaterial = new THREE.MeshStandardMaterial({ |
|
color: 0xD2B48C, |
|
roughness: 0.8, |
|
metalness: 0.2, |
|
envMapIntensity: 1.0 |
|
}); |
|
|
|
const ground = new THREE.Mesh(groundGeometry, groundMaterial); |
|
ground.rotation.x = -Math.PI / 2; |
|
ground.receiveShadow = true; |
|
|
|
|
|
const vertices = ground.geometry.attributes.position.array; |
|
for (let i = 0; i < vertices.length; i += 3) { |
|
vertices[i + 2] = 0; |
|
} |
|
|
|
ground.geometry.attributes.position.needsUpdate = true; |
|
ground.geometry.computeVertexNormals(); |
|
this.ground = ground; |
|
this.scene.add(ground); |
|
|
|
|
|
const gridHelper = new THREE.GridHelper(MAP_SIZE, 50, 0x000000, 0x000000); |
|
gridHelper.material.opacity = 0.1; |
|
gridHelper.material.transparent = true; |
|
gridHelper.position.y = 0.1; |
|
this.scene.add(gridHelper); |
|
|
|
|
|
await this.addDesertDecorations(); |
|
|
|
|
|
await this.tank.initialize(this.scene, this.loader); |
|
if (!this.tank.isLoaded) { |
|
throw new Error('Tank loading failed'); |
|
} |
|
|
|
|
|
const spawnPos = this.findValidSpawnPosition(); |
|
const heightAtSpawn = this.getHeightAtPosition(spawnPos.x, spawnPos.z); |
|
const slopeCheckPoints = [ |
|
{ x: spawnPos.x + 2, z: spawnPos.z }, |
|
{ x: spawnPos.x - 2, z: spawnPos.z }, |
|
{ x: spawnPos.x, z: spawnPos.z + 2 }, |
|
{ x: spawnPos.x, z: spawnPos.z - 2 } |
|
]; |
|
|
|
const slopes = slopeCheckPoints.map(point => { |
|
const pointHeight = this.getHeightAtPosition(point.x, point.z); |
|
return Math.abs(pointHeight - heightAtSpawn) / 2; |
|
}); |
|
|
|
const maxSlope = Math.max(...slopes); |
|
if (maxSlope > 0.3) { |
|
location.reload(); |
|
return; |
|
} |
|
|
|
|
|
const tankPosition = this.tank.getPosition(); |
|
this.camera.position.set( |
|
tankPosition.x, |
|
tankPosition.y + 15, |
|
tankPosition.z - 30 |
|
); |
|
this.camera.lookAt(tankPosition); |
|
|
|
|
|
this.isLoading = false; |
|
document.getElementById('loading').style.display = 'none'; |
|
|
|
|
|
this.animate(); |
|
this.spawnEnemies(); |
|
this.startGameTimer(); |
|
|
|
} catch (error) { |
|
console.error('Game initialization error:', error); |
|
this.handleLoadingError(); |
|
} |
|
} |
|
|
|
|
|
updateRadar() { |
|
const currentTime = Date.now(); |
|
if (currentTime - this.lastRadarUpdate < this.radarUpdateInterval) return; |
|
|
|
const radar = document.getElementById('radar'); |
|
const radarRect = radar.getBoundingClientRect(); |
|
const radarCenter = { |
|
x: radarRect.width / 2, |
|
y: radarRect.height / 2 |
|
}; |
|
|
|
|
|
const oldDots = radar.getElementsByClassName('enemy-dot'); |
|
while (oldDots[0]) { |
|
oldDots[0].remove(); |
|
} |
|
|
|
|
|
const tankPos = this.tank.getPosition(); |
|
|
|
|
|
this.enemies.forEach(enemy => { |
|
if (!enemy.mesh || !enemy.isLoaded) return; |
|
|
|
const enemyPos = enemy.mesh.position; |
|
const distance = tankPos.distanceTo(enemyPos); |
|
|
|
|
|
if (distance <= this.radarRange) { |
|
|
|
const angle = Math.atan2( |
|
enemyPos.x - tankPos.x, |
|
enemyPos.z - tankPos.z |
|
); |
|
|
|
|
|
const relativeDistance = distance / this.radarRange; |
|
const dotX = radarCenter.x + Math.sin(angle) * (radarCenter.x * relativeDistance); |
|
const dotY = radarCenter.y + Math.cos(angle) * (radarCenter.y * relativeDistance); |
|
|
|
|
|
const dot = document.createElement('div'); |
|
dot.className = 'enemy-dot'; |
|
dot.style.left = `${dotX}px`; |
|
dot.style.top = `${dotY}px`; |
|
radar.appendChild(dot); |
|
} |
|
}); |
|
|
|
this.lastRadarUpdate = currentTime; |
|
} |
|
|
|
async addDesertDecorations() { |
|
if (!this.obstacles) { |
|
this.obstacles = []; |
|
} |
|
|
|
const BUILDING_COUNT = 25; |
|
const buildingModels = [ |
|
'models/house1.glb', |
|
'models/house2.glb', |
|
'models/house3.glb', |
|
'models/house4.glb' |
|
]; |
|
|
|
|
|
const collisionBoxMaterial = new THREE.LineBasicMaterial({ |
|
color: 0xff0000, |
|
linewidth: 2 |
|
}); |
|
|
|
|
|
const playerCollisionRadius = 2; |
|
const clearanceBuffer = 1; |
|
const minimumDistance = playerCollisionRadius * 2 + clearanceBuffer; |
|
|
|
for (let i = 0; i < BUILDING_COUNT; i++) { |
|
try { |
|
const modelPath = buildingModels[Math.floor(Math.random() * buildingModels.length)]; |
|
const result = await this.loader.loadAsync(modelPath); |
|
const building = result.scene; |
|
|
|
let x, z; |
|
const edgeSpawn = Math.random() < 0.7; |
|
|
|
if (edgeSpawn) { |
|
if (Math.random() < 0.5) { |
|
x = (Math.random() < 0.5 ? -1 : 1) * (MAP_SIZE * 0.4 + Math.random() * MAP_SIZE * 0.1); |
|
z = (Math.random() - 0.5) * MAP_SIZE * 0.9; |
|
} else { |
|
x = (Math.random() - 0.5) * MAP_SIZE * 0.9; |
|
z = (Math.random() < 0.5 ? -1 : 1) * (MAP_SIZE * 0.4 + Math.random() * MAP_SIZE * 0.1); |
|
} |
|
} else { |
|
x = (Math.random() - 0.5) * MAP_SIZE * 0.6; |
|
z = (Math.random() - 0.5) * MAP_SIZE * 0.6; |
|
} |
|
|
|
building.position.set(x, 0, z); |
|
building.rotation.y = Math.random() * Math.PI * 2; |
|
building.scale.set(1, 1, 1); |
|
|
|
|
|
building.traverse((child) => { |
|
if (child.isMesh) { |
|
child.castShadow = true; |
|
child.receiveShadow = true; |
|
} |
|
}); |
|
|
|
|
|
const boundingBox = new THREE.Box3().setFromObject(building); |
|
const boxSize = boundingBox.getSize(new THREE.Vector3()); |
|
|
|
|
|
const boxGeometry = new THREE.BufferGeometry(); |
|
const positions = new Float32Array([ |
|
|
|
-boxSize.x / 2, 0.1, -boxSize.z / 2, |
|
-boxSize.x / 2, 0.1, boxSize.z / 2, |
|
boxSize.x / 2, 0.1, boxSize.z / 2, |
|
boxSize.x / 2, 0.1, -boxSize.z / 2, |
|
-boxSize.x / 2, 0.1, -boxSize.z / 2 |
|
]); |
|
|
|
boxGeometry.setAttribute('position', new THREE.BufferAttribute(positions, 3)); |
|
const collisionBoxLine = new THREE.Line(boxGeometry, collisionBoxMaterial); |
|
collisionBoxLine.position.copy(building.position); |
|
collisionBoxLine.rotation.y = building.rotation.y; |
|
|
|
|
|
building.userData.isCollidable = true; |
|
building.userData.type = 'building'; |
|
building.userData.collisionBox = collisionBoxLine; |
|
|
|
let tooClose = false; |
|
for (const obstacle of this.obstacles) { |
|
const distance = building.position.distanceTo(obstacle.position); |
|
if (distance < minimumDistance) { |
|
tooClose = true; |
|
break; |
|
} |
|
} |
|
|
|
if (!tooClose) { |
|
this.obstacles.push(building); |
|
this.scene.add(building); |
|
this.scene.add(collisionBoxLine); |
|
} |
|
|
|
} catch (error) { |
|
console.error('Error loading building model:', error); |
|
} |
|
} |
|
|
|
|
|
const cactusGeometry = new THREE.CylinderGeometry(0.5, 0.7, 4, 8); |
|
const cactusMaterial = new THREE.MeshStandardMaterial({ |
|
color: 0x2F4F2F, |
|
roughness: 0.8 |
|
}); |
|
|
|
for (let i = 0; i < 50; i++) { |
|
const cactus = new THREE.Mesh(cactusGeometry, cactusMaterial); |
|
cactus.position.set( |
|
(Math.random() - 0.5) * MAP_SIZE * 0.8, |
|
2, |
|
(Math.random() - 0.5) * MAP_SIZE * 0.8 |
|
); |
|
cactus.castShadow = true; |
|
cactus.receiveShadow = true; |
|
cactus.userData.isCollidable = false; |
|
cactus.userData.type = 'cactus'; |
|
|
|
this.scene.add(cactus); |
|
} |
|
} |
|
|
|
|
|
|
|
getHeightAtPosition(x, z) { |
|
return 0; |
|
} |
|
|
|
findValidSpawnPosition() { |
|
const margin = 50; |
|
let position; |
|
let attempts = 0; |
|
const maxAttempts = 50; |
|
const maxSlope = 0.3; |
|
|
|
while (attempts < maxAttempts) { |
|
position = new THREE.Vector3( |
|
(Math.random() - 0.5) * (MAP_SIZE - margin * 2), |
|
0, |
|
(Math.random() - 0.5) * (MAP_SIZE - margin * 2) |
|
); |
|
|
|
|
|
const height = this.getHeightAtPosition(position.x, position.z); |
|
position.y = height + TANK_HEIGHT; |
|
|
|
|
|
const checkPoints = [ |
|
{ x: position.x + 2, z: position.z }, |
|
{ x: position.x - 2, z: position.z }, |
|
{ x: position.x, z: position.z + 2 }, |
|
{ x: position.x, z: position.z - 2 } |
|
]; |
|
|
|
const slopes = checkPoints.map(point => { |
|
const pointHeight = this.getHeightAtPosition(point.x, point.z); |
|
return Math.abs(pointHeight - height) / 2; |
|
}); |
|
|
|
const maxCurrentSlope = Math.max(...slopes); |
|
|
|
if (maxCurrentSlope <= maxSlope) { |
|
return position; |
|
} |
|
|
|
attempts++; |
|
} |
|
|
|
|
|
return new THREE.Vector3(0, TANK_HEIGHT, 0); |
|
} |
|
|
|
setupEventListeners() { |
|
document.addEventListener('keydown', (event) => { |
|
if (this.isLoading || this.isGameOver) return; |
|
switch(event.code) { |
|
case 'KeyW': this.keys.forward = true; break; |
|
case 'KeyS': this.keys.backward = true; break; |
|
case 'KeyA': this.keys.left = true; break; |
|
case 'KeyD': this.keys.right = true; break; |
|
} |
|
}); |
|
|
|
document.addEventListener('keyup', (event) => { |
|
if (this.isLoading || this.isGameOver) return; |
|
switch(event.code) { |
|
case 'KeyW': this.keys.forward = false; break; |
|
case 'KeyS': this.keys.backward = false; break; |
|
case 'KeyA': this.keys.left = false; break; |
|
case 'KeyD': this.keys.right = false; break; |
|
} |
|
}); |
|
|
|
document.addEventListener('mousemove', (event) => { |
|
if (this.isLoading || this.isGameOver || !document.pointerLockElement) return; |
|
|
|
const movementX = event.movementX || event.mozMovementX || event.webkitMovementX || 0; |
|
this.mouse.x += movementX * 0.002; |
|
this.mouse.y = 0; |
|
|
|
while (this.mouse.x > Math.PI) this.mouse.x -= Math.PI * 2; |
|
while (this.mouse.x < -Math.PI) this.mouse.x += Math.PI * 2; |
|
}); |
|
|
|
document.addEventListener('click', () => { |
|
if (!document.pointerLockElement) { |
|
document.body.requestPointerLock(); |
|
} else if (!this.isGameOver && this.tank && this.tank.isLoaded) { |
|
const bullet = this.tank.shoot(this.scene); |
|
if (bullet) { |
|
|
|
} |
|
} |
|
}); |
|
|
|
document.addEventListener('pointerlockchange', () => { |
|
if (!document.pointerLockElement) { |
|
this.mouse.x = 0; |
|
this.mouse.y = 0; |
|
} |
|
}); |
|
|
|
window.addEventListener('resize', () => { |
|
this.camera.aspect = window.innerWidth / window.innerHeight; |
|
this.camera.updateProjectionMatrix(); |
|
this.renderer.setSize(window.innerWidth, window.innerHeight); |
|
}); |
|
} |
|
handleMovement() { |
|
if (!this.tank.isLoaded || this.isGameOver) return; |
|
|
|
const direction = new THREE.Vector3(); |
|
const isMoving = this.keys.forward || this.keys.backward; |
|
|
|
if (isMoving && !this.isEngineRunning) { |
|
this.isEngineRunning = true; |
|
|
|
|
|
if (this.engineStopSound) { |
|
this.engineStopSound.pause(); |
|
} |
|
if (this.engineSound) { |
|
this.engineSound.pause(); |
|
} |
|
|
|
|
|
this.engineStopSound = new Audio('sounds/engine.ogg'); |
|
this.engineStopSound.play(); |
|
|
|
|
|
this.engineStopSound.onended = () => { |
|
this.engineSound = new Audio('sounds/engine.ogg'); |
|
this.engineSound.loop = true; |
|
this.engineSound.play(); |
|
}; |
|
} |
|
|
|
else if (!isMoving && this.isEngineRunning) { |
|
this.isEngineRunning = false; |
|
if (this.engineSound) { |
|
this.engineSound.pause(); |
|
this.engineSound = null; |
|
} |
|
if (this.engineStopSound) { |
|
this.engineStopSound.pause(); |
|
this.engineStopSound = null; |
|
} |
|
const stopSound = new Audio('sounds/enginestop.ogg'); |
|
stopSound.play(); |
|
} |
|
|
|
|
|
|
|
if (this.keys.forward) direction.z += 1; |
|
if (this.keys.backward) direction.z -= 1; |
|
if (this.keys.left) this.tank.rotate(-1); |
|
if (this.keys.right) this.tank.rotate(1); |
|
|
|
if (direction.length() > 0) { |
|
direction.normalize(); |
|
direction.applyEuler(this.tank.body.rotation); |
|
this.tank.move(direction); |
|
} |
|
|
|
|
|
const tankPos = this.tank.getPosition(); |
|
|
|
|
|
const cameraDistance = 15; |
|
const cameraHeight = 8; |
|
const cameraAngle = this.mouse.x + Math.PI; |
|
|
|
const cameraX = tankPos.x + Math.sin(cameraAngle) * cameraDistance; |
|
const cameraZ = tankPos.z + Math.cos(cameraAngle) * cameraDistance; |
|
|
|
this.camera.position.set( |
|
cameraX, |
|
tankPos.y + cameraHeight, |
|
cameraZ |
|
); |
|
|
|
|
|
const lookAtPoint = new THREE.Vector3( |
|
tankPos.x, |
|
tankPos.y + 1, |
|
tankPos.z |
|
); |
|
this.camera.lookAt(lookAtPoint); |
|
} |
|
|
|
createBuildings() { |
|
const buildingTypes = [ |
|
{ width: 10, height: 30, depth: 10, color: 0x808080 }, |
|
{ width: 15, height: 40, depth: 15, color: 0x606060 }, |
|
{ width: 20, height: 50, depth: 20, color: 0x404040 } |
|
]; |
|
|
|
for (let i = 0; i < BUILDING_COUNT; i++) { |
|
const type = buildingTypes[Math.floor(Math.random() * buildingTypes.length)]; |
|
const building = this.createBuilding(type); |
|
|
|
let position; |
|
let attempts = 0; |
|
do { |
|
position = new THREE.Vector3( |
|
(Math.random() - 0.5) * (MAP_SIZE - type.width), |
|
type.height / 2, |
|
(Math.random() - 0.5) * (MAP_SIZE - type.depth) |
|
); |
|
attempts++; |
|
} while (this.checkBuildingCollision(position, type) && attempts < 50); |
|
|
|
if (attempts < 50) { |
|
building.position.copy(position); |
|
this.buildings.push(building); |
|
this.scene.add(building); |
|
} |
|
} |
|
return Promise.resolve(); |
|
} |
|
|
|
createBuilding(type) { |
|
const geometry = new THREE.BoxGeometry(type.width, type.height, type.depth); |
|
const material = new THREE.MeshPhongMaterial({ |
|
color: type.color, |
|
emissive: 0x222222, |
|
specular: 0x111111, |
|
shininess: 30 |
|
}); |
|
const building = new THREE.Mesh(geometry, material); |
|
building.castShadow = true; |
|
building.receiveShadow = true; |
|
return building; |
|
} |
|
checkBuildingCollision(position, type) { |
|
const margin = 5; |
|
const bbox = new THREE.Box3( |
|
new THREE.Vector3( |
|
position.x - (type.width / 2 + margin), |
|
0, |
|
position.z - (type.depth / 2 + margin) |
|
), |
|
new THREE.Vector3( |
|
position.x + (type.width / 2 + margin), |
|
type.height, |
|
position.z + (type.depth / 2 + margin) |
|
) |
|
); |
|
|
|
return this.buildings.some(building => { |
|
const buildingBox = new THREE.Box3().setFromObject(building); |
|
return bbox.intersectsBox(buildingBox); |
|
}); |
|
} |
|
|
|
handleLoadingError() { |
|
this.isLoading = false; |
|
const loadingElement = document.getElementById('loading'); |
|
if (loadingElement) { |
|
loadingElement.innerHTML = ` |
|
<div class="loading-text" style="color: red;"> |
|
Loading failed. Please refresh the page. |
|
</div> |
|
`; |
|
} |
|
} |
|
|
|
startGameTimer() { |
|
if (this.gameTimer) { |
|
clearInterval(this.gameTimer); |
|
} |
|
|
|
this.gameTimer = setInterval(() => { |
|
if (this.isLoading || this.isGameOver) { |
|
clearInterval(this.gameTimer); |
|
return; |
|
} |
|
|
|
this.gameTime--; |
|
document.getElementById('time').textContent = `Time: ${this.gameTime}s`; |
|
|
|
if (this.gameTime <= 0) { |
|
clearInterval(this.gameTimer); |
|
this.endGame(true); |
|
} |
|
}, 1000); |
|
} |
|
|
|
|
|
spawnEnemies() { |
|
const spawnEnemy = () => { |
|
if (this.enemies.length < 3 && !this.isGameOver) { |
|
const position = this.getValidEnemySpawnPosition(); |
|
if (position) { |
|
const type = Math.random() < 0.7 ? 'tank' : 'heavy'; |
|
const enemy = new Enemy(this.scene, position, type); |
|
enemy.initialize(this.loader); |
|
this.enemies.push(enemy); |
|
} |
|
} |
|
if (!this.isGameOver) { |
|
setTimeout(spawnEnemy, 10000); |
|
} |
|
}; |
|
|
|
spawnEnemy(); |
|
} |
|
|
|
getValidEnemySpawnPosition() { |
|
const margin = 50; |
|
let position; |
|
let attempts = 0; |
|
const maxAttempts = 50; |
|
const maxSlope = 0.3; |
|
|
|
do { |
|
position = new THREE.Vector3( |
|
(Math.random() - 0.5) * (MAP_SIZE - margin * 2), |
|
0, |
|
(Math.random() - 0.5) * (MAP_SIZE - margin * 2) |
|
); |
|
|
|
const height = this.getHeightAtPosition(position.x, position.z); |
|
position.y = height + TANK_HEIGHT; |
|
|
|
|
|
const checkPoints = [ |
|
{ x: position.x + 2, z: position.z }, |
|
{ x: position.x - 2, z: position.z }, |
|
{ x: position.x, z: position.z + 2 }, |
|
{ x: position.x, z: position.z - 2 } |
|
]; |
|
|
|
const slopes = checkPoints.map(point => { |
|
const pointHeight = this.getHeightAtPosition(point.x, point.z); |
|
return Math.abs(pointHeight - height) / 2; |
|
}); |
|
|
|
const maxCurrentSlope = Math.max(...slopes); |
|
|
|
|
|
const distanceToPlayer = position.distanceTo(this.tank.getPosition()); |
|
if (distanceToPlayer > 100 && maxCurrentSlope <= maxSlope) { |
|
return position; |
|
} |
|
|
|
attempts++; |
|
} while (attempts < maxAttempts); |
|
|
|
return null; |
|
} |
|
updateParticles() { |
|
for (let i = this.particles.length - 1; i >= 0; i--) { |
|
const particle = this.particles[i]; |
|
|
|
|
|
particle.velocity.y += particle.gravity; |
|
particle.mesh.position.add(particle.velocity); |
|
|
|
|
|
particle.mesh.material.opacity -= particle.fadeRate; |
|
particle.life--; |
|
|
|
|
|
if (particle.life <= 0 || particle.mesh.material.opacity <= 0) { |
|
this.scene.remove(particle.mesh); |
|
this.particles.splice(i, 1); |
|
} |
|
} |
|
} |
|
|
|
createExplosion(position) { |
|
for (let i = 0; i < PARTICLE_COUNT; i++) { |
|
this.particles.push(new Particle(this.scene, position)); |
|
} |
|
} |
|
|
|
checkCollisions() { |
|
if (this.isLoading || !this.tank.isLoaded) return; |
|
|
|
|
|
const hitSounds = [ |
|
'sounds/hit1.ogg', 'sounds/hit2.ogg', 'sounds/hit3.ogg', |
|
'sounds/hit4.ogg', 'sounds/hit5.ogg', 'sounds/hit6.ogg', 'sounds/hit7.ogg' |
|
]; |
|
|
|
|
|
const beatSounds = ['sounds/beat1.ogg', 'sounds/beat2.ogg', 'sounds/beat3.ogg']; |
|
|
|
const tankPosition = this.tank.getPosition(); |
|
const tankBoundingBox = new THREE.Box3().setFromObject(this.tank.body); |
|
|
|
const previousPosition = this.tank.body.position.clone(); |
|
|
|
|
|
this.obstacles.forEach(obstacle => { |
|
if (obstacle.userData.isCollidable) { |
|
const obstacleBoundingBox = new THREE.Box3().setFromObject(obstacle); |
|
const tankBoundingBox = new THREE.Box3().setFromObject(this.tank.body); |
|
|
|
if (tankBoundingBox.intersectsBox(obstacleBoundingBox)) { |
|
|
|
const collisionNormal = new THREE.Vector3() |
|
.subVectors(this.tank.body.position, obstacle.position) |
|
.normalize(); |
|
|
|
const correctionVector = collisionNormal.multiplyScalar(0.5); |
|
this.tank.body.position.add(correctionVector); |
|
|
|
|
|
this.previousTankPosition.copy(previousPosition); |
|
} |
|
} |
|
}); |
|
|
|
|
|
this.enemies.forEach(enemy => { |
|
if (!enemy.mesh || !enemy.isLoaded) return; |
|
|
|
const enemyBoundingBox = new THREE.Box3().setFromObject(enemy.mesh); |
|
const enemyPreviousPosition = enemy.mesh.position.clone(); |
|
|
|
this.obstacles.forEach(obstacle => { |
|
const obstacleBoundingBox = new THREE.Box3().setFromObject(obstacle); |
|
if (enemyBoundingBox.intersectsBox(obstacleBoundingBox)) { |
|
enemy.mesh.position.copy(enemyPreviousPosition); |
|
} |
|
}); |
|
}); |
|
|
|
|
|
this.enemies.forEach(enemy => { |
|
if (!enemy.mesh || !enemy.isLoaded) return; |
|
|
|
enemy.bullets.forEach((bullet, bulletIndex) => { |
|
|
|
const tankBox = new THREE.Box3().setFromObject(this.tank.body); |
|
|
|
tankBox.min.x -= 0.75; |
|
tankBox.max.x += 0.75; |
|
|
|
|
|
const bulletBox = new THREE.Box3().setFromObject(bullet); |
|
|
|
if (bulletBox.intersectsBox(tankBox)) { |
|
const randomBeatSound = beatSounds[Math.floor(Math.random() * beatSounds.length)]; |
|
const beatAudio = new Audio(randomBeatSound); |
|
beatAudio.play(); |
|
|
|
if (this.tank.takeDamage(250)) { |
|
this.endGame(); |
|
} |
|
|
|
this.tank.createExplosionEffect(this.scene, bullet.position); |
|
this.scene.remove(bullet); |
|
enemy.bullets.splice(bulletIndex, 1); |
|
|
|
document.getElementById('health').style.width = |
|
`${(this.tank.health / MAX_HEALTH) * 100}%`; |
|
} |
|
}); |
|
}); |
|
|
|
|
|
for (let i = this.tank.bullets.length - 1; i >= 0; i--) { |
|
const bullet = this.tank.bullets[i]; |
|
const bulletBox = new THREE.Box3().setFromObject(bullet); |
|
|
|
for (const obstacle of this.obstacles) { |
|
if (obstacle.userData.isCollidable) { |
|
const obstacleBox = new THREE.Box3().setFromObject(obstacle); |
|
if (bulletBox.intersectsBox(obstacleBox)) { |
|
|
|
this.tank.createExplosionEffect(this.scene, bullet.position); |
|
|
|
|
|
this.scene.remove(bullet); |
|
this.tank.bullets.splice(i, 1); |
|
break; |
|
} |
|
} |
|
} |
|
} |
|
|
|
|
|
this.enemies.forEach(enemy => { |
|
if (!enemy.mesh || !enemy.isLoaded) return; |
|
|
|
enemy.bullets.forEach((bullet, bulletIndex) => { |
|
const distance = bullet.position.distanceTo(tankPosition); |
|
if (distance < 1) { |
|
|
|
const randomBeatSound = beatSounds[Math.floor(Math.random() * beatSounds.length)]; |
|
const beatAudio = new Audio(randomBeatSound); |
|
beatAudio.play(); |
|
|
|
if (this.tank.takeDamage(250)) { |
|
this.endGame(); |
|
} |
|
|
|
|
|
this.tank.createExplosionEffect(this.scene, bullet.position); |
|
|
|
this.scene.remove(bullet); |
|
enemy.bullets.splice(bulletIndex, 1); |
|
|
|
document.getElementById('health').style.width = |
|
`${(this.tank.health / MAX_HEALTH) * 100}%`; |
|
} |
|
}); |
|
}); |
|
|
|
|
|
|
|
for (let i = this.tank.bullets.length - 1; i >= 0; i--) { |
|
const bullet = this.tank.bullets[i]; |
|
for (let j = this.enemies.length - 1; j >= 0; j--) { |
|
const enemy = this.enemies[j]; |
|
if (!enemy.mesh || !enemy.isLoaded) continue; |
|
|
|
|
|
const enemyBox = new THREE.Box3().setFromObject(enemy.mesh); |
|
|
|
enemyBox.min.x -= 0.75; |
|
enemyBox.max.x += 0.75; |
|
|
|
|
|
const bulletBox = new THREE.Box3().setFromObject(bullet); |
|
|
|
|
|
if (bulletBox.intersectsBox(enemyBox)) { |
|
const randomHitSound = hitSounds[Math.floor(Math.random() * hitSounds.length)]; |
|
const hitAudio = new Audio(randomHitSound); |
|
hitAudio.play(); |
|
|
|
if (enemy.takeDamage(50)) { |
|
enemy.createMassiveExplosion(); |
|
enemy.destroy(); |
|
this.enemies.splice(j, 1); |
|
this.score += 100; |
|
document.getElementById('score').textContent = `Score: ${this.score}`; |
|
} |
|
|
|
this.tank.createExplosionEffect(this.scene, bullet.position); |
|
this.scene.remove(bullet); |
|
this.tank.bullets.splice(i, 1); |
|
break; |
|
} |
|
} |
|
} |
|
|
|
|
|
this.enemies.forEach(enemy => { |
|
if (!enemy.mesh || !enemy.isLoaded) return; |
|
|
|
const enemyBoundingBox = new THREE.Box3().setFromObject(enemy.mesh); |
|
if (tankBoundingBox.intersectsBox(enemyBoundingBox)) { |
|
this.tank.body.position.copy(this.previousTankPosition); |
|
} |
|
}); |
|
|
|
|
|
this.previousTankPosition.copy(this.tank.body.position); |
|
} |
|
endGame(isVictory = false) { |
|
if (this.isGameOver) return; |
|
|
|
this.isGameOver = true; |
|
|
|
|
|
if (this.bgm) { |
|
this.bgm.pause(); |
|
this.bgm = null; |
|
this.bgmPlaying = false; |
|
} |
|
if (this.engineSound) { |
|
this.engineSound.pause(); |
|
this.engineSound = null; |
|
} |
|
if (this.engineStopSound) { |
|
this.engineStopSound.pause(); |
|
this.engineStopSound = null; |
|
} |
|
|
|
|
|
if (isVictory) { |
|
const victoryAudio = new Audio('sounds/victory.ogg'); |
|
victoryAudio.volume = 0.5; |
|
victoryAudio.play(); |
|
} else { |
|
const deathSounds = ['sounds/death1.ogg', 'sounds/death2.ogg']; |
|
const randomDeathSound = deathSounds[Math.floor(Math.random() * deathSounds.length)]; |
|
const deathAudio = new Audio(randomDeathSound); |
|
deathAudio.play(); |
|
} |
|
|
|
|
|
if (this.gameTimer) { |
|
clearInterval(this.gameTimer); |
|
} |
|
if (this.animationFrameId) { |
|
cancelAnimationFrame(this.animationFrameId); |
|
} |
|
|
|
document.exitPointerLock(); |
|
|
|
|
|
const gameOverDiv = document.createElement('div'); |
|
gameOverDiv.style.position = 'absolute'; |
|
gameOverDiv.style.top = '50%'; |
|
gameOverDiv.style.left = '50%'; |
|
gameOverDiv.style.transform = 'translate(-50%, -50%)'; |
|
gameOverDiv.style.color = '#0f0'; |
|
gameOverDiv.style.fontSize = '48px'; |
|
gameOverDiv.style.backgroundColor = 'rgba(0, 20, 0, 0.7)'; |
|
gameOverDiv.style.padding = '20px'; |
|
gameOverDiv.style.borderRadius = '10px'; |
|
gameOverDiv.style.textAlign = 'center'; |
|
gameOverDiv.innerHTML = ` |
|
${isVictory ? 'Victory!' : 'Game Over'}<br> |
|
Score: ${this.score}<br> |
|
Time Survived: ${GAME_DURATION - this.gameTime}s<br> |
|
<button onclick="location.reload()" |
|
style="font-size: 24px; padding: 10px; margin-top: 20px; |
|
cursor: pointer; background: #0f0; border: none; |
|
color: black; border-radius: 5px;"> |
|
Play Again |
|
</button> |
|
`; |
|
document.body.appendChild(gameOverDiv); |
|
} |
|
|
|
|
|
updateUI() { |
|
if (!this.isGameOver) { |
|
const healthBar = document.getElementById('health'); |
|
if (healthBar) { |
|
healthBar.style.width = `${(this.tank.health / MAX_HEALTH) * 100}%`; |
|
} |
|
|
|
const timeElement = document.getElementById('time'); |
|
if (timeElement) { |
|
timeElement.textContent = `Time: ${this.gameTime}s`; |
|
} |
|
|
|
const scoreElement = document.getElementById('score'); |
|
if (scoreElement) { |
|
scoreElement.textContent = `Score: ${this.score}`; |
|
} |
|
} |
|
} |
|
|
|
updateCrosshair() { |
|
|
|
const raycasterDirection = new THREE.Vector2(); |
|
this.raycaster.setFromCamera(raycasterDirection, this.camera); |
|
|
|
|
|
const detectEnemy = this.enemies.some(enemy => { |
|
if (!enemy.mesh || !enemy.isLoaded) return false; |
|
|
|
|
|
const boundingBox = new THREE.Box3().setFromObject(enemy.mesh); |
|
const intersects = this.raycaster.ray.intersectsBox(boundingBox); |
|
|
|
|
|
return intersects; |
|
}); |
|
|
|
if (detectEnemy) { |
|
this.crosshair.classList.add('target-detected'); |
|
} else { |
|
this.crosshair.classList.remove('target-detected'); |
|
} |
|
} |
|
updateEnemyLabels() { |
|
const labelsContainer = document.getElementById('enemyLabels'); |
|
|
|
|
|
labelsContainer.innerHTML = ''; |
|
|
|
this.enemies.forEach((enemy, index) => { |
|
if (!enemy.mesh || !enemy.isLoaded) return; |
|
|
|
|
|
const enemyPosition = enemy.mesh.position.clone(); |
|
enemyPosition.y += 5; |
|
|
|
const screenPosition = enemyPosition.project(this.camera); |
|
|
|
|
|
if (screenPosition.z > 1) return; |
|
|
|
|
|
const distance = enemy.mesh.position.distanceTo(this.tank.getPosition()); |
|
|
|
|
|
if (distance <= this.radarRange) { |
|
const x = (screenPosition.x + 1) / 2 * window.innerWidth; |
|
const y = (-screenPosition.y + 1) / 2 * window.innerHeight; |
|
|
|
|
|
const label = document.createElement('div'); |
|
label.className = 'enemy-label'; |
|
label.textContent = 'T-90'; |
|
label.style.left = `${x}px`; |
|
label.style.top = `${y}px`; |
|
|
|
|
|
const opacity = Math.max(0.2, 1 - (distance / this.radarRange)); |
|
label.style.opacity = opacity; |
|
|
|
labelsContainer.appendChild(label); |
|
} |
|
}); |
|
} |
|
|
|
animate() { |
|
if (this.isGameOver) { |
|
if (this.animationFrameId) { |
|
cancelAnimationFrame(this.animationFrameId); |
|
} |
|
return; |
|
} |
|
|
|
this.animationFrameId = requestAnimationFrame(() => this.animate()); |
|
|
|
if (!this.isStarted) { |
|
this.renderer.render(this.scene, this.camera); |
|
return; |
|
} |
|
|
|
const currentTime = performance.now(); |
|
const deltaTime = (currentTime - this.lastTime) / 1000; |
|
this.lastTime = currentTime; |
|
|
|
if (!this.isLoading) { |
|
this.handleMovement(); |
|
this.tank.update(this.mouse.x, this.mouse.y, this.scene); |
|
|
|
const tankPosition = this.tank.getPosition(); |
|
this.enemies.forEach(enemy => { |
|
enemy.update(tankPosition); |
|
|
|
if (enemy.isLoaded && enemy.mesh.position.distanceTo(tankPosition) < ENEMY_CONFIG.ATTACK_RANGE) { |
|
enemy.shoot(tankPosition); |
|
} |
|
}); |
|
this.updateEnemyLabels(); |
|
this.updateParticles(); |
|
this.checkCollisions(); |
|
this.updateUI(); |
|
this.updateRadar(); |
|
|
|
this.updateCrosshair(); |
|
} |
|
|
|
this.renderer.render(this.scene, this.camera); |
|
} |
|
} |
|
|
|
|
|
window.startGame = function() { |
|
document.getElementById('startScreen').style.display = 'none'; |
|
document.body.requestPointerLock(); |
|
|
|
if (!window.gameInstance) { |
|
window.gameInstance = new Game(); |
|
} |
|
|
|
|
|
window.gameInstance.isStarted = true; |
|
window.gameInstance.initialize(); |
|
}; |
|
|
|
|
|
document.addEventListener('DOMContentLoaded', () => { |
|
|
|
window.gameInstance = new Game(); |
|
|
|
|
|
window.gameInstance.setupScene(); |
|
window.gameInstance.animate(); |
|
|
|
|
|
document.getElementById('startScreen').style.display = 'block'; |
|
}); |