| 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 = 1000; |
| const TANK_HEIGHT = 0.5; |
| const ENEMY_GROUND_HEIGHT = 0; |
| const ENEMY_SCALE = 10; |
| const MAX_HEALTH = 1000; |
| const ENEMY_MOVE_SPEED = 0.1; |
| const ENEMY_COUNT_MAX = 3; |
| const PARTICLE_COUNT = 15; |
| const BUILDING_COUNT = 30; |
| 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/explosion.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; |
| return this.health <= 0; |
| } |
| |
| 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.moveSpeed = type === 'tank' ? ENEMY_MOVE_SPEED : ENEMY_MOVE_SPEED * 0.7; |
| } |
|
|
| async initialize(loader) { |
| try { |
| const modelPath = this.type === 'tank' ? '/models/enemy1.glb' : '/models/enemy4.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; |
| } |
| } |
|
|
| update(playerPosition) { |
| if (!this.mesh || !this.isLoaded) return; |
|
|
| const direction = new THREE.Vector3() |
| .subVectors(playerPosition, this.mesh.position) |
| .normalize(); |
| |
| const distanceToPlayer = this.mesh.position.distanceTo(playerPosition); |
| const minDistance = 50; |
| |
| |
| const previousPosition = this.mesh.position.clone(); |
| |
| if (distanceToPlayer > minDistance) { |
| const moveVector = direction.multiplyScalar(this.moveSpeed); |
| const newPosition = this.mesh.position.clone().add(moveVector); |
| |
| |
| const heightAtNewPos = window.gameInstance.getHeightAtPosition( |
| newPosition.x, |
| newPosition.z |
| ); |
| newPosition.y = heightAtNewPos + TANK_HEIGHT; |
| |
| |
| const originalPosition = this.mesh.position.clone(); |
| this.mesh.position.copy(newPosition); |
| |
| |
| const enemyBox = new THREE.Box3().setFromObject(this.mesh); |
| let hasCollision = false; |
| |
| |
| for (const obstacle of window.gameInstance.obstacles) { |
| const obstacleBox = new THREE.Box3().setFromObject(obstacle); |
| if (enemyBox.intersectsBox(obstacleBox)) { |
| hasCollision = true; |
| break; |
| } |
| } |
| |
| |
| if (!hasCollision) { |
| for (const otherEnemy of window.gameInstance.enemies) { |
| if (otherEnemy !== this && otherEnemy.mesh) { |
| const otherEnemyBox = new THREE.Box3().setFromObject(otherEnemy.mesh); |
| if (enemyBox.intersectsBox(otherEnemyBox)) { |
| hasCollision = true; |
| break; |
| } |
| } |
| } |
| } |
| |
| |
| const mapBoundary = MAP_SIZE / 2; |
| if (Math.abs(newPosition.x) > mapBoundary || |
| Math.abs(newPosition.z) > mapBoundary) { |
| hasCollision = true; |
| } |
| |
| |
| if (hasCollision) { |
| this.mesh.position.copy(previousPosition); |
| |
| |
| const alternateDirections = [ |
| new THREE.Vector3(-direction.z, 0, direction.x), |
| new THREE.Vector3(direction.z, 0, -direction.x), |
| new THREE.Vector3(-direction.x, 0, -direction.z) |
| ]; |
| |
| for (const altDirection of alternateDirections) { |
| const altMoveVector = altDirection.multiplyScalar(this.moveSpeed); |
| const altNewPosition = previousPosition.clone().add(altMoveVector); |
| |
| this.mesh.position.copy(altNewPosition); |
| const altEnemyBox = new THREE.Box3().setFromObject(this.mesh); |
| |
| let altHasCollision = false; |
| |
| |
| for (const obstacle of window.gameInstance.obstacles) { |
| const obstacleBox = new THREE.Box3().setFromObject(obstacle); |
| if (altEnemyBox.intersectsBox(obstacleBox)) { |
| altHasCollision = true; |
| break; |
| } |
| } |
| |
| if (!altHasCollision) { |
| |
| break; |
| } else { |
| |
| this.mesh.position.copy(previousPosition); |
| } |
| } |
| } |
| |
| |
| 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); |
| } |
| |
| |
| this.mesh.lookAt(playerPosition); |
| |
| |
| if (this.bullets) { |
| 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; |
| } |
| } |
| } |
| } |
| } |
|
|
|
|
| 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 bulletGeometry = new THREE.SphereGeometry(this.type === 'tank' ? 0.2 : 0.3); |
| const bulletMaterial = new THREE.MeshBasicMaterial({ |
| color: this.type === 'tank' ? 0xff0000 : 0xff6600 |
| }); |
| const bullet = new THREE.Mesh(bulletGeometry, bulletMaterial); |
|
|
| bullet.position.copy(this.mesh.position); |
| |
| const direction = new THREE.Vector3() |
| .subVectors(playerPosition, this.mesh.position) |
| .normalize(); |
| |
| const bulletSpeed = this.type === 'tank' ? |
| ENEMY_CONFIG.BULLET_SPEED : |
| ENEMY_CONFIG.BULLET_SPEED * 0.8; |
| |
| bullet.velocity = direction.multiplyScalar(bulletSpeed); |
| |
| this.scene.add(bullet); |
| this.bullets.push(bullet); |
| this.lastAttackTime = currentTime; |
| } |
|
|
| takeDamage(damage) { |
| this.health -= damage; |
| return this.health <= 0; |
| } |
|
|
| 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; |
| 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 rockGeometries = [ |
| new THREE.DodecahedronGeometry(3), |
| new THREE.DodecahedronGeometry(2), |
| new THREE.DodecahedronGeometry(4) |
| ]; |
|
|
| const rockMaterial = new THREE.MeshStandardMaterial({ |
| color: 0x8B4513, |
| roughness: 0.9, |
| metalness: 0.1 |
| }); |
|
|
| for (let i = 0; i < 100; i++) { |
| const rockGeometry = rockGeometries[Math.floor(Math.random() * rockGeometries.length)]; |
| const rock = new THREE.Mesh(rockGeometry, rockMaterial); |
| rock.position.set( |
| (Math.random() - 0.5) * MAP_SIZE * 0.9, |
| Math.random() * 2, |
| (Math.random() - 0.5) * MAP_SIZE * 0.9 |
| ); |
| |
| rock.rotation.set( |
| Math.random() * Math.PI, |
| Math.random() * Math.PI, |
| Math.random() * Math.PI |
| ); |
| |
| rock.scale.set( |
| 1 + Math.random() * 0.5, |
| 1 + Math.random() * 0.5, |
| 1 + Math.random() * 0.5 |
| ); |
| |
| rock.castShadow = true; |
| rock.receiveShadow = true; |
| rock.userData.type = 'rock'; |
| this.obstacles.push(rock); |
| this.scene.add(rock); |
| } |
|
|
| |
| 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.type = 'cactus'; |
| this.obstacles.push(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(); |
| } |
| }, 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); |
|
|
| |
| this.obstacles.forEach(obstacle => { |
| const obstacleBoundingBox = new THREE.Box3().setFromObject(obstacle); |
| if (tankBoundingBox.intersectsBox(obstacleBoundingBox)) { |
| |
| this.tank.body.position.copy(this.previousTankPosition); |
| |
| |
| |
| |
| |
| } |
| }); |
| |
| 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 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.scene.remove(bullet); |
| enemy.bullets.splice(bulletIndex, 1); |
| |
| this.createExplosion(bullet.position); |
| 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) { |
| 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.bullets) return; |
| |
| for (let i = enemy.bullets.length - 1; i >= 0; i--) { |
| const bullet = enemy.bullets[i]; |
| const bulletBox = new THREE.Box3().setFromObject(bullet); |
|
|
| for (const obstacle of this.obstacles) { |
| const obstacleBox = new THREE.Box3().setFromObject(obstacle); |
| if (bulletBox.intersectsBox(obstacleBox)) { |
| |
| this.tank.createExplosionEffect(this.scene, bullet.position); |
| |
| |
| this.scene.remove(bullet); |
| enemy.bullets.splice(i, 1); |
| break; |
| } |
| } |
| } |
| }); |
|
|
|
|
| |
| 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 distance = bullet.position.distanceTo(enemy.mesh.position); |
| if (distance < 2) { |
| |
| const randomHitSound = hitSounds[Math.floor(Math.random() * hitSounds.length)]; |
| const hitAudio = new Audio(randomHitSound); |
| hitAudio.play(); |
|
|
| if (enemy.takeDamage(50)) { |
| enemy.destroy(); |
| this.enemies.splice(j, 1); |
| this.score += 100; |
| document.getElementById('score').textContent = `Score: ${this.score}`; |
| } |
| this.scene.remove(bullet); |
| this.tank.bullets.splice(i, 1); |
| this.createExplosion(bullet.position); |
| 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() { |
| if (this.isGameOver) return; |
| |
| this.isGameOver = true; |
| |
| if (this.bgm) { |
| this.bgm.pause(); |
| this.bgm = null; |
| this.bgmPlaying = false; |
| } |
| |
| |
| 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 = ` |
| 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}`; |
| } |
| } |
| } |
|
|
| 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.updateParticles(); |
| this.checkCollisions(); |
| this.updateUI(); |
| this.updateRadar(); |
| } |
| |
| 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'; |
| }); |