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); } }