threejs-test/src/game/Game.js
Juan Sebastian Montoya 112aa68a83
All checks were successful
Build and Publish Docker Image / Publish to Registry (push) Successful in 8s
Build and Publish Docker Image / Deploy to Portainer (push) Successful in 2s
Feature/Implement invincibility and combo systems (#16)
- Added Invincibility component to manage invincibility state and duration.
- Introduced InvincibilitySystem to handle visual effects during invincibility.
- Updated Game class to integrate combo multiplier mechanics and high score tracking.
- Enhanced UI to display current combo status and high score.
- Configured GameConfig for centralized game settings, including obstacle damage and invincibility duration.
- Updated game logic to reset combo on damage and manage health regeneration.

This update enhances gameplay dynamics by introducing invincibility frames and a scoring combo system.

Reviewed-on: #16
Co-authored-by: Juan Sebastian Montoya <juansmm@outlook.com>
Co-committed-by: Juan Sebastian Montoya <juansmm@outlook.com>
2025-11-26 16:49:25 -05:00

630 lines
19 KiB
JavaScript

import { World } from '../ecs/World.js';
import { EntityFactory } from './EntityFactory.js';
import { GameConfig } from './GameConfig.js';
// Systems
import { InputSystem } from '../systems/InputSystem.js';
import { PlayerControlSystem } from '../systems/PlayerControlSystem.js';
import { MovementSystem } from '../systems/MovementSystem.js';
import { BoundarySystem } from '../systems/BoundarySystem.js';
import { CoinSystem } from '../systems/CoinSystem.js';
import { ObstacleSystem } from '../systems/ObstacleSystem.js';
import { CollisionSystem } from '../systems/CollisionSystem.js';
import { InvincibilitySystem } from '../systems/InvincibilitySystem.js';
import { RenderSystem } from '../systems/RenderSystem.js';
// Components
import { Transform } from '../components/Transform.js';
import { Health } from '../components/Health.js';
import { Invincibility } from '../components/Invincibility.js';
/**
* Main Game class - manages the game loop and coordinates all systems.
* Orchestrates the ECS architecture and Three.js rendering.
*
* @typedef {import('../ecs/World.js').EntityId} EntityId
*/
export class Game {
constructor() {
/** @type {number} Size of the game play area */
this.groundSize = GameConfig.GROUND_SIZE;
/** @type {number} Current game score */
this.score = 0;
/** @type {number} High score (loaded from localStorage) */
this.highScore = this.loadHighScore();
/** @type {boolean} Whether the game is currently active */
this.gameActive = true;
/** @type {EntityId|null} The player entity ID */
this.playerEntity = null;
/** @type {EntityId[]} Array of coin entity IDs */
this.coins = [];
/** @type {EntityId[]} Array of obstacle entity IDs */
this.obstacles = [];
/** @type {number} Last frame timestamp for deltaTime calculation */
this.lastTime = performance.now();
/** @type {number} Maximum deltaTime cap (in seconds) to prevent huge jumps */
this.maxDeltaTime = 0.1; // 100ms cap
/** @type {number} Smoothed FPS for display */
this.smoothedFPS = 60;
/** @type {number} Last time performance monitor was updated */
this.lastPerfUpdate = performance.now();
/** @type {boolean} Whether the game is paused (e.g., tab not visible) */
this.isPaused = false;
/** @type {boolean} Whether performance monitor is visible */
this.perfMonitorVisible = false;
/** @type {Object} Shake detection state */
this.shakeDetection = {
lastX: 0,
lastY: 0,
lastZ: 0,
shakeThreshold: 15,
shakeCount: 0,
lastShakeTime: 0
};
// Combo system state
/** @type {number} Current combo multiplier (1x, 2x, 3x, etc.) */
this.comboMultiplier = 1;
/** @type {number} Time since last coin collection (for combo) */
this.comboTimer = 0;
/** @type {number} Last time a coin was collected */
this.lastCoinTime = 0;
// Health regeneration state
/** @type {number} Time since last health regeneration */
this.healthRegenTimer = 0;
this.init();
this.setupEventListeners();
this.animate();
}
/**
* Initialize the game (ECS, Three.js, entities)
*/
init() {
// Initialize ECS
this.world = new World();
// Setup Three.js
this.setupScene();
this.setupCamera();
this.setupRenderer();
this.setupLights();
this.setupGround();
// Create entity factory
this.entityFactory = new EntityFactory(this.world, this.scene);
// Initialize systems
this.setupSystems();
// Create game entities
this.createGameEntities();
}
setupScene() {
this.scene = new window.THREE.Scene();
this.scene.background = new window.THREE.Color(0x87CEEB);
this.scene.fog = new window.THREE.Fog(0x87CEEB, 0, 50);
}
setupCamera() {
this.camera = new window.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 window.THREE.WebGLRenderer({ antialias: true });
this.renderer.setSize(window.innerWidth, window.innerHeight);
this.renderer.shadowMap.enabled = true;
this.renderer.shadowMap.type = window.THREE.PCFSoftShadowMap;
document.body.appendChild(this.renderer.domElement);
}
setupLights() {
// Increased ambient light for brighter scene (was 0.6)
const ambientLight = new window.THREE.AmbientLight(0xffffff, 0.6);
this.scene.add(ambientLight);
// Increased directional light for better clarity (was 0.8)
const directionalLight = new window.THREE.DirectionalLight(0xffffff, 3.0);
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 window.THREE.PlaneGeometry(this.groundSize, this.groundSize);
const groundMaterial = new window.THREE.MeshStandardMaterial({
color: 0x90EE90,
roughness: 0.8
});
const ground = new window.THREE.Mesh(groundGeometry, groundMaterial);
ground.rotation.x = -Math.PI / 2;
ground.receiveShadow = true;
this.scene.add(ground);
const gridHelper = new window.THREE.GridHelper(this.groundSize, 20, 0x000000, 0x000000);
gridHelper.material.opacity = 0.2;
gridHelper.material.transparent = true;
this.scene.add(gridHelper);
}
setupSystems() {
// Input system (must be first)
this.inputSystem = new InputSystem();
this.world.addSystem(this.inputSystem);
// Player control
this.world.addSystem(new PlayerControlSystem(this.inputSystem));
// Movement and physics
this.world.addSystem(new MovementSystem());
this.world.addSystem(new BoundarySystem());
this.world.addSystem(new ObstacleSystem());
// Game-specific behavior
this.world.addSystem(new CoinSystem());
// Invincibility system (before collision to update state)
this.world.addSystem(new InvincibilitySystem());
// Collision detection
this.collisionSystem = new CollisionSystem();
this.collisionSystem.onCollision((entity1, entity2, layer1, layer2) => {
this.handleCollision(entity1, entity2, layer1, layer2);
});
this.world.addSystem(this.collisionSystem);
// Rendering (must be last to sync transforms)
this.world.addSystem(new RenderSystem(this.scene));
}
createGameEntities() {
// Create player
this.playerEntity = this.entityFactory.createPlayer();
// Create coins
for (let i = 0; i < GameConfig.INITIAL_COIN_COUNT; i++) {
const coin = this.entityFactory.createCoin(this.coins.length);
this.coins.push(coin);
}
// Create obstacles
for (let i = 0; i < GameConfig.INITIAL_OBSTACLE_COUNT; i++) {
const obstacle = this.entityFactory.createObstacle();
this.obstacles.push(obstacle);
}
}
handleCollision(entity1, entity2, layer1, layer2) {
if (!this.gameActive) return;
// Player-Coin collision
if ((layer1 === 'player' && layer2 === 'coin') || (layer1 === 'coin' && layer2 === 'player')) {
const coinEntity = layer1 === 'coin' ? entity1 : entity2;
this.collectCoin(coinEntity);
}
// Player-Obstacle collision
if ((layer1 === 'player' && layer2 === 'obstacle') || (layer1 === 'obstacle' && layer2 === 'player')) {
const playerEntity = layer1 === 'player' ? entity1 : entity2;
const obstacleEntity = layer1 === 'obstacle' ? entity1 : entity2;
this.handleObstacleCollision(playerEntity, obstacleEntity);
}
}
collectCoin(coinEntity) {
// Remove coin
this.entityFactory.destroyEntity(coinEntity);
const index = this.coins.indexOf(coinEntity);
if (index > -1) {
this.coins.splice(index, 1);
}
// Update combo system
const currentTime = performance.now() / 1000; // Convert to seconds
const timeSinceLastCoin = currentTime - this.lastCoinTime;
if (timeSinceLastCoin <= GameConfig.COMBO_TIME_WINDOW && this.lastCoinTime > 0) {
// Maintain combo
this.comboMultiplier = Math.min(
this.comboMultiplier + 1,
GameConfig.COMBO_MULTIPLIER_MAX
);
this.comboTimer = GameConfig.COMBO_TIME_WINDOW;
} else {
// Reset combo
this.comboMultiplier = 1;
this.comboTimer = GameConfig.COMBO_TIME_WINDOW;
}
this.lastCoinTime = currentTime;
// Calculate score with combo multiplier
const baseScore = GameConfig.COMBO_BASE_SCORE;
const scoreGain = baseScore * this.comboMultiplier;
this.score += scoreGain;
// Check for new high score
if (this.score > this.highScore) {
this.highScore = this.score;
this.saveHighScore(this.highScore);
}
this.updateUI();
// Spawn new coin
const newCoin = this.entityFactory.createCoin(this.coins.length);
this.coins.push(newCoin);
}
handleObstacleCollision(playerEntity, obstacleEntity) {
// Check if player is invincible
const invincibility = this.world.getComponent(playerEntity, Invincibility);
if (invincibility && invincibility.getIsInvincible()) {
return; // No damage if invincible
}
const health = this.world.getComponent(playerEntity, Health);
const playerTransform = this.world.getComponent(playerEntity, Transform);
const obstacleTransform = this.world.getComponent(obstacleEntity, Transform);
// Damage player (using config damage amount)
const isDead = health.damage(GameConfig.OBSTACLE_DAMAGE);
// Activate invincibility frames
if (invincibility) {
invincibility.activate(GameConfig.INVINCIBILITY_DURATION);
}
// Push player back
const pushDirection = playerTransform.position.clone().sub(obstacleTransform.position);
pushDirection.y = 0;
pushDirection.normalize();
playerTransform.position.add(pushDirection.multiplyScalar(0.3));
playerTransform.position.y = 0.5;
// Reset combo on damage
this.comboMultiplier = 1;
this.comboTimer = 0;
this.updateUI();
if (isDead) {
this.gameOver();
}
}
updateCamera() {
if (!this.playerEntity) return;
const playerTransform = this.world.getComponent(this.playerEntity, Transform);
if (playerTransform) {
this.camera.position.x = playerTransform.position.x;
this.camera.position.z = playerTransform.position.z + 15;
this.camera.lookAt(playerTransform.position);
}
}
updateUI() {
document.getElementById('score').textContent = this.score;
// Update high score
const highScoreEl = document.getElementById('highScore');
if (highScoreEl) {
highScoreEl.textContent = this.highScore;
}
// Update combo display
const comboEl = document.getElementById('combo');
if (comboEl) {
if (this.comboMultiplier > 1 && this.comboTimer > 0) {
comboEl.textContent = `${this.comboMultiplier}x COMBO!`;
comboEl.style.display = 'block';
} else {
comboEl.style.display = 'none';
}
}
const health = this.world.getComponent(this.playerEntity, Health);
if (health) {
document.getElementById('health').textContent = Math.max(0, health.currentHealth);
}
}
gameOver() {
this.gameActive = false;
document.getElementById('finalScore').textContent = this.score;
// Show "New High Score!" if applicable
const newHighScoreEl = document.getElementById('newHighScore');
if (newHighScoreEl && this.score === this.highScore && this.score > 0) {
newHighScoreEl.style.display = 'block';
} else if (newHighScoreEl) {
newHighScoreEl.style.display = 'none';
}
document.getElementById('gameOver').style.display = 'block';
}
restart() {
// Clean up old entities
[...this.coins].forEach(coin => this.entityFactory.destroyEntity(coin));
[...this.obstacles].forEach(obstacle => this.entityFactory.destroyEntity(obstacle));
if (this.playerEntity) {
this.entityFactory.destroyEntity(this.playerEntity);
}
this.coins = [];
this.obstacles = [];
// Reset game state
this.score = 0;
this.gameActive = true;
this.lastTime = performance.now(); // Reset timer to prevent deltaTime spike
// Reset combo system
this.comboMultiplier = 1;
this.comboTimer = 0;
this.lastCoinTime = 0;
// Reset health regeneration timer
this.healthRegenTimer = 0;
// Recreate entities
this.createGameEntities();
// Hide game over screen
document.getElementById('gameOver').style.display = 'none';
this.updateUI();
}
setupEventListeners() {
window.addEventListener('resize', () => this.onWindowResize());
document.getElementById('restartBtn').addEventListener('click', () => this.restart());
// Toggle performance monitor with 'T' key
window.addEventListener('keydown', (e) => {
if (e.key.toLowerCase() === 't') {
this.togglePerformanceMonitor();
}
});
// Shake detection for mobile
if (window.DeviceMotionEvent) {
window.addEventListener('devicemotion', (e) => this.handleDeviceMotion(e), false);
}
// Pause game when tab loses focus
document.addEventListener('visibilitychange', () => {
if (document.hidden) {
this.isPaused = true;
console.log('Game paused (tab hidden)');
} else {
this.isPaused = false;
// Reset timer to prevent deltaTime spike
this.lastTime = performance.now();
console.log('Game resumed');
}
});
// Also handle window blur/focus as fallback
window.addEventListener('blur', () => {
this.isPaused = true;
});
window.addEventListener('focus', () => {
if (!document.hidden) {
this.isPaused = false;
this.lastTime = performance.now();
}
});
// Load version
this.loadVersion();
}
/**
* Toggle performance monitor visibility
*/
togglePerformanceMonitor() {
this.perfMonitorVisible = !this.perfMonitorVisible;
const monitor = document.getElementById('perfMonitor');
if (this.perfMonitorVisible) {
monitor.classList.add('visible');
console.log('Performance monitor enabled');
} else {
monitor.classList.remove('visible');
console.log('Performance monitor disabled');
}
}
/**
* Handle device motion for shake detection
* @param {DeviceMotionEvent} event
*/
handleDeviceMotion(event) {
const acceleration = event.accelerationIncludingGravity;
if (!acceleration) return;
const currentTime = Date.now();
const timeDiff = currentTime - this.shakeDetection.lastShakeTime;
if (timeDiff > 100) { // Check every 100ms
const { x = 0, y = 0, z = 0 } = acceleration;
const deltaX = Math.abs(x - this.shakeDetection.lastX);
const deltaY = Math.abs(y - this.shakeDetection.lastY);
const deltaZ = Math.abs(z - this.shakeDetection.lastZ);
if (deltaX + deltaY + deltaZ > this.shakeDetection.shakeThreshold) {
this.shakeDetection.shakeCount++;
// Toggle after 2 shakes within 500ms
if (this.shakeDetection.shakeCount >= 2) {
this.togglePerformanceMonitor();
this.shakeDetection.shakeCount = 0;
}
} else {
this.shakeDetection.shakeCount = 0;
}
this.shakeDetection.lastX = x;
this.shakeDetection.lastY = y;
this.shakeDetection.lastZ = z;
this.shakeDetection.lastShakeTime = currentTime;
}
}
loadVersion() {
fetch('/version.json')
.then(response => {
if (response.ok) {
return response.json();
}
throw new Error('Version file not found');
})
.then(data => {
const versionElement = document.getElementById('versionNumber');
if (versionElement && data.version) {
versionElement.textContent = data.version;
}
})
.catch(error => {
console.debug('Version information not available:', error.message);
});
}
/**
* Load high score from localStorage
* @returns {number} High score value
*/
loadHighScore() {
try {
const saved = localStorage.getItem(GameConfig.STORAGE_HIGH_SCORE);
return saved ? parseInt(saved, 10) : 0;
} catch (error) {
console.debug('Failed to load high score:', error);
return 0;
}
}
/**
* Save high score to localStorage
* @param {number} score - Score to save
*/
saveHighScore(score) {
try {
localStorage.setItem(GameConfig.STORAGE_HIGH_SCORE, score.toString());
} catch (error) {
console.debug('Failed to save high score:', error);
}
}
onWindowResize() {
this.camera.aspect = window.innerWidth / window.innerHeight;
this.camera.updateProjectionMatrix();
this.renderer.setSize(window.innerWidth, window.innerHeight);
}
/**
* Main game loop - calculates deltaTime and updates all systems
* @param {number} [currentTime] - Current timestamp from requestAnimationFrame
*/
animate(currentTime = performance.now()) {
requestAnimationFrame((time) => this.animate(time));
// If paused, skip updates but keep rendering
if (this.isPaused) {
this.renderer.render(this.scene, this.camera);
return;
}
// Calculate deltaTime in seconds
const deltaTime = Math.min((currentTime - this.lastTime) / 1000, this.maxDeltaTime);
this.lastTime = currentTime;
// Update performance monitor with smoothed values
if (this.perfMonitorVisible) {
// Calculate instant FPS from deltaTime
const instantFPS = 1 / deltaTime;
// Smooth FPS using exponential moving average for stability
this.smoothedFPS = this.smoothedFPS * 0.9 + instantFPS * 0.1;
// Update display every 100ms for real-time feel without flickering
if (currentTime - this.lastPerfUpdate >= 100) {
const frameTime = (deltaTime * 1000).toFixed(1);
const entityCount = this.world.entities.size;
document.getElementById('fps').textContent = Math.round(this.smoothedFPS);
document.getElementById('frameTime').textContent = frameTime;
document.getElementById('entityCount').textContent = entityCount;
this.lastPerfUpdate = currentTime;
}
}
if (this.gameActive) {
// Update combo timer
this.comboTimer = Math.max(0, this.comboTimer - deltaTime);
if (this.comboTimer <= 0 && this.comboMultiplier > 1) {
this.comboMultiplier = 1;
this.updateUI();
}
// Update health regeneration
this.healthRegenTimer += deltaTime;
if (this.healthRegenTimer >= GameConfig.HEALTH_REGEN_INTERVAL) {
const health = this.world.getComponent(this.playerEntity, Health);
if (health && health.currentHealth < health.maxHealth) {
health.heal(GameConfig.HEALTH_REGEN_AMOUNT);
this.updateUI();
}
this.healthRegenTimer = 0;
}
// Update ECS world with actual deltaTime
this.world.update(deltaTime);
// Update camera
this.updateCamera();
}
// Render scene
this.renderer.render(this.scene, this.camera);
}
}