Refactor to ES6 classes with GameObject inheritance pattern

- Convert from function-based to class-based architecture
- Create GameObject base class with common functionality (position, collision, scene management)
- Implement Player, Coin, and Obstacle classes extending GameObject
- Add Game class to manage game lifecycle and state
- Fix shadow rendering by configuring directional light shadow camera bounds
- Fix player collision to prevent sinking below ground (horizontal push only)
- Improve code organization and maintainability with OOP principles
This commit is contained in:
Juan Sebastián Montoya 2025-11-25 14:32:48 -05:00
parent 0bf4afd499
commit fc24028ed9

View file

@ -92,313 +92,441 @@
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<script> <script>
// Game variables // Base GameObject class
let scene, camera, renderer; class GameObject {
let player; constructor(scene, groundSize) {
let coins = []; this.scene = scene;
let obstacles = []; this.groundSize = groundSize;
let score = 0; this.mesh = this.createMesh();
let health = 100; this.initialize();
let gameActive = true; scene.add(this.mesh);
}
// Movement
const keys = {};
const playerSpeed = 0.15;
const groundSize = 30;
// Initialize the game
function init() {
// Scene setup
scene = new THREE.Scene();
scene.background = new THREE.Color(0x87CEEB);
scene.fog = new THREE.Fog(0x87CEEB, 0, 50);
// Camera setup // Override in child classes
camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000); createMesh() {
camera.position.set(0, 10, 15); throw new Error('createMesh() must be implemented by child class');
camera.lookAt(0, 0, 0); }
// Renderer setup // Override in child classes for initialization
renderer = new THREE.WebGLRenderer({ antialias: true }); initialize() {
renderer.setSize(window.innerWidth, window.innerHeight); // Default implementation - can be overridden
renderer.shadowMap.enabled = true; }
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
document.body.appendChild(renderer.domElement);
// Lights // Override in child classes for update logic
const ambientLight = new THREE.AmbientLight(0xffffff, 0.6); update(...args) {
scene.add(ambientLight); // Default implementation - can be overridden
}
const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8); // Common collision detection
directionalLight.position.set(10, 20, 10); checkCollision(otherPosition, collisionRadius) {
directionalLight.castShadow = true; const distance = this.mesh.position.distanceTo(otherPosition);
directionalLight.shadow.mapSize.width = 2048; return distance < collisionRadius;
directionalLight.shadow.mapSize.height = 2048; }
directionalLight.shadow.camera.left = -20;
directionalLight.shadow.camera.right = 20;
directionalLight.shadow.camera.top = 20;
directionalLight.shadow.camera.bottom = -20;
directionalLight.shadow.camera.near = 0.5;
directionalLight.shadow.camera.far = 50;
scene.add(directionalLight);
// Ground // Common position getter
const groundGeometry = new THREE.PlaneGeometry(groundSize, groundSize); getPosition() {
const groundMaterial = new THREE.MeshStandardMaterial({ return this.mesh.position;
color: 0x90EE90, }
roughness: 0.8
});
const ground = new THREE.Mesh(groundGeometry, groundMaterial);
ground.rotation.x = -Math.PI / 2;
ground.receiveShadow = true;
scene.add(ground);
// Grid helper // Remove from scene
const gridHelper = new THREE.GridHelper(groundSize, 20, 0x000000, 0x000000); remove() {
gridHelper.material.opacity = 0.2; this.scene.remove(this.mesh);
gridHelper.material.transparent = true; }
scene.add(gridHelper);
// Player // Set position
const playerGeometry = new THREE.BoxGeometry(1, 1, 1); setPosition(x, y, z) {
const playerMaterial = new THREE.MeshStandardMaterial({ this.mesh.position.set(x, y, z);
color: 0x4169E1, }
metalness: 0.3,
roughness: 0.4
});
player = new THREE.Mesh(playerGeometry, playerMaterial);
player.position.y = 0.5;
player.castShadow = true;
player.receiveShadow = true;
scene.add(player);
// Create initial game objects // Get position vector
createCoins(10); getPositionVector() {
createObstacles(8); return this.mesh.position.clone();
}
// Event listeners
window.addEventListener('keydown', (e) => keys[e.key.toLowerCase()] = true);
window.addEventListener('keyup', (e) => keys[e.key.toLowerCase()] = false);
window.addEventListener('resize', onWindowResize);
document.getElementById('restartBtn').addEventListener('click', restartGame);
// Start animation loop
animate();
} }
// Create coins // Player class extends GameObject
function createCoins(count) { class Player extends GameObject {
for (let i = 0; i < count; i++) { constructor(scene, groundSize) {
const coinGeometry = new THREE.SphereGeometry(0.3, 16, 16); super(scene, groundSize);
const coinMaterial = new THREE.MeshStandardMaterial({ this.speed = 0.15;
}
createMesh() {
const geometry = new THREE.BoxGeometry(1, 1, 1);
const material = new THREE.MeshStandardMaterial({
color: 0x4169E1,
metalness: 0.3,
roughness: 0.4
});
const mesh = new THREE.Mesh(geometry, material);
mesh.castShadow = true;
mesh.receiveShadow = true;
return mesh;
}
initialize() {
this.reset();
}
reset() {
this.setPosition(0, 0.5, 0);
this.mesh.rotation.y = 0;
}
update(keys) {
const moveVector = new THREE.Vector3();
if (keys['w'] || keys['arrowup']) moveVector.z -= this.speed;
if (keys['s'] || keys['arrowdown']) moveVector.z += this.speed;
if (keys['a'] || keys['arrowleft']) moveVector.x -= this.speed;
if (keys['d'] || keys['arrowright']) moveVector.x += this.speed;
this.mesh.position.add(moveVector);
// Boundary checks
const boundary = this.groundSize / 2 - 0.5;
this.mesh.position.x = Math.max(-boundary, Math.min(boundary, this.mesh.position.x));
this.mesh.position.z = Math.max(-boundary, Math.min(boundary, this.mesh.position.z));
// Rotate player based on movement
if (moveVector.length() > 0) {
this.mesh.rotation.y += 0.1;
}
}
handleCollision(obstaclePosition) {
const pushDirection = this.mesh.position.clone().sub(obstaclePosition);
pushDirection.y = 0;
pushDirection.normalize();
this.mesh.position.add(pushDirection.multiplyScalar(0.3));
this.mesh.position.y = 0.5;
}
}
// Coin class extends GameObject
class Coin extends GameObject {
constructor(scene, groundSize, index = 0) {
super(scene, groundSize);
this.index = index;
this.rotationSpeed = 0.02;
this.collisionRadius = 0.8;
}
createMesh() {
const geometry = new THREE.SphereGeometry(0.3, 16, 16);
const material = new THREE.MeshStandardMaterial({
color: 0xFFD700, color: 0xFFD700,
metalness: 0.8, metalness: 0.8,
roughness: 0.2, roughness: 0.2,
emissive: 0xFFD700, emissive: 0xFFD700,
emissiveIntensity: 0.3 emissiveIntensity: 0.3
}); });
const coin = new THREE.Mesh(coinGeometry, coinMaterial); const mesh = new THREE.Mesh(geometry, material);
mesh.castShadow = true;
// Random position mesh.receiveShadow = true;
coin.position.x = (Math.random() - 0.5) * (groundSize - 4); return mesh;
coin.position.z = (Math.random() - 0.5) * (groundSize - 4); }
coin.position.y = 0.5;
initialize() {
coin.castShadow = true; this.setRandomPosition();
coin.receiveShadow = true; }
coin.userData.rotationSpeed = 0.02;
setRandomPosition() {
scene.add(coin); const x = (Math.random() - 0.5) * (this.groundSize - 4);
coins.push(coin); const z = (Math.random() - 0.5) * (this.groundSize - 4);
this.setPosition(x, 0.5, z);
}
update() {
this.mesh.rotation.y += this.rotationSpeed;
this.mesh.position.y = 0.5 + Math.sin(Date.now() * 0.003 + this.index) * 0.2;
}
checkCollisionWithPlayer(playerPosition) {
return this.checkCollision(playerPosition, this.collisionRadius);
} }
} }
// Create obstacles // Obstacle class extends GameObject
function createObstacles(count) { class Obstacle extends GameObject {
for (let i = 0; i < count; i++) { constructor(scene, groundSize) {
const obstacleGeometry = new THREE.BoxGeometry(1.5, 2, 1.5); super(scene, groundSize);
const obstacleMaterial = new THREE.MeshStandardMaterial({ this.direction = this.createRandomDirection();
this.collisionRadius = 1.5;
}
createMesh() {
const geometry = new THREE.BoxGeometry(1.5, 2, 1.5);
const material = new THREE.MeshStandardMaterial({
color: 0xFF4500, color: 0xFF4500,
metalness: 0.3, metalness: 0.3,
roughness: 0.7 roughness: 0.7
}); });
const obstacle = new THREE.Mesh(obstacleGeometry, obstacleMaterial); const mesh = new THREE.Mesh(geometry, material);
mesh.castShadow = true;
// Random position (ensure not too close to player start) mesh.receiveShadow = true;
let posX, posZ; return mesh;
do { }
posX = (Math.random() - 0.5) * (groundSize - 4);
posZ = (Math.random() - 0.5) * (groundSize - 4); initialize() {
} while (Math.abs(posX) < 3 && Math.abs(posZ) < 3); this.setRandomPosition();
this.mesh.position.y = 1;
obstacle.position.x = posX; }
obstacle.position.z = posZ;
obstacle.position.y = 1; createRandomDirection() {
return new THREE.Vector3(
obstacle.castShadow = true;
obstacle.receiveShadow = true;
// Random movement pattern
obstacle.userData.direction = new THREE.Vector3(
(Math.random() - 0.5) * 0.05, (Math.random() - 0.5) * 0.05,
0, 0,
(Math.random() - 0.5) * 0.05 (Math.random() - 0.5) * 0.05
); );
scene.add(obstacle);
obstacles.push(obstacle);
} }
}
// Update player movement
function updatePlayer() {
if (!gameActive) return;
const moveVector = new THREE.Vector3(); setRandomPosition() {
let posX, posZ;
if (keys['w'] || keys['arrowup']) moveVector.z -= playerSpeed; do {
if (keys['s'] || keys['arrowdown']) moveVector.z += playerSpeed; posX = (Math.random() - 0.5) * (this.groundSize - 4);
if (keys['a'] || keys['arrowleft']) moveVector.x -= playerSpeed; posZ = (Math.random() - 0.5) * (this.groundSize - 4);
if (keys['d'] || keys['arrowright']) moveVector.x += playerSpeed; } while (Math.abs(posX) < 3 && Math.abs(posZ) < 3);
// Apply movement
player.position.add(moveVector);
// Boundary checks
const boundary = groundSize / 2 - 0.5;
player.position.x = Math.max(-boundary, Math.min(boundary, player.position.x));
player.position.z = Math.max(-boundary, Math.min(boundary, player.position.z));
// Rotate player based on movement
if (moveVector.length() > 0) {
player.rotation.y += 0.1;
}
}
// Update coins
function updateCoins() {
coins.forEach((coin, index) => {
// Rotate coins
coin.rotation.y += coin.userData.rotationSpeed;
coin.position.y = 0.5 + Math.sin(Date.now() * 0.003 + index) * 0.2;
// Check collision with player this.setPosition(posX, 1, posZ);
const distance = player.position.distanceTo(coin.position); }
if (distance < 0.8) {
// Collect coin
scene.remove(coin);
coins.splice(index, 1);
score += 10;
updateUI();
// Create new coin
createCoins(1);
}
});
}
// Update obstacles
function updateObstacles() {
if (!gameActive) return;
obstacles.forEach(obstacle => { update() {
// Move obstacle this.mesh.position.add(this.direction);
obstacle.position.add(obstacle.userData.direction);
// Bounce off boundaries // Bounce off boundaries
const boundary = groundSize / 2 - 1; const boundary = this.groundSize / 2 - 1;
if (Math.abs(obstacle.position.x) > boundary) { if (Math.abs(this.mesh.position.x) > boundary) {
obstacle.userData.direction.x *= -1; this.direction.x *= -1;
} }
if (Math.abs(obstacle.position.z) > boundary) { if (Math.abs(this.mesh.position.z) > boundary) {
obstacle.userData.direction.z *= -1; this.direction.z *= -1;
} }
}
checkCollisionWithPlayer(playerPosition) {
return this.checkCollision(playerPosition, this.collisionRadius);
}
}
// Game class
class Game {
constructor() {
this.groundSize = 30;
this.score = 0;
this.health = 100;
this.gameActive = true;
this.keys = {};
this.coins = [];
this.obstacles = [];
// Check collision with player this.init();
const distance = player.position.distanceTo(obstacle.position); this.setupEventListeners();
if (distance < 1.5) { this.animate();
health -= 1; }
updateUI();
init() {
// Push player away (only on X and Z axis, keep Y constant) this.setupScene();
const pushDirection = player.position.clone().sub(obstacle.position); this.setupCamera();
pushDirection.y = 0; // Keep player on ground level this.setupRenderer();
pushDirection.normalize(); this.setupLights();
player.position.add(pushDirection.multiplyScalar(0.3)); this.setupGround();
player.position.y = 0.5; // Ensure player stays at ground level this.setupPlayer();
this.createGameObjects();
// Check game over }
if (health <= 0) {
gameOver(); setupScene() {
} this.scene = new THREE.Scene();
this.scene.background = new THREE.Color(0x87CEEB);
this.scene.fog = new THREE.Fog(0x87CEEB, 0, 50);
}
setupCamera() {
this.camera = new THREE.PerspectiveCamera(
75,
window.innerWidth / window.innerHeight,
0.1,
1000
);
this.camera.position.set(0, 10, 15);
this.camera.lookAt(0, 0, 0);
}
setupRenderer() {
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;
document.body.appendChild(this.renderer.domElement);
}
setupLights() {
const ambientLight = new THREE.AmbientLight(0xffffff, 0.6);
this.scene.add(ambientLight);
const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8);
directionalLight.position.set(10, 20, 10);
directionalLight.castShadow = true;
directionalLight.shadow.mapSize.width = 2048;
directionalLight.shadow.mapSize.height = 2048;
directionalLight.shadow.camera.left = -20;
directionalLight.shadow.camera.right = 20;
directionalLight.shadow.camera.top = 20;
directionalLight.shadow.camera.bottom = -20;
directionalLight.shadow.camera.near = 0.5;
directionalLight.shadow.camera.far = 50;
this.scene.add(directionalLight);
}
setupGround() {
const groundGeometry = new THREE.PlaneGeometry(this.groundSize, this.groundSize);
const groundMaterial = new THREE.MeshStandardMaterial({
color: 0x90EE90,
roughness: 0.8
});
const ground = new THREE.Mesh(groundGeometry, groundMaterial);
ground.rotation.x = -Math.PI / 2;
ground.receiveShadow = true;
this.scene.add(ground);
const gridHelper = new THREE.GridHelper(this.groundSize, 20, 0x000000, 0x000000);
gridHelper.material.opacity = 0.2;
gridHelper.material.transparent = true;
this.scene.add(gridHelper);
}
setupPlayer() {
this.player = new Player(this.scene, this.groundSize);
}
createGameObjects() {
this.createCoins(10);
this.createObstacles(8);
}
createCoins(count) {
for (let i = 0; i < count; i++) {
const coin = new Coin(this.scene, this.groundSize, this.coins.length);
this.coins.push(coin);
} }
}); }
}
// Update UI
function updateUI() {
document.getElementById('score').textContent = score;
document.getElementById('health').textContent = Math.max(0, health);
}
// Game over
function gameOver() {
gameActive = false;
document.getElementById('finalScore').textContent = score;
document.getElementById('gameOver').style.display = 'block';
}
// Restart game
function restartGame() {
// Remove all coins and obstacles
coins.forEach(coin => scene.remove(coin));
obstacles.forEach(obstacle => scene.remove(obstacle));
coins = [];
obstacles = [];
// Reset player createObstacles(count) {
player.position.set(0, 0.5, 0); for (let i = 0; i < count; i++) {
player.rotation.y = 0; const obstacle = new Obstacle(this.scene, this.groundSize);
this.obstacles.push(obstacle);
}
}
// Reset game state setupEventListeners() {
score = 0; window.addEventListener('keydown', (e) => {
health = 100; this.keys[e.key.toLowerCase()] = true;
gameActive = true; });
window.addEventListener('keyup', (e) => {
this.keys[e.key.toLowerCase()] = false;
});
window.addEventListener('resize', () => this.onWindowResize());
document.getElementById('restartBtn').addEventListener('click', () => this.restart());
}
// Recreate game objects updatePlayer() {
createCoins(10); if (!this.gameActive) return;
createObstacles(8); this.player.update(this.keys);
}
// Hide game over screen updateCoins() {
document.getElementById('gameOver').style.display = 'none'; this.coins.forEach((coin, index) => {
updateUI(); coin.update();
}
if (coin.checkCollisionWithPlayer(this.player.getPosition())) {
// Handle window resize coin.remove();
function onWindowResize() { this.coins.splice(index, 1);
camera.aspect = window.innerWidth / window.innerHeight; this.score += 10;
camera.updateProjectionMatrix(); this.updateUI();
renderer.setSize(window.innerWidth, window.innerHeight); this.createCoins(1);
} }
});
// Animation loop }
function animate() {
requestAnimationFrame(animate);
updatePlayer(); updateObstacles() {
updateCoins(); if (!this.gameActive) return;
updateObstacles();
this.obstacles.forEach(obstacle => {
obstacle.update();
if (obstacle.checkCollisionWithPlayer(this.player.getPosition())) {
this.health -= 1;
this.updateUI();
this.player.handleCollision(obstacle.getPosition());
if (this.health <= 0) {
this.gameOver();
}
}
});
}
// Camera follows player updateCamera() {
camera.position.x = player.position.x; const playerPos = this.player.getPosition();
camera.position.z = player.position.z + 15; this.camera.position.x = playerPos.x;
camera.lookAt(player.position); this.camera.position.z = playerPos.z + 15;
this.camera.lookAt(playerPos);
}
renderer.render(scene, camera); updateUI() {
document.getElementById('score').textContent = this.score;
document.getElementById('health').textContent = Math.max(0, this.health);
}
gameOver() {
this.gameActive = false;
document.getElementById('finalScore').textContent = this.score;
document.getElementById('gameOver').style.display = 'block';
}
restart() {
// Remove all coins and obstacles
this.coins.forEach(coin => coin.remove());
this.obstacles.forEach(obstacle => obstacle.remove());
this.coins = [];
this.obstacles = [];
// Reset player
this.player.reset();
// Reset game state
this.score = 0;
this.health = 100;
this.gameActive = true;
// Recreate game objects
this.createGameObjects();
// Hide game over screen
document.getElementById('gameOver').style.display = 'none';
this.updateUI();
}
onWindowResize() {
this.camera.aspect = window.innerWidth / window.innerHeight;
this.camera.updateProjectionMatrix();
this.renderer.setSize(window.innerWidth, window.innerHeight);
}
animate() {
requestAnimationFrame(() => this.animate());
this.updatePlayer();
this.updateCoins();
this.updateObstacles();
this.updateCamera();
this.renderer.render(this.scene, this.camera);
}
} }
// Start the game // Start the game
init(); const game = new Game();
</script> </script>
</body> </body>
</html> </html>