407 lines
14 KiB
JavaScript
407 lines
14 KiB
JavaScript
|
|
import * as THREE from 'three';
|
|
import { Field } from './Field.js';
|
|
import { Ball } from './Ball.js';
|
|
import { Team } from './Team.js';
|
|
|
|
class Game {
|
|
constructor() {
|
|
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.keyboardState = {};
|
|
|
|
this.init();
|
|
}
|
|
|
|
init() {
|
|
this.scene.background = new THREE.Color(0x87CEEB); // Sky blue background
|
|
this.camera.position.set(45, 25, 0);
|
|
this.renderer.setSize(window.innerWidth, window.innerHeight);
|
|
this.renderer.shadowMap.enabled = true;
|
|
document.body.appendChild(this.renderer.domElement);
|
|
|
|
this.initLighting();
|
|
this.field = new Field(this.scene);
|
|
this.ball = new Ball(this.scene);
|
|
|
|
this.playerTeam = new Team(this.scene, 0xff0000, 4, true);
|
|
this.botTeam = new Team(this.scene, 0x0000ff, 4, false);
|
|
|
|
this.allPlayers = [...this.playerTeam.players, ...this.botTeam.players];
|
|
this.player = this.playerTeam.players[0];
|
|
|
|
this.initControls();
|
|
this.score = { red: 0, blue: 0 };
|
|
this.redScoreEl = document.getElementById('red-score');
|
|
this.blueScoreEl = document.getElementById('blue-score');
|
|
this.isGoalScored = false;
|
|
this.goalCooldown = 0;
|
|
this.timer = 30;
|
|
this.gameOver = false;
|
|
this.timerEl = document.getElementById('timer');
|
|
this.gameOverModal = document.getElementById('game-over-modal');
|
|
this.finalScoreEl = document.getElementById('final-score');
|
|
this.winnerEl = document.getElementById('winner');
|
|
this.restartButton = document.getElementById('restart-button');
|
|
this.restartButton.addEventListener('click', () => this.restartGame());
|
|
this.animate();
|
|
}
|
|
|
|
initLighting() {
|
|
const ambientLight = new THREE.AmbientLight(0xffffff, 0.6);
|
|
this.scene.add(ambientLight);
|
|
|
|
const directionalLight = new THREE.DirectionalLight(0xffffff, 1.2);
|
|
directionalLight.position.set(20, 30, 20);
|
|
directionalLight.castShadow = true;
|
|
directionalLight.shadow.camera.top = 20;
|
|
directionalLight.shadow.camera.bottom = -20;
|
|
directionalLight.shadow.camera.left = -20;
|
|
directionalLight.shadow.camera.right = 20;
|
|
this.scene.add(directionalLight);
|
|
}
|
|
|
|
initControls() {
|
|
window.addEventListener('keydown', (e) => { this.keyboardState[e.code] = true; });
|
|
window.addEventListener('keyup', (e) => { this.keyboardState[e.code] = false; });
|
|
}
|
|
|
|
updatePlayerPosition() {
|
|
const playerSpeed = 0.2;
|
|
const playerRadius = 1; // Approximate radius for collision
|
|
const velocity = new THREE.Vector3();
|
|
|
|
if (this.keyboardState['ArrowUp']) {
|
|
velocity.x -= playerSpeed;
|
|
}
|
|
if (this.keyboardState['ArrowDown']) {
|
|
velocity.x += playerSpeed;
|
|
}
|
|
if (this.keyboardState['ArrowLeft']) {
|
|
velocity.z += playerSpeed;
|
|
}
|
|
if (this.keyboardState['ArrowRight']) {
|
|
velocity.z -= playerSpeed;
|
|
}
|
|
|
|
if (velocity.lengthSq() > 0) {
|
|
this.player.mesh.lastVelocity.copy(velocity);
|
|
}
|
|
|
|
const newPosition = this.player.mesh.position.clone().add(velocity);
|
|
|
|
// --- Field Boundaries ---
|
|
const fieldWidth = 60;
|
|
const fieldLength = 100;
|
|
newPosition.x = Math.max(-fieldWidth / 2 + playerRadius, Math.min(fieldWidth / 2 - playerRadius, newPosition.x));
|
|
newPosition.z = Math.max(-fieldLength / 2 + playerRadius, Math.min(fieldLength / 2 - playerRadius, newPosition.z));
|
|
|
|
// Collision detection with other players
|
|
let collision = false;
|
|
for (const otherPlayer of this.allPlayers) {
|
|
if (otherPlayer === this.player) continue;
|
|
const distance = newPosition.distanceTo(otherPlayer.mesh.position);
|
|
if (distance < playerRadius * 2) {
|
|
collision = true;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (!collision) {
|
|
this.player.mesh.position.copy(newPosition);
|
|
}
|
|
|
|
// --- Force 2D movement on the XZ plane ---
|
|
this.player.mesh.position.y = 1;
|
|
|
|
// Shooting
|
|
if (this.keyboardState['KeyD']) {
|
|
this.player.shoot(this.ball);
|
|
}
|
|
|
|
// Passing
|
|
if (this.keyboardState['KeyS']) {
|
|
this.player.pass(this.ball, this.playerTeam.players.slice(1));
|
|
}
|
|
}
|
|
|
|
updateAIPlayers() {
|
|
const aiSpeed = 0.1;
|
|
const playerRadius = 1;
|
|
|
|
for (const aiPlayer of this.allPlayers) {
|
|
if (aiPlayer === this.player) continue; // Skip user-controlled player
|
|
|
|
let targetPosition;
|
|
const ballOwner = this.ball.mesh.possessedBy;
|
|
|
|
if (ballOwner === aiPlayer.mesh) {
|
|
// This AI has the ball, move towards the opponent's goal
|
|
targetPosition = new THREE.Vector3(0, 0, aiPlayer.mesh.team === 'player' ? 50 : -50);
|
|
} else if (ballOwner && aiPlayer.mesh.team === ballOwner.team) {
|
|
// This AI is a teammate of the ball owner, get into formation
|
|
targetPosition = ballOwner.position.clone().add(new THREE.Vector3(10, 0, 10));
|
|
} else {
|
|
// This AI is an opponent, or no one has the ball. Chase the ball.
|
|
targetPosition = this.ball.mesh.position;
|
|
}
|
|
|
|
const direction = targetPosition.clone().sub(aiPlayer.mesh.position).normalize();
|
|
const velocity = direction.multiplyScalar(aiSpeed);
|
|
|
|
const newPosition = aiPlayer.mesh.position.clone().add(velocity);
|
|
|
|
// --- Field Boundaries ---
|
|
const fieldWidth = 60;
|
|
const fieldLength = 100;
|
|
newPosition.x = Math.max(-fieldWidth / 2 + playerRadius, Math.min(fieldWidth / 2 - playerRadius, newPosition.x));
|
|
newPosition.z = Math.max(-fieldLength / 2 + playerRadius, Math.min(fieldLength / 2 - playerRadius, newPosition.z));
|
|
|
|
// Basic collision avoidance with other players
|
|
let collision = false;
|
|
for (const otherPlayer of this.allPlayers) {
|
|
if (otherPlayer === aiPlayer) continue;
|
|
const distance = newPosition.distanceTo(otherPlayer.mesh.position);
|
|
if (distance < playerRadius * 2) {
|
|
collision = true;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (!collision) {
|
|
aiPlayer.mesh.position.copy(newPosition);
|
|
aiPlayer.mesh.lastVelocity.copy(velocity);
|
|
}
|
|
|
|
// --- Force 2D movement on the XZ plane ---
|
|
aiPlayer.mesh.position.y = 1;
|
|
}
|
|
}
|
|
|
|
updateBall() {
|
|
this.ball.update();
|
|
const playerRadius = 1;
|
|
const possessionDistance = playerRadius + 0.8 + 0.5;
|
|
const STEAL_COOLDOWN = 1000;
|
|
|
|
// Ball collision with players
|
|
for (const p of this.allPlayers) {
|
|
const distance = this.ball.mesh.position.distanceTo(p.mesh.position);
|
|
if (distance < playerRadius + 0.8) {
|
|
const normal = this.ball.mesh.position.clone().sub(p.mesh.position).normalize();
|
|
this.ball.mesh.velocity.reflect(normal).multiplyScalar(0.8);
|
|
}
|
|
}
|
|
|
|
// Check for gaining possession
|
|
if (!this.ball.mesh.possessedBy) {
|
|
for (const p of this.allPlayers) {
|
|
const distanceToPlayer = this.ball.mesh.position.distanceTo(p.mesh.position);
|
|
if (distanceToPlayer < possessionDistance && this.ball.mesh.velocity.lengthSq() < 0.1) {
|
|
this.ball.mesh.possessedBy = p.mesh;
|
|
this.ball.mesh.velocity.set(0, 0, 0);
|
|
this.ball.mesh.lastStealTime = Date.now();
|
|
break; // Only one player can possess the ball at a time
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check for steals
|
|
const stealRadius = playerRadius * 2.2;
|
|
const now = Date.now();
|
|
const ballOwner = this.ball.mesh.possessedBy;
|
|
|
|
if (ballOwner && now - this.ball.mesh.lastStealTime > STEAL_COOLDOWN) {
|
|
for (const p of this.allPlayers) {
|
|
if (p.mesh === ballOwner) continue;
|
|
|
|
const distanceToBallOwner = p.mesh.position.distanceTo(ballOwner.position);
|
|
|
|
if (distanceToBallOwner < stealRadius) {
|
|
const owner = ballOwner;
|
|
const stealer = p.mesh;
|
|
|
|
let canSteal = true; // Default to allow steal
|
|
|
|
if (owner.lastVelocity.lengthSq() > 0) {
|
|
// Check if the stealer is roughly in front of the owner
|
|
const ownerDirection = owner.lastVelocity.clone().normalize();
|
|
const toStealer = stealer.position.clone().sub(owner.position).normalize();
|
|
const angle = ownerDirection.angleTo(toStealer);
|
|
|
|
if (angle >= Math.PI / 2) { // If stealer is not in front (90 degree FOV)
|
|
canSteal = false;
|
|
}
|
|
}
|
|
|
|
if (canSteal) {
|
|
this.ball.mesh.possessedBy = stealer;
|
|
this.ball.mesh.lastStealTime = now;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
updateScoreboard() {
|
|
this.redScoreEl.textContent = this.score.red;
|
|
this.blueScoreEl.textContent = this.score.blue;
|
|
}
|
|
|
|
resetPositions(teamToGetPossession) {
|
|
this.ball.mesh.position.set(0, 0.8, 0);
|
|
this.ball.mesh.velocity.set(0, 0, 0);
|
|
this.ball.mesh.possessedBy = null;
|
|
|
|
// Reset player positions
|
|
this.playerTeam.resetPositions();
|
|
this.botTeam.resetPositions();
|
|
|
|
// Give possession to the team that was scored on
|
|
if (teamToGetPossession) {
|
|
// Give ball to the player closest to the center
|
|
let closestPlayer = null;
|
|
let minDistance = Infinity;
|
|
|
|
const team = teamToGetPossession === 'red' ? this.playerTeam : this.botTeam;
|
|
|
|
for (const player of team.players) {
|
|
const distance = player.mesh.position.length();
|
|
if (distance < minDistance) {
|
|
minDistance = distance;
|
|
closestPlayer = player;
|
|
}
|
|
}
|
|
if(closestPlayer){
|
|
this.ball.mesh.possessedBy = closestPlayer.mesh;
|
|
}
|
|
}
|
|
}
|
|
|
|
handleGoal(scoringTeam) {
|
|
if (this.isGoalScored) return;
|
|
this.isGoalScored = true;
|
|
this.goalCooldown = 60; // 60 frames ~ 1 second
|
|
|
|
if (scoringTeam === 'red') {
|
|
this.score.blue++;
|
|
} else {
|
|
this.score.red++;
|
|
}
|
|
this.updateScoreboard();
|
|
|
|
const losingTeam = scoringTeam === 'red' ? 'blue' : 'red';
|
|
this.resetPositions(losingTeam);
|
|
}
|
|
|
|
updateTimer() {
|
|
if (this.gameOver) return;
|
|
|
|
this.timer -= 1 / 60; // Assuming 60 FPS
|
|
this.timerEl.textContent = Math.ceil(this.timer);
|
|
|
|
if (this.timer <= 0) {
|
|
this.endGame();
|
|
}
|
|
}
|
|
|
|
endGame() {
|
|
this.gameOver = true;
|
|
this.gameOverModal.style.display = 'flex';
|
|
|
|
let winner;
|
|
if (this.score.red > this.score.blue) {
|
|
winner = 'Red Team Wins!';
|
|
} else if (this.score.blue > this.score.red) {
|
|
winner = 'Blue Team Wins!';
|
|
} else {
|
|
winner = "It's a draw!";
|
|
}
|
|
|
|
this.finalScoreEl.textContent = `Final Score: ${this.score.red} - ${this.score.blue}`;
|
|
this.winnerEl.textContent = winner;
|
|
}
|
|
|
|
restartGame() {
|
|
this.gameOver = false;
|
|
this.timer = 30;
|
|
this.score = { red: 0, blue: 0 };
|
|
this.updateScoreboard();
|
|
this.gameOverModal.style.display = 'none';
|
|
this.resetPositions();
|
|
}
|
|
|
|
checkCollisions() {
|
|
const ballPos = this.ball.mesh.position;
|
|
const ballRadius = 0.8; // Corrected from this.ball.radius
|
|
const fieldWidth = 60;
|
|
const fieldLength = 100;
|
|
const goalWidth = 20;
|
|
|
|
// Sideline collision (bounce)
|
|
const hasHitSideline = (Math.abs(ballPos.x) + ballRadius) > (fieldWidth / 2);
|
|
if (hasHitSideline) {
|
|
this.ball.mesh.velocity.x *= -1;
|
|
ballPos.x = (fieldWidth / 2 - ballRadius) * Math.sign(ballPos.x);
|
|
this.ball.mesh.velocity.y = 0; // Keep ball on the ground
|
|
ballPos.y = 0.8; // Reset y position
|
|
}
|
|
|
|
const goalLine = fieldLength / 2;
|
|
const isWithinGoalPosts = Math.abs(ballPos.x) < goalWidth / 2;
|
|
|
|
// Goal detection or back wall bounce
|
|
if (Math.abs(ballPos.z) + ballRadius > goalLine) {
|
|
if (isWithinGoalPosts) {
|
|
// Goal
|
|
if (ballPos.z > 0) {
|
|
this.handleGoal('blue'); // Blue team scored
|
|
} else {
|
|
this.handleGoal('red'); // Red team scored
|
|
}
|
|
} else {
|
|
// Bounce off back wall
|
|
this.ball.mesh.velocity.z *= -1;
|
|
ballPos.z = (goalLine - ballRadius) * Math.sign(ballPos.z);
|
|
this.ball.mesh.velocity.y = 0; // Keep ball on the ground
|
|
ballPos.y = 0.8; // Reset y position
|
|
}
|
|
}
|
|
}
|
|
|
|
animate() {
|
|
requestAnimationFrame(() => this.animate());
|
|
|
|
if (this.gameOver) {
|
|
this.renderer.render(this.scene, this.camera);
|
|
return;
|
|
}
|
|
|
|
this.updateTimer();
|
|
|
|
if (this.goalCooldown > 0) {
|
|
this.goalCooldown--;
|
|
if (this.goalCooldown === 0) {
|
|
this.isGoalScored = false;
|
|
}
|
|
}
|
|
|
|
this.updatePlayerPosition();
|
|
this.updateAIPlayers();
|
|
this.updateBall();
|
|
if (!this.isGoalScored) {
|
|
this.checkCollisions();
|
|
}
|
|
|
|
// Camera follows player from the sideline
|
|
this.camera.position.z = this.player.mesh.position.z;
|
|
this.camera.lookAt(this.player.mesh.position);
|
|
|
|
this.renderer.render(this.scene, this.camera);
|
|
}
|
|
}
|
|
|
|
new Game();
|