feat: Implement game UI and state management
All checks were successful
Build and Publish Docker Image / Build and Validate (pull_request) Successful in 9s

- Added start menu, pause menu, and game over screens to enhance user experience.
- Introduced UISystem to manage UI state transitions and visibility.
- Updated Game class to handle game state (menu, playing, paused, game over).
- Integrated CameraSystem for improved camera control and screen shake effects.
- Added new components for collision handling, scoring, and game state management.
- Refactored sound management to separate background music handling.

This update significantly improves the game's UI and overall gameplay flow.
This commit is contained in:
Juan Sebastián Montoya 2025-11-26 18:46:03 -05:00
parent a95a079d0b
commit d8748f4c4a
14 changed files with 1467 additions and 751 deletions

View file

@ -41,29 +41,43 @@
0%, 100% { transform: translateX(-50%) scale(1); } 0%, 100% { transform: translateX(-50%) scale(1); }
50% { transform: translateX(-50%) scale(1.2); } 50% { transform: translateX(-50%) scale(1.2); }
} }
#gameOver { #startMenu, #pauseMenu, #gameOver {
position: absolute; position: absolute;
top: 50%; top: 50%;
left: 50%; left: 50%;
transform: translate(-50%, -50%); transform: translate(-50%, -50%);
background: rgba(0,0,0,0.8); background: rgba(0,0,0,0.9);
padding: 40px; padding: 50px;
border-radius: 20px; border-radius: 20px;
text-align: center; text-align: center;
color: white; color: white;
display: none;
z-index: 200; z-index: 200;
min-width: 400px;
box-shadow: 0 10px 40px rgba(0,0,0,0.5);
} }
#gameOver h1 { #startMenu {
display: block;
}
#pauseMenu, #gameOver {
display: none;
}
#startMenu h1, #pauseMenu h1, #gameOver h1 {
font-size: 48px; font-size: 48px;
margin-bottom: 20px; margin-bottom: 20px;
color: #4CAF50;
}
#pauseMenu h1 {
color: #FFA500;
}
#gameOver h1 {
color: #ff6b6b; color: #ff6b6b;
} }
#gameOver p { #startMenu p, #pauseMenu p, #gameOver p {
font-size: 24px; font-size: 20px;
margin-bottom: 30px; margin-bottom: 30px;
line-height: 1.6;
} }
#restartBtn { #startMenu button, #pauseMenu button, #restartBtn {
background: #4CAF50; background: #4CAF50;
border: none; border: none;
color: white; color: white;
@ -72,11 +86,18 @@
border-radius: 10px; border-radius: 10px;
cursor: pointer; cursor: pointer;
transition: all 0.3s; transition: all 0.3s;
margin: 10px;
} }
#restartBtn:hover { #startMenu button:hover, #pauseMenu button:hover, #restartBtn:hover {
background: #45a049; background: #45a049;
transform: scale(1.1); transform: scale(1.1);
} }
#pauseMenu button.resumeBtn {
background: #2196F3;
}
#pauseMenu button.resumeBtn:hover {
background: #1976D2;
}
#instructions { #instructions {
position: absolute; position: absolute;
bottom: 20px; bottom: 20px;
@ -153,7 +174,7 @@
</style> </style>
</head> </head>
<body> <body>
<div id="ui"> <div id="ui" style="display: none;">
<div>Score: <span id="score">0</span></div> <div>Score: <span id="score">0</span></div>
<div>High Score: <span id="highScore">0</span></div> <div>High Score: <span id="highScore">0</span></div>
<div>Health: <span id="health">100</span></div> <div>Health: <span id="health">100</span></div>
@ -171,6 +192,25 @@
<div><span class="label">Entities:</span> <span id="entityCount">0</span></div> <div><span class="label">Entities:</span> <span id="entityCount">0</span></div>
</div> </div>
<div id="startMenu">
<h1>🎮 Coin Collector</h1>
<p>Collect coins, avoid obstacles, and survive as long as you can!</p>
<p style="font-size: 16px; opacity: 0.8; margin-top: 20px;">
<strong>Controls:</strong><br>
WASD or Arrow Keys to move<br>
Touch and drag (mobile)<br>
ESC or P to pause
</p>
<button id="startBtn">Start Game</button>
</div>
<div id="pauseMenu">
<h1>⏸️ Paused</h1>
<p>Game is paused</p>
<button class="resumeBtn" id="resumeBtn">Resume</button>
<button id="pauseMenuBtn">Main Menu</button>
</div>
<div id="gameOver"> <div id="gameOver">
<h1>Game Over!</h1> <h1>Game Over!</h1>
<p id="newHighScore" style="display: none; color: #FFD700; font-size: 28px; margin-bottom: 10px;">🏆 New High Score! 🏆</p> <p id="newHighScore" style="display: none; color: #FFD700; font-size: 28px; margin-bottom: 10px;">🏆 New High Score! 🏆</p>
@ -178,9 +218,9 @@
<button id="restartBtn">Play Again</button> <button id="restartBtn">Play Again</button>
</div> </div>
<div id="instructions"> <div id="instructions" style="display: none;">
<p><strong>Controls:</strong> WASD or Arrow Keys to move | Touch and drag to move (mobile) | Collect yellow coins | Avoid red obstacles!</p> <p><strong>Controls:</strong> WASD or Arrow Keys to move | Touch and drag to move (mobile) | Collect yellow coins | Avoid red obstacles!</p>
<p style="margin-top: 5px; font-size: 11px; opacity: 0.7;">Press "T" to toggle performance monitor | Press "M" to toggle sound</p> <p style="margin-top: 5px; font-size: 11px; opacity: 0.7;">Press "T" to toggle performance monitor | Press "M" to toggle sound | Press "ESC" or "P" to pause</p>
</div> </div>
<script type="module"> <script type="module">

11
src/components/Camera.js Normal file
View file

@ -0,0 +1,11 @@
/**
* Camera component - tracks camera state (screen shake, etc.)
* Attached to a singleton camera entity or the player
*/
export class Camera {
constructor() {
/** @type {number} Remaining screen shake time */
this.screenShakeTime = 0;
}
}

View file

@ -0,0 +1,21 @@
/**
* CollisionEvent component - represents a collision between two entities
* Added by CollisionSystem, processed by CollisionResponseSystem
*/
export class CollisionEvent {
/**
* @param {number} otherEntity - The other entity in the collision
* @param {string} otherLayer - The layer of the other entity
*/
constructor(otherEntity, otherLayer) {
/** @type {number} The other entity ID */
this.otherEntity = otherEntity;
/** @type {string} The layer of the other entity */
this.otherLayer = otherLayer;
/** @type {boolean} Whether this event has been processed */
this.processed = false;
}
}

View file

@ -0,0 +1,11 @@
/**
* GameOver component - marks that the game should end
* Added by systems when player dies, processed by Game
*/
export class GameOver {
constructor() {
/** @type {boolean} Whether this event has been processed */
this.processed = false;
}
}

23
src/components/Score.js Normal file
View file

@ -0,0 +1,23 @@
/**
* Score component - tracks game score and combo
* Attached to a singleton game state entity
*/
export class Score {
constructor() {
/** @type {number} Current score */
this.score = 0;
/** @type {number} High score */
this.highScore = 0;
/** @type {number} Current combo multiplier */
this.comboMultiplier = 1;
/** @type {number} Combo timer remaining */
this.comboTimer = 0;
/** @type {number} Last time a coin was collected */
this.lastCoinTime = 0;
}
}

View file

@ -154,9 +154,18 @@ export class World {
} }
/** /**
* Cleanup all systems and clear all entities/components * Cleanup all entities/components but keep systems
*/ */
cleanup() { cleanup() {
// Clear all entities and components, but keep systems
this.entities.clear();
this.components.clear();
}
/**
* Full cleanup - removes systems too (use with caution)
*/
fullCleanup() {
for (const system of this.systems) { for (const system of this.systems) {
if (system.cleanup) { if (system.cleanup) {
system.cleanup(); system.cleanup();

View file

@ -10,18 +10,21 @@ import { BoundarySystem } from '../systems/BoundarySystem.js';
import { CoinSystem } from '../systems/CoinSystem.js'; import { CoinSystem } from '../systems/CoinSystem.js';
import { ObstacleSystem } from '../systems/ObstacleSystem.js'; import { ObstacleSystem } from '../systems/ObstacleSystem.js';
import { CollisionSystem } from '../systems/CollisionSystem.js'; import { CollisionSystem } from '../systems/CollisionSystem.js';
import { CollisionResponseSystem } from '../systems/CollisionResponseSystem.js';
import { InvincibilitySystem } from '../systems/InvincibilitySystem.js'; import { InvincibilitySystem } from '../systems/InvincibilitySystem.js';
import { ParticleSystem } from '../systems/ParticleSystem.js'; import { ParticleSystem } from '../systems/ParticleSystem.js';
import { PowerUpSystem } from '../systems/PowerUpSystem.js'; import { PowerUpSystem } from '../systems/PowerUpSystem.js';
import { GameStateSystem } from '../systems/GameStateSystem.js';
import { CameraSystem } from '../systems/CameraSystem.js';
import { SoundSystem } from '../systems/SoundSystem.js'; import { SoundSystem } from '../systems/SoundSystem.js';
import { UISystem } from '../systems/UISystem.js';
import { RenderSystem } from '../systems/RenderSystem.js'; import { RenderSystem } from '../systems/RenderSystem.js';
// Components // Components
import { Transform } from '../components/Transform.js'; import { Transform } from '../components/Transform.js';
import { Health } from '../components/Health.js'; import { Health } from '../components/Health.js';
import { Invincibility } from '../components/Invincibility.js'; import { Score } from '../components/Score.js';
import { CoinType } from '../components/CoinType.js'; import { GameOver } from '../components/GameOver.js';
import { PowerUp } from '../components/PowerUp.js';
import { SoundEvent } from '../components/SoundEvent.js'; import { SoundEvent } from '../components/SoundEvent.js';
/** /**
@ -35,27 +38,15 @@ export class Game {
/** @type {number} Size of the game play area */ /** @type {number} Size of the game play area */
this.groundSize = GameConfig.GROUND_SIZE; 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 */ /** @type {boolean} Whether the game is currently active */
this.gameActive = true; this.gameActive = false; // Start with menu shown
/** @type {boolean} Whether the game is in menu state (start menu) */
this.inMenu = true;
/** @type {EntityId|null} The player entity ID */ /** @type {EntityId|null} The player entity ID */
this.playerEntity = null; this.playerEntity = null;
/** @type {EntityId[]} Array of coin entity IDs */
this.coins = [];
/** @type {EntityId[]} Array of obstacle entity IDs */
this.obstacles = [];
/** @type {EntityId[]} Array of power-up entity IDs */
this.powerUps = [];
/** @type {number} Last frame timestamp for deltaTime calculation */ /** @type {number} Last frame timestamp for deltaTime calculation */
this.lastTime = performance.now(); this.lastTime = performance.now();
@ -68,9 +59,12 @@ export class Game {
/** @type {number} Last time performance monitor was updated */ /** @type {number} Last time performance monitor was updated */
this.lastPerfUpdate = performance.now(); this.lastPerfUpdate = performance.now();
/** @type {boolean} Whether the game is paused (e.g., tab not visible) */ /** @type {boolean} Whether the game is paused (e.g., tab not visible or manually paused) */
this.isPaused = false; this.isPaused = false;
/** @type {number} Time scale multiplier (0 = paused, 1 = normal, >1 = fast forward) */
this.timeScale = 1.0;
/** @type {boolean} Whether performance monitor is visible */ /** @type {boolean} Whether performance monitor is visible */
this.perfMonitorVisible = false; this.perfMonitorVisible = false;
@ -84,36 +78,6 @@ export class Game {
lastShakeTime: 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;
// Screen shake state
/** @type {number} Remaining screen shake time */
this.screenShakeTime = 0;
/** @type {import('three').Vector3} Original camera position offset */
this.cameraBaseOffset = new window.THREE.Vector3(0, 10, 15);
// Difficulty scaling state
/** @type {number} Game start time */
this.gameStartTime = performance.now() / 1000;
/** @type {number} Last score when obstacle was added */
this.lastDifficultyScore = 0;
/** @type {number} Last time when obstacle was added */
this.lastDifficultyTime = 0;
this.init(); this.init();
this.setupEventListeners(); this.setupEventListeners();
@ -140,9 +104,11 @@ export class Game {
// Initialize systems // Initialize systems
this.setupSystems(); this.setupSystems();
// Create game entities // Create game entities only if game is active (not in menu)
if (this.gameActive && !this.inMenu) {
this.createGameEntities(); this.createGameEntities();
} }
}
setupScene() { setupScene() {
this.scene = new window.THREE.Scene(); this.scene = new window.THREE.Scene();
@ -162,6 +128,12 @@ export class Game {
} }
setupRenderer() { setupRenderer() {
// Remove existing renderer if it exists
if (this.renderer && this.renderer.domElement && this.renderer.domElement.parentNode) {
this.renderer.domElement.parentNode.removeChild(this.renderer.domElement);
this.renderer.dispose();
}
this.renderer = new window.THREE.WebGLRenderer({ antialias: true }); this.renderer = new window.THREE.WebGLRenderer({ antialias: true });
this.renderer.setSize(window.innerWidth, window.innerHeight); this.renderer.setSize(window.innerWidth, window.innerHeight);
this.renderer.shadowMap.enabled = true; this.renderer.shadowMap.enabled = true;
@ -245,20 +217,39 @@ export class Game {
// Collision detection // Collision detection
this.collisionSystem = new CollisionSystem(); this.collisionSystem = new CollisionSystem();
this.collisionSystem.onCollision((entity1, entity2, layer1, layer2) => {
this.handleCollision(entity1, entity2, layer1, layer2);
});
this.world.addSystem(this.collisionSystem); this.world.addSystem(this.collisionSystem);
// Collision response (processes CollisionEvent components)
this.collisionResponseSystem = new CollisionResponseSystem(
this.entityFactory,
this.powerUpSystem,
this.particleSystem
);
this.world.addSystem(this.collisionResponseSystem);
// Game state system (manages score, combo, health regen, difficulty)
this.gameStateSystem = new GameStateSystem(this.entityFactory);
this.world.addSystem(this.gameStateSystem);
// Camera system (manages camera position and screen shake)
this.cameraSystem = new CameraSystem(this.camera);
this.world.addSystem(this.cameraSystem);
// Sound system (processes SoundEvent components) // Sound system (processes SoundEvent components)
this.soundSystem = new SoundSystem(); this.soundSystem = new SoundSystem();
this.world.addSystem(this.soundSystem); this.world.addSystem(this.soundSystem);
// UI system (manages menu/UI state)
this.uiSystem = new UISystem();
this.world.addSystem(this.uiSystem);
// Rendering (must be last to sync transforms) // Rendering (must be last to sync transforms)
this.world.addSystem(new RenderSystem(this.scene)); this.world.addSystem(new RenderSystem(this.scene));
} }
createGameEntities() { createGameEntities() {
if (!this.gameActive || this.inMenu) return;
// Create player // Create player
this.playerEntity = this.entityFactory.createPlayer(); this.playerEntity = this.entityFactory.createPlayer();
@ -269,321 +260,50 @@ export class Game {
if (this.powerUpSystem) { if (this.powerUpSystem) {
this.powerUpSystem.setPlayerEntity(this.playerEntity); this.powerUpSystem.setPlayerEntity(this.playerEntity);
} }
if (this.gameStateSystem) {
this.gameStateSystem.setPlayerEntity(this.playerEntity);
}
if (this.cameraSystem) {
this.cameraSystem.setPlayerEntity(this.playerEntity);
}
// Create coins // Create coins (systems will manage their own entities)
for (let i = 0; i < GameConfig.INITIAL_COIN_COUNT; i++) { for (let i = 0; i < GameConfig.INITIAL_COIN_COUNT; i++) {
const coin = this.entityFactory.createCoin(this.coins.length); this.entityFactory.createCoin();
this.coins.push(coin);
} }
// Create obstacles (mix of types) // Create obstacles
for (let i = 0; i < GameConfig.INITIAL_OBSTACLE_COUNT; i++) { for (let i = 0; i < GameConfig.INITIAL_OBSTACLE_COUNT; i++) {
const obstacle = this.entityFactory.createObstacle(); 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-PowerUp collision
if ((layer1 === 'player' && layer2 === 'powerup') || (layer1 === 'powerup' && layer2 === 'player')) {
const powerUpEntity = layer1 === 'powerup' ? entity1 : entity2;
this.collectPowerUp(powerUpEntity);
}
// 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) {
// Get coin position and type before destroying
const coinTransform = this.world.getComponent(coinEntity, Transform);
const coinType = this.world.getComponent(coinEntity, CoinType);
const coinPosition = coinTransform ? coinTransform.position.clone() : null;
// Emit sound event BEFORE destroying the entity
const soundEntity = this.world.createEntity();
if (coinType && coinType.type === 'health') {
this.world.addComponent(soundEntity, new SoundEvent('health'));
} else {
this.world.addComponent(soundEntity, new SoundEvent('coin'));
}
// Remove coin
this.entityFactory.destroyEntity(coinEntity);
const index = this.coins.indexOf(coinEntity);
if (index > -1) {
this.coins.splice(index, 1);
}
// Determine particle color based on coin type
let particleColor = 0xFFD700; // Default gold
if (coinType) {
if (coinType.type === 'silver') {
particleColor = 0xC0C0C0;
} else if (coinType.type === 'diamond') {
particleColor = 0x00FFFF;
} else if (coinType.type === 'health') {
particleColor = 0x00FF00;
}
}
// Emit particles for coin collection
if (coinPosition && this.particleSystem) {
this.particleSystem.emit(
coinPosition,
GameConfig.PARTICLE_COUNT_COIN,
particleColor,
8
);
}
// Handle health coin
if (coinType && coinType.type === 'health') {
const health = this.world.getComponent(this.playerEntity, Health);
if (health) {
health.heal(coinType.healthRestore);
this.updateUI();
}
// Health coins don't contribute to combo or score
const newCoin = this.entityFactory.createCoin(this.coins.length);
this.coins.push(newCoin);
return;
}
// Update combo system (only for score coins)
const currentTime = performance.now() / 1000; // Convert to seconds
const timeSinceLastCoin = currentTime - this.lastCoinTime;
if (timeSinceLastCoin <= GameConfig.COMBO_TIME_WINDOW && this.lastCoinTime > 0) {
// Maintain combo
const oldMultiplier = this.comboMultiplier;
this.comboMultiplier = Math.min(
this.comboMultiplier + 1,
GameConfig.COMBO_MULTIPLIER_MAX
);
this.comboTimer = GameConfig.COMBO_TIME_WINDOW;
// Emit combo sound event if multiplier increased
if (this.comboMultiplier > oldMultiplier) {
// Create temporary entity for combo sound
const comboEntity = this.world.createEntity();
this.world.addComponent(comboEntity, new SoundEvent('combo', this.comboMultiplier));
}
} else {
// Reset combo
this.comboMultiplier = 1;
this.comboTimer = GameConfig.COMBO_TIME_WINDOW;
}
this.lastCoinTime = currentTime;
// Calculate score with combo multiplier and power-up multiplier (use coin's base value)
const baseScore = coinType ? coinType.scoreValue : GameConfig.COMBO_BASE_SCORE;
const powerUpMultiplier = this.powerUpSystem ? this.powerUpSystem.scoreMultiplier : 1.0;
const scoreGain = baseScore * this.comboMultiplier * powerUpMultiplier;
this.score += scoreGain;
// Check for new high score
if (this.score > this.highScore) {
this.highScore = this.score;
this.saveHighScore(this.highScore);
// Emit high score sound event
const highScoreEntity = this.world.createEntity();
this.world.addComponent(highScoreEntity, new SoundEvent('highscore'));
}
this.updateUI();
// Spawn new coin or power-up (based on chance)
if (Math.random() < GameConfig.POWERUP_SPAWN_CHANCE) {
const powerUp = this.entityFactory.createPowerUp();
this.powerUps.push(powerUp);
} else {
const newCoin = this.entityFactory.createCoin(this.coins.length);
this.coins.push(newCoin);
}
}
collectPowerUp(powerUpEntity) {
// Get power-up position and type before destroying
const powerUpTransform = this.world.getComponent(powerUpEntity, Transform);
const powerUp = this.world.getComponent(powerUpEntity, PowerUp);
const powerUpPosition = powerUpTransform ? powerUpTransform.position.clone() : null;
// Remove power-up
this.entityFactory.destroyEntity(powerUpEntity);
const index = this.powerUps.indexOf(powerUpEntity);
if (index > -1) {
this.powerUps.splice(index, 1);
}
// Activate power-up effect
if (powerUp && this.powerUpSystem) {
this.powerUpSystem.activatePowerUp(powerUp.type, powerUp.duration);
// Special handling for shield - activate invincibility
if (powerUp.type === 'shield') {
const invincibility = this.world.getComponent(this.playerEntity, Invincibility);
if (invincibility) {
invincibility.activate(powerUp.duration);
}
}
// Emit power-up sound event
const powerUpSoundEntity = this.world.createEntity();
this.world.addComponent(powerUpSoundEntity, new SoundEvent('powerup'));
}
// Emit particles
if (powerUpPosition && this.particleSystem) {
let particleColor = 0x00FF00;
if (powerUp) {
switch (powerUp.type) {
case 'speed':
particleColor = 0x00FFFF;
break;
case 'shield':
particleColor = 0x0000FF;
break;
case 'multiplier':
particleColor = 0xFF00FF;
break;
case 'magnet':
particleColor = 0xFFFF00;
break;
}
}
this.particleSystem.emit(
powerUpPosition,
GameConfig.PARTICLE_COUNT_COIN,
particleColor,
10
);
}
// Spawn new coin or power-up
if (Math.random() < GameConfig.POWERUP_SPAWN_CHANCE) {
const newPowerUp = this.entityFactory.createPowerUp();
this.powerUps.push(newPowerUp);
} else {
const newCoin = this.entityFactory.createCoin(this.coins.length);
this.coins.push(newCoin);
}
}
handleObstacleCollision(playerEntity, obstacleEntity) {
// Check if player has shield power-up active
if (this.powerUpSystem && this.powerUpSystem.isActive('shield')) {
return; // No damage if shield is active
}
// 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);
}
// Screen shake on damage
this.screenShakeTime = GameConfig.SCREEN_SHAKE_DURATION;
// Emit damage particles
if (this.particleSystem && playerTransform) {
this.particleSystem.emit(
playerTransform.position.clone().add(new window.THREE.Vector3(0, 0.5, 0)),
GameConfig.PARTICLE_COUNT_DAMAGE,
0xFF0000, // Red color
6
);
}
// 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;
// Emit damage sound event
const damageSoundEntity = this.world.createEntity();
this.world.addComponent(damageSoundEntity, new SoundEvent('damage'));
this.updateUI();
if (isDead) {
this.gameOver();
}
}
updateCamera() {
if (!this.playerEntity) return;
const playerTransform = this.world.getComponent(this.playerEntity, Transform);
if (playerTransform) {
// Base camera position
let cameraX = playerTransform.position.x;
let cameraZ = playerTransform.position.z + 15;
let cameraY = 10;
// Apply screen shake
if (this.screenShakeTime > 0) {
const intensity = (this.screenShakeTime / GameConfig.SCREEN_SHAKE_DURATION) * GameConfig.SCREEN_SHAKE_INTENSITY;
cameraX += (Math.random() - 0.5) * intensity;
cameraY += (Math.random() - 0.5) * intensity;
cameraZ += (Math.random() - 0.5) * intensity;
}
this.camera.position.set(cameraX, cameraY, cameraZ);
this.camera.lookAt(playerTransform.position);
}
}
updateUI() { updateUI() {
document.getElementById('score').textContent = this.score; // Get score from game state system
const scoreEntities = this.world.getEntitiesWithComponents(Score);
if (scoreEntities.length > 0) {
const score = this.world.getComponent(scoreEntities[0], Score);
if (score) {
document.getElementById('score').textContent = score.score;
// Update high score
const highScoreEl = document.getElementById('highScore'); const highScoreEl = document.getElementById('highScore');
if (highScoreEl) { if (highScoreEl) {
highScoreEl.textContent = this.highScore; highScoreEl.textContent = score.highScore;
} }
// Update combo display
const comboEl = document.getElementById('combo'); const comboEl = document.getElementById('combo');
if (comboEl) { if (comboEl) {
if (this.comboMultiplier > 1 && this.comboTimer > 0) { if (score.comboMultiplier > 1 && score.comboTimer > 0) {
comboEl.textContent = `${this.comboMultiplier}x COMBO!`; comboEl.textContent = `${score.comboMultiplier}x COMBO!`;
comboEl.style.display = 'block'; comboEl.style.display = 'block';
} else { } else {
comboEl.style.display = 'none'; comboEl.style.display = 'none';
} }
} }
}
}
const health = this.world.getComponent(this.playerEntity, Health); const health = this.world.getComponent(this.playerEntity, Health);
if (health) { if (health) {
@ -593,56 +313,43 @@ export class Game {
gameOver() { gameOver() {
this.gameActive = false; this.gameActive = false;
document.getElementById('finalScore').textContent = this.score;
// Show "New High Score!" if applicable // Get score from game state
const scoreEntities = this.world.getEntitiesWithComponents(Score);
if (scoreEntities.length > 0) {
const score = this.world.getComponent(scoreEntities[0], Score);
if (score) {
document.getElementById('finalScore').textContent = score.score;
const newHighScoreEl = document.getElementById('newHighScore'); const newHighScoreEl = document.getElementById('newHighScore');
if (newHighScoreEl && this.score === this.highScore && this.score > 0) { if (newHighScoreEl && score.score === score.highScore && score.score > 0) {
newHighScoreEl.style.display = 'block'; newHighScoreEl.style.display = 'block';
} else if (newHighScoreEl) { } else if (newHighScoreEl) {
newHighScoreEl.style.display = 'none'; newHighScoreEl.style.display = 'none';
} }
}
// Emit game over sound event }
const gameOverEntity = this.world.createEntity();
this.world.addComponent(gameOverEntity, new SoundEvent('gameover'));
document.getElementById('gameOver').style.display = 'block'; document.getElementById('gameOver').style.display = 'block';
} }
restart() { restart() {
// Clean up old entities // Properly clean up all entities and their meshes
[...this.coins].forEach(coin => this.entityFactory.destroyEntity(coin)); this.cleanupEntities();
[...this.obstacles].forEach(obstacle => this.entityFactory.destroyEntity(obstacle));
[...this.powerUps].forEach(powerUp => this.entityFactory.destroyEntity(powerUp));
if (this.playerEntity) {
this.entityFactory.destroyEntity(this.playerEntity);
}
this.coins = [];
this.obstacles = [];
this.powerUps = [];
// Reset game state // Reset game state
this.score = 0; if (this.gameStateSystem) {
this.gameStateSystem.reset();
}
if (this.cameraSystem) {
this.cameraSystem.reset();
}
this.gameActive = true; this.gameActive = true;
this.lastTime = performance.now(); // Reset timer to prevent deltaTime spike this.inMenu = false;
this.isPaused = false;
// Reset combo system this.timeScale = 1.0;
this.comboMultiplier = 1; this.lastTime = performance.now();
this.comboTimer = 0;
this.lastCoinTime = 0;
// Reset health regeneration timer
this.healthRegenTimer = 0;
// Reset screen shake
this.screenShakeTime = 0;
// Reset difficulty scaling
this.gameStartTime = performance.now() / 1000;
this.lastDifficultyScore = 0;
this.lastDifficultyTime = 0;
// Recreate entities // Recreate entities
this.createGameEntities(); this.createGameEntities();
@ -652,8 +359,163 @@ export class Game {
this.updateUI(); this.updateUI();
} }
/**
* Properly clean up all entities and their meshes from the scene
*/
cleanupEntities() {
if (!this.entityFactory) return;
// Get all entity IDs before cleanup
const entityIds = Array.from(this.world.entities.keys());
// Destroy each entity properly (removes meshes from scene)
for (const entityId of entityIds) {
this.entityFactory.destroyEntity(entityId);
}
// Now clear the world (entities should already be removed, but this ensures cleanup)
this.world.cleanup();
}
startGame() {
this.inMenu = false;
this.gameActive = true;
this.isPaused = false;
this.timeScale = 1.0;
// Reset game state
if (this.gameStateSystem) {
this.gameStateSystem.reset();
}
if (this.cameraSystem) {
this.cameraSystem.reset();
}
// Properly clean up all entities and their meshes
this.cleanupEntities();
// Ensure entity factory exists
if (!this.entityFactory) {
this.entityFactory = new EntityFactory(this.world, this.scene);
}
// Create game entities
this.createGameEntities();
// Update UI state
if (this.uiSystem) {
this.uiSystem.setState('playing');
} else {
document.getElementById('startMenu').style.display = 'none';
document.getElementById('ui').style.display = 'block';
document.getElementById('instructions').style.display = 'block';
}
// Reset UI
this.updateUI();
document.getElementById('combo').style.display = 'none';
// Start background music if enabled and not already playing
if (this.soundSystem && this.soundSystem.isEnabled() && !this.soundSystem.bgMusicPlaying) {
this.soundSystem.startBackgroundMusic().catch(err => {
console.warn('Failed to start background music:', err);
});
}
}
/**
* Pause the game
*/
pauseGame() {
if (!this.gameActive || this.inMenu) return;
this.isPaused = true;
this.timeScale = 0; // Stop all time-based updates
// Pause background music
if (this.soundSystem && this.soundSystem.bgMusicPlaying) {
this.soundSystem.stopBackgroundMusic();
}
if (this.uiSystem) {
this.uiSystem.setState('paused');
} else {
document.getElementById('pauseMenu').style.display = 'block';
}
}
/**
* Resume the game from pause
*/
resumeGame() {
if (!this.gameActive || this.inMenu) return;
this.isPaused = false;
this.timeScale = 1.0; // Resume normal time
// Resume background music
if (this.soundSystem && this.soundSystem.isEnabled() && !this.soundSystem.bgMusicPlaying) {
this.soundSystem.startBackgroundMusic().catch(err => {
console.warn('Failed to resume background music:', err);
});
}
if (this.uiSystem) {
this.uiSystem.setState('playing');
} else {
document.getElementById('pauseMenu').style.display = 'none';
}
// Reset timer to prevent deltaTime spike
this.lastTime = performance.now();
}
/**
* Return to main menu
*/
returnToMenu() {
this.inMenu = true;
this.gameActive = false;
this.isPaused = false;
this.timeScale = 0; // Stop time in menu
// Update UI state
if (this.uiSystem) {
this.uiSystem.setState('menu');
} else {
// Fallback to manual UI management
document.getElementById('pauseMenu').style.display = 'none';
document.getElementById('gameOver').style.display = 'none';
document.getElementById('startMenu').style.display = 'block';
document.getElementById('ui').style.display = 'none';
document.getElementById('instructions').style.display = 'none';
}
// Properly clean up all entities and their meshes
this.cleanupEntities();
// Stop background music
if (this.soundSystem) {
this.soundSystem.stopBackgroundMusic();
}
}
setupEventListeners() { setupEventListeners() {
window.addEventListener('resize', () => this.onWindowResize()); window.addEventListener('resize', () => this.onWindowResize());
// Menu buttons
const startBtn = document.getElementById('startBtn');
if (startBtn) {
startBtn.addEventListener('click', () => this.startGame());
}
const resumeBtn = document.getElementById('resumeBtn');
if (resumeBtn) {
resumeBtn.addEventListener('click', () => this.resumeGame());
}
const pauseMenuBtn = document.getElementById('pauseMenuBtn');
if (pauseMenuBtn) {
pauseMenuBtn.addEventListener('click', () => this.returnToMenu());
}
document.getElementById('restartBtn').addEventListener('click', () => this.restart()); document.getElementById('restartBtn').addEventListener('click', () => this.restart());
// Toggle performance monitor with 'T' key // Toggle performance monitor with 'T' key
@ -665,6 +527,14 @@ export class Game {
if (e.key.toLowerCase() === 'm') { if (e.key.toLowerCase() === 'm') {
this.toggleSound(); this.toggleSound();
} }
// Pause/Resume with ESC or P
if ((e.key === 'Escape' || e.key.toLowerCase() === 'p') && this.gameActive && !this.inMenu) {
if (this.isPaused) {
this.resumeGame();
} else {
this.pauseGame();
}
}
}); });
// Shake detection for mobile // Shake detection for mobile
@ -672,31 +542,51 @@ export class Game {
window.addEventListener('devicemotion', (e) => this.handleDeviceMotion(e), false); window.addEventListener('devicemotion', (e) => this.handleDeviceMotion(e), false);
} }
// Pause game when tab loses focus // Pause game when tab loses focus - show pause menu and require user interaction to resume
document.addEventListener('visibilitychange', () => { document.addEventListener('visibilitychange', () => {
if (document.hidden) { if (document.hidden) {
if (this.gameActive && !this.inMenu) {
// Pause when tab hidden - show pause menu
this.isPaused = true; this.isPaused = true;
console.log('Game paused (tab hidden)'); this.timeScale = 0;
} else {
this.isPaused = false; // Pause background music
// Reset timer to prevent deltaTime spike if (this.soundSystem && this.soundSystem.bgMusicPlaying) {
this.lastTime = performance.now(); this.soundSystem.stopBackgroundMusic();
console.log('Game resumed');
} }
if (this.uiSystem) {
this.uiSystem.setState('paused');
} else {
document.getElementById('pauseMenu').style.display = 'block';
}
console.log('Game paused (tab hidden)');
}
}
// Don't auto-resume - user must click resume button
}); });
// Also handle window blur/focus as fallback // Also handle window blur/focus as fallback
window.addEventListener('blur', () => { window.addEventListener('blur', () => {
if (this.gameActive && !this.inMenu) {
this.isPaused = true; this.isPaused = true;
}); this.timeScale = 0;
window.addEventListener('focus', () => { // Pause background music
if (!document.hidden) { if (this.soundSystem && this.soundSystem.bgMusicPlaying) {
this.isPaused = false; this.soundSystem.stopBackgroundMusic();
this.lastTime = performance.now(); }
if (this.uiSystem) {
this.uiSystem.setState('paused');
} else {
document.getElementById('pauseMenu').style.display = 'block';
}
} }
}); });
// Don't auto-resume on focus - user must click resume
// Load version // Load version
this.loadVersion(); this.loadVersion();
@ -711,10 +601,10 @@ export class Game {
// Initialize sound system on first click anywhere // Initialize sound system on first click anywhere
document.addEventListener('click', () => { document.addEventListener('click', () => {
if (this.soundSystem && this.soundSystem.isEnabled()) { if (this.soundSystem && this.soundSystem.isEnabled() && this.gameActive && !this.inMenu) {
console.log('Sound system ready, state:', this.soundSystem.getState()); console.log('Sound system ready, state:', this.soundSystem.getState());
// Start background music after initialization // Start background music only if game is active and music isn't already playing
if (this.soundSystem.isEnabled()) { if (!this.soundSystem.bgMusicPlaying) {
this.soundSystem.startBackgroundMusic().catch(err => { this.soundSystem.startBackgroundMusic().catch(err => {
console.warn('Failed to start background music:', err); console.warn('Failed to start background music:', err);
}); });
@ -752,9 +642,12 @@ export class Game {
// Start/stop background music based on sound state // Start/stop background music based on sound state
if (enabled) { if (enabled) {
// Only start music if game is active and music isn't already playing
if (this.gameActive && !this.inMenu && !this.soundSystem.bgMusicPlaying) {
this.soundSystem.startBackgroundMusic().catch(err => { this.soundSystem.startBackgroundMusic().catch(err => {
console.warn('Failed to start background music:', err); console.warn('Failed to start background music:', err);
}); });
}
} else { } else {
this.soundSystem.stopBackgroundMusic(); this.soundSystem.stopBackgroundMusic();
} }
@ -823,58 +716,7 @@ export class Game {
}); });
} }
/**
* Update difficulty scaling - spawn more obstacles over time
* @param {number} deltaTime - Time since last frame in seconds
*/
updateDifficulty(deltaTime) {
const currentTime = performance.now() / 1000;
const elapsedTime = currentTime - this.gameStartTime;
// Check if we should add an obstacle based on score
const scoreDiff = this.score - this.lastDifficultyScore;
if (scoreDiff >= GameConfig.DIFFICULTY_SCORE_INTERVAL &&
this.obstacles.length < GameConfig.MAX_OBSTACLES) {
const newObstacle = this.entityFactory.createObstacle();
this.obstacles.push(newObstacle);
this.lastDifficultyScore = this.score;
}
// Check if we should add an obstacle based on time
const timeDiff = currentTime - this.lastDifficultyTime;
if (timeDiff >= GameConfig.DIFFICULTY_TIME_INTERVAL &&
this.obstacles.length < GameConfig.MAX_OBSTACLES) {
const newObstacle = this.entityFactory.createObstacle();
this.obstacles.push(newObstacle);
this.lastDifficultyTime = currentTime;
}
}
/**
* 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() { onWindowResize() {
this.camera.aspect = window.innerWidth / window.innerHeight; this.camera.aspect = window.innerWidth / window.innerHeight;
@ -889,9 +731,11 @@ export class Game {
animate(currentTime = performance.now()) { animate(currentTime = performance.now()) {
requestAnimationFrame((time) => this.animate(time)); requestAnimationFrame((time) => this.animate(time));
// If paused, skip updates but keep rendering // Always render (even when paused/in menu)
if (this.isPaused) {
this.renderer.render(this.scene, this.camera); this.renderer.render(this.scene, this.camera);
// If in menu, skip updates
if (this.inMenu) {
return; return;
} }
@ -899,6 +743,10 @@ export class Game {
const deltaTime = Math.min((currentTime - this.lastTime) / 1000, this.maxDeltaTime); const deltaTime = Math.min((currentTime - this.lastTime) / 1000, this.maxDeltaTime);
this.lastTime = currentTime; this.lastTime = currentTime;
// Apply timeScale (0 when paused, 1 when normal)
this.timeScale = this.isPaused ? 0 : 1.0;
const scaledDeltaTime = deltaTime * this.timeScale;
// Update performance monitor with smoothed values // Update performance monitor with smoothed values
if (this.perfMonitorVisible) { if (this.perfMonitorVisible) {
// Calculate instant FPS from deltaTime // Calculate instant FPS from deltaTime
@ -921,42 +769,23 @@ export class Game {
} }
if (this.gameActive) { if (this.gameActive) {
// Update screen shake // Update ECS world with scaled deltaTime (respects timeScale)
if (this.screenShakeTime > 0) { // All systems (including GameStateSystem, CameraSystem) update here
this.screenShakeTime = Math.max(0, this.screenShakeTime - deltaTime); this.world.update(scaledDeltaTime);
// Check for game over
if (this.playerEntity) {
const gameOver = this.world.getComponent(this.playerEntity, GameOver);
if (gameOver && !gameOver.processed) {
this.gameOver();
gameOver.processed = true;
}
} }
// Update combo timer // Update UI after systems have processed
this.comboTimer = Math.max(0, this.comboTimer - deltaTime);
if (this.comboTimer <= 0 && this.comboMultiplier > 1) {
this.comboMultiplier = 1;
this.updateUI(); 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);
// Emit health regen sound event
const healthRegenEntity = this.world.createEntity();
this.world.addComponent(healthRegenEntity, new SoundEvent('health'));
this.updateUI();
}
this.healthRegenTimer = 0;
}
// Difficulty scaling - add obstacles over time
this.updateDifficulty(deltaTime);
// Update ECS world with actual deltaTime
this.world.update(deltaTime);
// Update camera
this.updateCamera();
}
// Render scene // Render scene
this.renderer.render(this.scene, this.camera); this.renderer.render(this.scene, this.camera);
} }

View file

@ -0,0 +1,285 @@
/**
* BackgroundMusicManager - Manages background music playback
* Separated from SoundSystem to reduce responsibilities
*/
export class BackgroundMusicManager {
constructor(audioContext) {
/** @type {AudioContext|null} Web Audio API context */
this.audioContext = audioContext;
/** @type {boolean} Whether background music is playing */
this.isPlaying = false;
/** @type {OscillatorNode[]} Active background music oscillators */
this.oscillators = [];
/** @type {GainNode|null} Background music gain node */
this.gainNode = null;
/** @type {number} Background music volume (0-1) */
this.volume = 0.25;
/** @type {number} Next scheduled loop time */
this.nextLoopTime = 0;
}
/**
* Set audio context (can be updated if context changes)
* @param {AudioContext} audioContext
*/
setAudioContext(audioContext) {
this.audioContext = audioContext;
}
/**
* Start background music - animated looping OST
*/
async start() {
if (this.isPlaying || !this.audioContext) {
return;
}
// Defensive cleanup
if (this.oscillators.length > 0) {
this.stop();
}
this.isPlaying = true;
try {
// Create gain node for background music volume control
this.gainNode = this.audioContext.createGain();
this.gainNode.gain.value = this.volume * 0.5;
this.gainNode.connect(this.audioContext.destination);
// Electronic/EDM style music
// Simple chord progression: C - Am - F - G (I - vi - IV - V)
const chordProgression = [
[130.81, 164.81, 196], // C major (C, E, G)
[220, 261.63, 329.63], // Am (A, C, E)
[174.61, 220, 261.63], // F major (F, A, C)
[196, 246.94, 293.66] // G major (G, B, D)
];
// Electronic melody notes (higher octave, pentatonic for catchy hook)
const melodyNotes = [523.25, 587.33, 659.25, 698.46, 783.99, 880, 987.77, 1046.5]; // C5 to C6
// Bass line for electronic feel (lower octave)
const bassNotes = [65.41, 73.42, 82.41, 87.31, 98, 110, 123.47]; // C2 to B2
// Initialize loop time
this.nextLoopTime = this.audioContext.currentTime;
// Start first loop
this.scheduleLoop(chordProgression, melodyNotes, bassNotes);
} catch (error) {
console.warn('Failed to start background music:', error);
this.isPlaying = false;
}
}
/**
* Stop background music
*/
stop() {
if (!this.isPlaying && this.oscillators.length === 0) {
return;
}
this.isPlaying = false;
// Stop all oscillators
this.oscillators.forEach(osc => {
try {
osc.stop();
} catch (e) {
// Oscillator might already be stopped
}
});
this.oscillators = [];
if (this.gainNode) {
try {
this.gainNode.disconnect();
} catch (e) {
// Already disconnected
}
this.gainNode = null;
}
}
/**
* Set background music volume
* @param {number} volume - Volume (0-1)
*/
setVolume(volume) {
this.volume = Math.max(0, Math.min(1, volume));
if (this.gainNode) {
this.gainNode.gain.value = this.volume * 0.5;
}
}
/**
* Get current volume
* @returns {number} Current volume (0-1)
*/
getVolume() {
return this.volume;
}
/**
* Check if music is playing
* @returns {boolean}
*/
getIsPlaying() {
return this.isPlaying;
}
/**
* Schedule a music loop
* @private
*/
scheduleLoop(chordProgression, melodyNotes, bassNotes) {
if (!this.isPlaying || !this.audioContext) return;
const startTime = this.nextLoopTime;
const loopDuration = 8; // seconds - fast electronic tempo
// Play chord progression (4 chords, 2 seconds each)
chordProgression.forEach((chord, chordIndex) => {
const chordStart = startTime + (chordIndex * 2);
this.playChord(chord, chordStart, 1.9);
});
// Electronic bass line (pulsing rhythm)
const bassPattern = [
{ note: bassNotes[0], time: 0, duration: 0.3 }, // C - kick
{ note: bassNotes[0], time: 0.5, duration: 0.2 }, // C
{ note: bassNotes[2], time: 1.0, duration: 0.3 }, // E
{ note: bassNotes[0], time: 1.5, duration: 0.2 }, // C
{ note: bassNotes[3], time: 2.0, duration: 0.3 }, // F
{ note: bassNotes[0], time: 2.5, duration: 0.2 }, // C
{ note: bassNotes[4], time: 3.0, duration: 0.3 }, // G
{ note: bassNotes[0], time: 3.5, duration: 0.2 }, // C
{ note: bassNotes[2], time: 4.0, duration: 0.3 }, // E
{ note: bassNotes[0], time: 4.5, duration: 0.2 }, // C
{ note: bassNotes[3], time: 5.0, duration: 0.3 }, // F
{ note: bassNotes[0], time: 5.5, duration: 0.2 }, // C
{ note: bassNotes[4], time: 6.0, duration: 0.3 }, // G
{ note: bassNotes[0], time: 6.5, duration: 0.2 }, // C
{ note: bassNotes[2], time: 7.0, duration: 0.3 } // E
];
bassPattern.forEach(({ note, time, duration }) => {
this.playBass(note, startTime + time, duration);
});
// Play melody over chords
const melodyPattern = [
{ note: melodyNotes[0], time: 0.5, duration: 1 },
{ note: melodyNotes[2], time: 2, duration: 1 },
{ note: melodyNotes[4], time: 4.5, duration: 1 },
{ note: melodyNotes[3], time: 6, duration: 1 },
{ note: melodyNotes[5], time: 8.5, duration: 1.5 },
{ note: melodyNotes[4], time: 10.5, duration: 1 },
{ note: melodyNotes[2], time: 12, duration: 1.5 },
{ note: melodyNotes[0], time: 14, duration: 1.5 }
];
melodyPattern.forEach(({ note, time, duration }) => {
this.playMelody(note, startTime + time, duration);
});
// Schedule next loop
this.nextLoopTime += loopDuration;
// Schedule next loop before current one ends (schedule 2 seconds before end)
const timeUntilNext = (this.nextLoopTime - this.audioContext.currentTime) * 1000 - 2000;
if (timeUntilNext > 0) {
setTimeout(() => {
if (this.isPlaying && this.audioContext) {
this.scheduleLoop(chordProgression, melodyNotes, bassNotes);
}
}, timeUntilNext);
} else {
// If we're behind, schedule immediately
this.scheduleLoop(chordProgression, melodyNotes, bassNotes);
}
}
/**
* Play a chord
* @private
*/
playChord(chord, startTime, duration) {
chord.forEach((freq) => {
const osc = this.audioContext.createOscillator();
const gain = this.audioContext.createGain();
osc.type = 'sawtooth';
osc.frequency.value = freq;
osc.connect(gain);
gain.connect(this.gainNode);
gain.gain.setValueAtTime(0, startTime);
gain.gain.linearRampToValueAtTime(0.5, startTime + 0.1);
gain.gain.setValueAtTime(0.5, startTime + duration - 0.2);
gain.gain.linearRampToValueAtTime(0, startTime + duration);
osc.start(startTime);
osc.stop(startTime + duration);
this.oscillators.push(osc);
});
}
/**
* Play a melody note
* @private
*/
playMelody(note, startTime, duration) {
const osc = this.audioContext.createOscillator();
const gain = this.audioContext.createGain();
osc.type = 'square';
osc.frequency.value = note;
osc.connect(gain);
gain.connect(this.gainNode);
gain.gain.setValueAtTime(0, startTime);
gain.gain.linearRampToValueAtTime(0.4, startTime + 0.05);
gain.gain.setValueAtTime(0.4, startTime + duration - 0.05);
gain.gain.linearRampToValueAtTime(0, startTime + duration);
osc.start(startTime);
osc.stop(startTime + duration);
this.oscillators.push(osc);
}
/**
* Play a bass note
* @private
*/
playBass(note, startTime, duration) {
const osc = this.audioContext.createOscillator();
const gain = this.audioContext.createGain();
osc.type = 'square';
osc.frequency.value = note;
osc.connect(gain);
gain.connect(this.gainNode);
gain.gain.setValueAtTime(0, startTime);
gain.gain.linearRampToValueAtTime(0.4, startTime + 0.02);
gain.gain.setValueAtTime(0.4, startTime + duration - 0.05);
gain.gain.exponentialRampToValueAtTime(0.001, startTime + duration);
osc.start(startTime);
osc.stop(startTime + duration);
this.oscillators.push(osc);
}
}

109
src/systems/CameraSystem.js Normal file
View file

@ -0,0 +1,109 @@
import { System } from '../ecs/System.js';
import { Transform } from '../components/Transform.js';
import { Camera } from '../components/Camera.js';
import { GameConfig } from '../game/GameConfig.js';
/**
* CameraSystem - manages camera position and screen shake
* Operates on Camera component and follows player entity
*/
export class CameraSystem extends System {
constructor(camera) {
super();
/** @type {import('three').PerspectiveCamera} Three.js camera */
this.camera = camera;
/** @type {import('../ecs/World.js').EntityId|null} Reference to player entity */
this.playerEntity = null;
}
/**
* Set player entity reference
* @param {import('../ecs/World.js').EntityId} entityId
*/
setPlayerEntity(entityId) {
this.playerEntity = entityId;
}
/**
* Initialize - create camera entity
*/
init() {
const cameraEntity = this.world.createEntity();
this.world.addComponent(cameraEntity, new Camera());
}
/**
* Update - updates camera position based on player and screen shake
* @param {number} deltaTime
*/
update(deltaTime) {
if (!this.playerEntity) return;
const playerTransform = this.getComponent(this.playerEntity, Transform);
if (!playerTransform) return;
const cameraEntity = this.getCameraEntity();
const cameraComponent = this.getComponent(cameraEntity, Camera);
if (!cameraComponent) return;
// Base camera position
let cameraX = playerTransform.position.x;
let cameraZ = playerTransform.position.z + 15;
let cameraY = 10;
// Apply screen shake
if (cameraComponent.screenShakeTime > 0) {
const intensity = (cameraComponent.screenShakeTime / GameConfig.SCREEN_SHAKE_DURATION) *
GameConfig.SCREEN_SHAKE_INTENSITY;
cameraX += (Math.random() - 0.5) * intensity;
cameraY += (Math.random() - 0.5) * intensity;
cameraZ += (Math.random() - 0.5) * intensity;
// Update shake timer
cameraComponent.screenShakeTime = Math.max(0, cameraComponent.screenShakeTime - deltaTime);
}
this.camera.position.set(cameraX, cameraY, cameraZ);
this.camera.lookAt(playerTransform.position);
}
/**
* Trigger screen shake
*/
triggerShake() {
const cameraEntity = this.getCameraEntity();
const cameraComponent = this.getComponent(cameraEntity, Camera);
if (cameraComponent) {
cameraComponent.screenShakeTime = GameConfig.SCREEN_SHAKE_DURATION;
}
}
/**
* Reset screen shake
*/
reset() {
const cameraEntity = this.getCameraEntity();
const cameraComponent = this.getComponent(cameraEntity, Camera);
if (cameraComponent) {
cameraComponent.screenShakeTime = 0;
}
}
/**
* Get camera entity (singleton)
* @private
*/
getCameraEntity() {
const cameraEntities = this.getEntities(Camera);
if (cameraEntities.length > 0) {
return cameraEntities[0];
}
// Create if doesn't exist
const entity = this.world.createEntity();
this.world.addComponent(entity, new Camera());
return entity;
}
}

View file

@ -0,0 +1,323 @@
import { System } from '../ecs/System.js';
import { CollisionEvent } from '../components/CollisionEvent.js';
import { Transform } from '../components/Transform.js';
import { Health } from '../components/Health.js';
import { CoinType } from '../components/CoinType.js';
import { PowerUp } from '../components/PowerUp.js';
import { Invincibility } from '../components/Invincibility.js';
import { Collidable } from '../components/Collidable.js';
import { SoundEvent } from '../components/SoundEvent.js';
import { Score } from '../components/Score.js';
import { Camera } from '../components/Camera.js';
import { GameOver } from '../components/GameOver.js';
import { GameConfig } from '../game/GameConfig.js';
/**
* CollisionResponseSystem - handles collision responses
* Processes CollisionEvent components added by CollisionSystem
*/
export class CollisionResponseSystem extends System {
constructor(entityFactory, powerUpSystem, particleSystem) {
super();
/** @type {import('../game/EntityFactory.js').EntityFactory} */
this.entityFactory = entityFactory;
/** @type {import('./PowerUpSystem.js').PowerUpSystem} */
this.powerUpSystem = powerUpSystem;
/** @type {import('./ParticleSystem.js').ParticleSystem} */
this.particleSystem = particleSystem;
}
/**
* Update - processes collision events
* @param {number} _deltaTime
*/
update(_deltaTime) {
const collisionEvents = this.getEntities(CollisionEvent);
for (const entityId of collisionEvents) {
const collisionEvent = this.getComponent(entityId, CollisionEvent);
if (!collisionEvent || collisionEvent.processed) continue;
const collidable = this.getComponent(entityId, Collidable);
if (!collidable) continue;
// Process collision based on layers
if (collidable.layer === 'player') {
this.handlePlayerCollision(entityId, collisionEvent.otherEntity, collisionEvent.otherLayer);
}
// Mark as processed and remove
collisionEvent.processed = true;
this.world.removeComponent(entityId, CollisionEvent);
}
}
/**
* Handle player collision with another entity
* @private
*/
handlePlayerCollision(playerEntity, otherEntity, otherLayer) {
switch (otherLayer) {
case 'coin':
this.handleCoinCollision(playerEntity, otherEntity);
break;
case 'powerup':
this.handlePowerUpCollision(playerEntity, otherEntity);
break;
case 'obstacle':
this.handleObstacleCollision(playerEntity, otherEntity);
break;
}
}
/**
* Handle coin collection
* @private
*/
handleCoinCollision(playerEntity, coinEntity) {
const coinTransform = this.getComponent(coinEntity, Transform);
const coinType = this.getComponent(coinEntity, CoinType);
const coinPosition = coinTransform ? coinTransform.position.clone() : null;
// Emit sound event
const soundEntity = this.world.createEntity();
if (coinType && coinType.type === 'health') {
this.world.addComponent(soundEntity, new SoundEvent('health'));
} else {
this.world.addComponent(soundEntity, new SoundEvent('coin'));
}
// Handle health coin
if (coinType && coinType.type === 'health') {
const health = this.getComponent(playerEntity, Health);
if (health) {
health.heal(coinType.healthRestore);
}
this.entityFactory.destroyEntity(coinEntity);
const newCoin = this.entityFactory.createCoin();
return;
}
// Get score component from game state entity
const scoreEntity = this.getScoreEntity();
const score = this.getComponent(scoreEntity, Score);
if (!score) return;
// Update combo
const currentTime = performance.now() / 1000;
const timeSinceLastCoin = currentTime - score.lastCoinTime;
if (timeSinceLastCoin <= GameConfig.COMBO_TIME_WINDOW && score.lastCoinTime > 0) {
score.comboMultiplier = Math.min(score.comboMultiplier + 1, GameConfig.COMBO_MULTIPLIER_MAX);
score.comboTimer = GameConfig.COMBO_TIME_WINDOW;
const comboEntity = this.world.createEntity();
this.world.addComponent(comboEntity, new SoundEvent('combo', score.comboMultiplier));
} else {
score.comboMultiplier = 1;
score.comboTimer = GameConfig.COMBO_TIME_WINDOW;
}
score.lastCoinTime = currentTime;
// Calculate and add score
const baseScore = coinType ? coinType.scoreValue : GameConfig.COMBO_BASE_SCORE;
const powerUpMultiplier = this.powerUpSystem ? this.powerUpSystem.scoreMultiplier : 1.0;
const scoreGain = baseScore * score.comboMultiplier * powerUpMultiplier;
score.score += scoreGain;
// Check for high score
if (score.score > score.highScore) {
score.highScore = score.score;
this.saveHighScore(score.highScore);
const highScoreEntity = this.world.createEntity();
this.world.addComponent(highScoreEntity, new SoundEvent('highscore'));
}
// Emit particles
if (coinPosition && this.particleSystem) {
let particleColor = 0xFFD700;
if (coinType) {
if (coinType.type === 'silver') particleColor = 0xC0C0C0;
else if (coinType.type === 'diamond') particleColor = 0x00FFFF;
else if (coinType.type === 'health') particleColor = 0x00FF00;
}
this.particleSystem.emit(coinPosition, GameConfig.PARTICLE_COUNT_COIN, particleColor, 8);
}
// Remove coin and spawn new one
this.entityFactory.destroyEntity(coinEntity);
if (Math.random() < GameConfig.POWERUP_SPAWN_CHANCE) {
this.entityFactory.createPowerUp();
} else {
this.entityFactory.createCoin();
}
}
/**
* Handle power-up collection
* @private
*/
handlePowerUpCollision(playerEntity, powerUpEntity) {
const powerUpTransform = this.getComponent(powerUpEntity, Transform);
const powerUp = this.getComponent(powerUpEntity, PowerUp);
const powerUpPosition = powerUpTransform ? powerUpTransform.position.clone() : null;
// Activate power-up
if (powerUp && this.powerUpSystem) {
this.powerUpSystem.activatePowerUp(powerUp.type, powerUp.duration);
if (powerUp.type === 'shield') {
const invincibility = this.getComponent(playerEntity, Invincibility);
if (invincibility) {
invincibility.activate(powerUp.duration);
}
}
const soundEntity = this.world.createEntity();
this.world.addComponent(soundEntity, new SoundEvent('powerup'));
}
// Emit particles
if (powerUpPosition && this.particleSystem) {
let particleColor = 0x00FF00;
if (powerUp) {
switch (powerUp.type) {
case 'speed': particleColor = 0x00FFFF; break;
case 'shield': particleColor = 0x0000FF; break;
case 'multiplier': particleColor = 0xFF00FF; break;
case 'magnet': particleColor = 0xFFFF00; break;
}
}
this.particleSystem.emit(powerUpPosition, GameConfig.PARTICLE_COUNT_COIN, particleColor, 10);
}
// Remove power-up and spawn new one
this.entityFactory.destroyEntity(powerUpEntity);
if (Math.random() < GameConfig.POWERUP_SPAWN_CHANCE) {
this.entityFactory.createPowerUp();
} else {
this.entityFactory.createCoin();
}
}
/**
* Handle obstacle collision
* @private
*/
handleObstacleCollision(playerEntity, obstacleEntity) {
// Check shield
if (this.powerUpSystem && this.powerUpSystem.isActive('shield')) {
return;
}
// Check invincibility
const invincibility = this.getComponent(playerEntity, Invincibility);
if (invincibility && invincibility.getIsInvincible()) {
return;
}
const health = this.getComponent(playerEntity, Health);
const playerTransform = this.getComponent(playerEntity, Transform);
const obstacleTransform = this.getComponent(obstacleEntity, Transform);
if (!health || !playerTransform || !obstacleTransform) return;
// Damage player
const isDead = health.damage(GameConfig.OBSTACLE_DAMAGE);
// Activate invincibility
if (invincibility) {
invincibility.activate(GameConfig.INVINCIBILITY_DURATION);
}
// Trigger screen shake
const cameraEntity = this.getCameraEntity();
const camera = this.getComponent(cameraEntity, Camera);
if (camera) {
camera.screenShakeTime = GameConfig.SCREEN_SHAKE_DURATION;
}
// Reset combo
const scoreEntity = this.getScoreEntity();
const score = this.getComponent(scoreEntity, Score);
if (score) {
score.comboMultiplier = 1;
score.comboTimer = 0;
}
// Emit particles
if (this.particleSystem) {
this.particleSystem.emit(
playerTransform.position.clone().add(new window.THREE.Vector3(0, 0.5, 0)),
GameConfig.PARTICLE_COUNT_DAMAGE,
0xFF0000,
6
);
}
// 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;
// Emit damage sound
const soundEntity = this.world.createEntity();
this.world.addComponent(soundEntity, new SoundEvent('damage'));
// Check if dead - add GameOver component
if (isDead) {
const gameOverEntity = this.world.createEntity();
this.world.addComponent(gameOverEntity, new SoundEvent('gameover'));
// Add GameOver component to player so Game can detect it
this.world.addComponent(playerEntity, new GameOver());
}
}
/**
* Get or create score entity (singleton)
* @private
*/
getScoreEntity() {
const scoreEntities = this.getEntities(Score);
if (scoreEntities.length > 0) {
return scoreEntities[0];
}
// Create if doesn't exist
const entity = this.world.createEntity();
this.world.addComponent(entity, new Score());
return entity;
}
/**
* Get or create camera entity (singleton)
* @private
*/
getCameraEntity() {
const cameraEntities = this.getEntities(Camera);
if (cameraEntities.length > 0) {
return cameraEntities[0];
}
// Create if doesn't exist
const entity = this.world.createEntity();
this.world.addComponent(entity, new Camera());
return entity;
}
/**
* Save high score to localStorage
* @private
*/
saveHighScore(score) {
try {
localStorage.setItem(GameConfig.STORAGE_HIGH_SCORE, score.toString());
} catch (error) {
console.debug('Failed to save high score:', error);
}
}
}

View file

@ -1,34 +1,21 @@
import { System } from '../ecs/System.js'; import { System } from '../ecs/System.js';
import { Transform } from '../components/Transform.js'; import { Transform } from '../components/Transform.js';
import { Collidable } from '../components/Collidable.js'; import { Collidable } from '../components/Collidable.js';
import { CollisionEvent } from '../components/CollisionEvent.js';
/** /**
* CollisionSystem - detects and reports collisions * CollisionSystem - detects collisions and adds CollisionEvent components
* CollisionResponseSystem will process these events
*/ */
export class CollisionSystem extends System { export class CollisionSystem extends System {
constructor() {
super();
this.collisionCallbacks = [];
}
/**
* Register a callback for collision events
* @param {Function} callback - (entity1Id, entity2Id, layer1, layer2) => void
*/
onCollision(callback) {
this.collisionCallbacks.push(callback);
}
/** /**
* Update collision detection * Update collision detection
* Note: Entity list is captured at the start of update, but entities may be
* destroyed during collision callbacks, so we need defensive null checks.
* @param {number} _deltaTime - Time since last frame (unused - collision is instantaneous) * @param {number} _deltaTime - Time since last frame (unused - collision is instantaneous)
*/ */
update(_deltaTime) { update(_deltaTime) {
const entities = this.getEntities(Transform, Collidable); const entities = this.getEntities(Transform, Collidable);
// Track checked pairs to avoid duplicate collision callbacks this frame // Track checked pairs to avoid duplicate collisions this frame
const checkedPairs = new Set(); const checkedPairs = new Set();
// Check all pairs of collidable entities // Check all pairs of collidable entities
@ -47,7 +34,7 @@ export class CollisionSystem extends System {
const collidable1 = this.getComponent(entity1, Collidable); const collidable1 = this.getComponent(entity1, Collidable);
const collidable2 = this.getComponent(entity2, Collidable); const collidable2 = this.getComponent(entity2, Collidable);
// Skip if entity was destroyed during collision handling this frame // Skip if entity was destroyed
if (!transform1 || !transform2 || !collidable1 || !collidable2) { if (!transform1 || !transform2 || !collidable1 || !collidable2) {
continue; continue;
} }
@ -55,22 +42,25 @@ export class CollisionSystem extends System {
// Calculate distance between entities // Calculate distance between entities
const distance = transform1.position.distanceTo(transform2.position); const distance = transform1.position.distanceTo(transform2.position);
// Determine which radius to use (use non-player radius, or sum if both non-player) // Determine which radius to use
let collisionRadius; let collisionRadius;
if (collidable1.layer === 'player') { if (collidable1.layer === 'player') {
collisionRadius = collidable2.radius; // Use other entity's radius collisionRadius = collidable2.radius;
} else if (collidable2.layer === 'player') { } else if (collidable2.layer === 'player') {
collisionRadius = collidable1.radius; // Use other entity's radius collisionRadius = collidable1.radius;
} else { } else {
// Both are non-player, use sum of radii
collisionRadius = collidable1.radius + collidable2.radius; collisionRadius = collidable1.radius + collidable2.radius;
} }
// Check if colliding // Check if colliding
if (distance < collisionRadius) { if (distance < collisionRadius) {
// Notify all collision callbacks // Add CollisionEvent components to both entities
for (const callback of this.collisionCallbacks) { // Only add if not already present (avoid duplicates)
callback(entity1, entity2, collidable1.layer, collidable2.layer); if (!this.getComponent(entity1, CollisionEvent)) {
this.world.addComponent(entity1, new CollisionEvent(entity2, collidable2.layer));
}
if (!this.getComponent(entity2, CollisionEvent)) {
this.world.addComponent(entity2, new CollisionEvent(entity1, collidable1.layer));
} }
} }
} }

View file

@ -0,0 +1,151 @@
import { System } from '../ecs/System.js';
import { Score } from '../components/Score.js';
import { Health } from '../components/Health.js';
import { SoundEvent } from '../components/SoundEvent.js';
import { Collidable } from '../components/Collidable.js';
import { GameConfig } from '../game/GameConfig.js';
/**
* GameStateSystem - manages game state (score, combo, health regen, difficulty)
* Operates on a singleton Score component
*/
export class GameStateSystem extends System {
constructor(entityFactory) {
super();
/** @type {import('../game/EntityFactory.js').EntityFactory} */
this.entityFactory = entityFactory;
/** @type {import('../ecs/World.js').EntityId|null} Reference to player entity */
this.playerEntity = null;
/** @type {number} Last time obstacle was added */
this.lastDifficultyTime = 0;
}
/**
* Set player entity reference
* @param {import('../ecs/World.js').EntityId} entityId
*/
setPlayerEntity(entityId) {
this.playerEntity = entityId;
}
/**
* Initialize - create score entity and load high score
*/
init() {
const scoreEntity = this.world.createEntity();
const score = new Score();
score.highScore = this.loadHighScore();
this.world.addComponent(scoreEntity, score);
}
/**
* Update - manages combo timer, health regen, difficulty scaling
* @param {number} deltaTime
*/
update(deltaTime) {
const scoreEntity = this.getScoreEntity();
const score = this.getComponent(scoreEntity, Score);
if (!score) return;
// Update combo timer
score.comboTimer = Math.max(0, score.comboTimer - deltaTime);
if (score.comboTimer <= 0 && score.comboMultiplier > 1) {
score.comboMultiplier = 1;
}
// Update health regeneration
// Health regen is handled here but could be a separate component
// For simplicity, we'll check player health directly
if (this.playerEntity) {
const health = this.getComponent(this.playerEntity, Health);
if (health && health.currentHealth < health.maxHealth) {
// Simple approach: heal every interval
// In a more complex system, this would be a HealthRegen component
const currentTime = performance.now() / 1000;
if (!this.lastHealthRegenTime) {
this.lastHealthRegenTime = currentTime;
}
if (currentTime - this.lastHealthRegenTime >= GameConfig.HEALTH_REGEN_INTERVAL) {
health.heal(GameConfig.HEALTH_REGEN_AMOUNT);
const healthRegenEntity = this.world.createEntity();
this.world.addComponent(healthRegenEntity, new SoundEvent('health'));
this.lastHealthRegenTime = currentTime;
}
}
}
// Update difficulty scaling
const currentTime = performance.now() / 1000;
// Check score-based difficulty
const obstacles = this.getEntities(Collidable).filter(id => {
const collidable = this.getComponent(id, Collidable);
return collidable && collidable.layer === 'obstacle';
});
if (score.score - (score.lastDifficultyScore || 0) >= GameConfig.DIFFICULTY_SCORE_INTERVAL &&
obstacles.length < GameConfig.MAX_OBSTACLES) {
this.entityFactory.createObstacle();
score.lastDifficultyScore = score.score;
}
// Check time-based difficulty
if (currentTime - this.lastDifficultyTime >= GameConfig.DIFFICULTY_TIME_INTERVAL) {
if (obstacles.length < GameConfig.MAX_OBSTACLES) {
this.entityFactory.createObstacle();
this.lastDifficultyTime = currentTime;
}
}
}
/**
* Reset game state
*/
reset() {
const scoreEntity = this.getScoreEntity();
const score = this.getComponent(scoreEntity, Score);
if (score) {
score.score = 0;
score.comboMultiplier = 1;
score.comboTimer = 0;
score.lastCoinTime = 0;
score.lastDifficultyScore = 0;
}
this.lastDifficultyTime = 0;
this.lastHealthRegenTime = 0;
}
/**
* Get score entity (singleton)
* @private
*/
getScoreEntity() {
const scoreEntities = this.getEntities(Score);
if (scoreEntities.length > 0) {
return scoreEntities[0];
}
// Create if doesn't exist
const entity = this.world.createEntity();
this.world.addComponent(entity, new Score());
return entity;
}
/**
* Load high score from localStorage
* @private
*/
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;
}
}
}

View file

@ -1,5 +1,6 @@
import { System } from '../ecs/System.js'; import { System } from '../ecs/System.js';
import { SoundEvent } from '../components/SoundEvent.js'; import { SoundEvent } from '../components/SoundEvent.js';
import { BackgroundMusicManager } from './BackgroundMusicManager.js';
/** /**
* SoundSystem - Lightweight sound system using Web Audio API (no external dependencies) * SoundSystem - Lightweight sound system using Web Audio API (no external dependencies)
@ -19,17 +20,8 @@ export class SoundSystem extends System {
/** @type {boolean} Whether audio context is initialized */ /** @type {boolean} Whether audio context is initialized */
this.initialized = false; this.initialized = false;
/** @type {OscillatorNode[]} Active background music oscillators */ /** @type {BackgroundMusicManager|null} Background music manager */
this.bgMusicOscillators = []; this.bgMusicManager = null;
/** @type {GainNode|null} Background music gain node */
this.bgMusicGain = null;
/** @type {boolean} Whether background music is playing */
this.bgMusicPlaying = false;
/** @type {number} Background music volume (0-1) */
this.bgMusicVolume = 0.25; // Increased from 0.15 for better audibility
// Initialize on first user interaction (browser requirement) // Initialize on first user interaction (browser requirement)
this.initOnInteraction(); this.initOnInteraction();
@ -39,11 +31,6 @@ export class SoundSystem extends System {
* Called when system is added to world * Called when system is added to world
*/ */
init() { init() {
if (this.enabled && !this.bgMusicPlaying) {
this.startBackgroundMusic().catch(err => {
console.warn('Failed to auto-start background music:', err);
});
}
} }
/** /**
@ -156,6 +143,14 @@ export class SoundSystem extends System {
} }
} }
// Initialize background music manager if it doesn't exist
if (!this.bgMusicManager && this.audioContext) {
this.bgMusicManager = new BackgroundMusicManager(this.audioContext);
} else if (this.bgMusicManager && this.audioContext) {
// Update background music manager if context changed
this.bgMusicManager.setAudioContext(this.audioContext);
}
// Try to resume if suspended (non-blocking - will resume on next interaction if needed) // Try to resume if suspended (non-blocking - will resume on next interaction if needed)
if (this.audioContext.state === 'suspended') { if (this.audioContext.state === 'suspended') {
this.audioContext.resume().then(() => { this.audioContext.resume().then(() => {
@ -358,227 +353,50 @@ export class SoundSystem extends System {
} }
/** /**
* Start background music - animated looping OST * Start background music - delegates to BackgroundMusicManager
*/ */
async startBackgroundMusic() { async startBackgroundMusic() {
if (this.bgMusicPlaying) {
console.log('Background music already playing');
return;
}
const ready = this.ensureInitialized(); const ready = this.ensureInitialized();
if (!ready || !this.audioContext) { if (!ready || !this.audioContext || !this.bgMusicManager) {
console.warn('Cannot start background music: audio context not ready, state:', this.getState()); console.warn('Cannot start background music: audio context not ready, state:', this.getState());
return; return;
} }
try { try {
this.bgMusicPlaying = true; await this.bgMusicManager.start();
console.log('Starting background music, audio context state:', this.audioContext.state);
// Create gain node for background music volume control
this.bgMusicGain = this.audioContext.createGain();
this.bgMusicGain.gain.value = this.bgMusicVolume * 0.5;
this.bgMusicGain.connect(this.audioContext.destination);
// Electronic/EDM style music
// Simple chord progression: C - Am - F - G (I - vi - IV - V - classic electronic progression)
const chordProgression = [
[130.81, 164.81, 196], // C major (C, E, G)
[220, 261.63, 329.63], // Am (A, C, E)
[174.61, 220, 261.63], // F major (F, A, C)
[196, 246.94, 293.66] // G major (G, B, D)
];
// Electronic melody notes (higher octave, pentatonic for catchy hook)
const melodyNotes = [523.25, 587.33, 659.25, 698.46, 783.99, 880, 987.77, 1046.5]; // C5 to C6 - bright electronic range
// Bass line for electronic feel (lower octave)
const bassNotes = [65.41, 73.42, 82.41, 87.31, 98, 110, 123.47]; // C2 to B2
const playChord = (chord, startTime, duration) => {
chord.forEach((freq, index) => {
const osc = this.audioContext.createOscillator();
const gain = this.audioContext.createGain();
// Use sawtooth for electronic/analog synth feel
osc.type = 'sawtooth';
osc.frequency.value = freq;
osc.connect(gain);
gain.connect(this.bgMusicGain);
// Very bright, energetic attack for happy feel
gain.gain.setValueAtTime(0, startTime);
gain.gain.linearRampToValueAtTime(0.5, startTime + 0.1); // Very fast, bright attack
gain.gain.setValueAtTime(0.5, startTime + duration - 0.2);
gain.gain.linearRampToValueAtTime(0, startTime + duration);
osc.start(startTime);
osc.stop(startTime + duration);
this.bgMusicOscillators.push(osc);
});
};
const playMelody = (note, startTime, duration) => {
const osc = this.audioContext.createOscillator();
const gain = this.audioContext.createGain();
osc.type = 'square'; // Square wave for electronic lead
osc.frequency.value = note;
osc.connect(gain);
gain.connect(this.bgMusicGain);
// Very energetic, bouncy attack for happy melody
gain.gain.setValueAtTime(0, startTime);
gain.gain.linearRampToValueAtTime(0.4, startTime + 0.05); // Very quick, bright attack
gain.gain.setValueAtTime(0.4, startTime + duration - 0.05);
gain.gain.linearRampToValueAtTime(0, startTime + duration);
osc.start(startTime);
osc.stop(startTime + duration);
this.bgMusicOscillators.push(osc);
};
// Electronic bass line
const playBass = (note, startTime, duration) => {
const osc = this.audioContext.createOscillator();
const gain = this.audioContext.createGain();
osc.type = 'square'; // Square wave for electronic bass
osc.frequency.value = note;
osc.connect(gain);
gain.connect(this.bgMusicGain);
// Punchy bass envelope
gain.gain.setValueAtTime(0, startTime);
gain.gain.linearRampToValueAtTime(0.4, startTime + 0.02);
gain.gain.setValueAtTime(0.4, startTime + duration - 0.05);
gain.gain.exponentialRampToValueAtTime(0.001, startTime + duration);
osc.start(startTime);
osc.stop(startTime + duration);
this.bgMusicOscillators.push(osc);
};
// Loop function - creates 8 second loop (fast electronic tempo)
const loopDuration = 8; // seconds - fast electronic tempo
let nextLoopTime = this.audioContext.currentTime;
const scheduleLoop = () => {
if (!this.bgMusicPlaying || !this.audioContext) return;
const startTime = nextLoopTime;
// Play chord progression (4 chords, 2 seconds each - fast electronic tempo)
chordProgression.forEach((chord, chordIndex) => {
const chordStart = startTime + (chordIndex * 2);
playChord(chord, chordStart, 1.9);
});
// Electronic bass line (pulsing rhythm)
const bassPattern = [
{ note: bassNotes[0], time: 0, duration: 0.3 }, // C - kick
{ note: bassNotes[0], time: 0.5, duration: 0.2 }, // C
{ note: bassNotes[2], time: 1.0, duration: 0.3 }, // E
{ note: bassNotes[0], time: 1.5, duration: 0.2 }, // C
{ note: bassNotes[3], time: 2.0, duration: 0.3 }, // F
{ note: bassNotes[0], time: 2.5, duration: 0.2 }, // C
{ note: bassNotes[4], time: 3.0, duration: 0.3 }, // G
{ note: bassNotes[0], time: 3.5, duration: 0.2 }, // C
{ note: bassNotes[2], time: 4.0, duration: 0.3 }, // E
{ note: bassNotes[0], time: 4.5, duration: 0.2 }, // C
{ note: bassNotes[3], time: 5.0, duration: 0.3 }, // F
{ note: bassNotes[0], time: 5.5, duration: 0.2 }, // C
{ note: bassNotes[4], time: 6.0, duration: 0.3 }, // G
{ note: bassNotes[0], time: 6.5, duration: 0.2 }, // C
{ note: bassNotes[2], time: 7.0, duration: 0.3 } // E
];
bassPattern.forEach(({ note, time, duration }) => {
playBass(note, startTime + time, duration);
});
// Play melody over chords (simpler pattern)
const melodyPattern = [
{ note: melodyNotes[0], time: 0.5, duration: 1 },
{ note: melodyNotes[2], time: 2, duration: 1 },
{ note: melodyNotes[4], time: 4.5, duration: 1 },
{ note: melodyNotes[3], time: 6, duration: 1 },
{ note: melodyNotes[5], time: 8.5, duration: 1.5 },
{ note: melodyNotes[4], time: 10.5, duration: 1 },
{ note: melodyNotes[2], time: 12, duration: 1.5 },
{ note: melodyNotes[0], time: 14, duration: 1.5 }
];
melodyPattern.forEach(({ note, time, duration }) => {
playMelody(note, startTime + time, duration);
});
// Schedule next loop
nextLoopTime += loopDuration;
// Schedule next loop before current one ends (schedule 2 seconds before end)
const timeUntilNext = (nextLoopTime - this.audioContext.currentTime) * 1000 - 2000;
if (timeUntilNext > 0) {
setTimeout(() => {
if (this.bgMusicPlaying && this.audioContext) {
scheduleLoop();
}
}, timeUntilNext);
} else {
// If we're behind, schedule immediately
scheduleLoop();
}
};
// Start first loop immediately
scheduleLoop();
console.log('Background music started, audio context state:', this.audioContext.state); console.log('Background music started, audio context state:', this.audioContext.state);
console.log('Background music volume:', this.bgMusicVolume);
} catch (error) { } catch (error) {
console.warn('Failed to start background music:', error); console.warn('Failed to start background music:', error);
this.bgMusicPlaying = false;
} }
} }
/** /**
* Stop background music * Stop background music - delegates to BackgroundMusicManager
*/ */
stopBackgroundMusic() { stopBackgroundMusic() {
this.bgMusicPlaying = false; if (this.bgMusicManager) {
this.bgMusicManager.stop();
// Stop all oscillators
this.bgMusicOscillators.forEach(osc => {
try {
osc.stop();
} catch (e) {
// Oscillator might already be stopped
}
});
this.bgMusicOscillators = [];
if (this.bgMusicGain) {
this.bgMusicGain.disconnect();
this.bgMusicGain = null;
}
console.log('Background music stopped'); console.log('Background music stopped');
} }
}
/** /**
* Set background music volume * Set background music volume - delegates to BackgroundMusicManager
* @param {number} volume - Volume (0-1) * @param {number} volume - Volume (0-1)
*/ */
setBackgroundMusicVolume(volume) { setBackgroundMusicVolume(volume) {
this.bgMusicVolume = Math.max(0, Math.min(1, volume)); if (this.bgMusicManager) {
if (this.bgMusicGain) { this.bgMusicManager.setVolume(volume);
this.bgMusicGain.gain.value = this.bgMusicVolume;
}
} }
} }
/**
* Get background music playing state
* @returns {boolean}
*/
get bgMusicPlaying() {
return this.bgMusicManager ? this.bgMusicManager.getIsPlaying() : false;
}
}

96
src/systems/UISystem.js Normal file
View file

@ -0,0 +1,96 @@
import { System } from '../ecs/System.js';
/**
* UISystem - Manages UI state and menu visibility
* Handles menu transitions, UI updates, and state management
*/
export class UISystem extends System {
constructor() {
super();
/** @type {string} Current UI state: 'menu', 'playing', 'paused', 'gameover' */
this.state = 'menu';
/** @type {HTMLElement|null} Start menu element */
this.startMenuEl = null;
/** @type {HTMLElement|null} Pause menu element */
this.pauseMenuEl = null;
/** @type {HTMLElement|null} Game over element */
this.gameOverEl = null;
/** @type {HTMLElement|null} Game UI element */
this.gameUIEl = null;
/** @type {HTMLElement|null} Instructions element */
this.instructionsEl = null;
}
/**
* Called when system is added to world
*/
init() {
// Cache UI elements
this.startMenuEl = document.getElementById('startMenu');
this.pauseMenuEl = document.getElementById('pauseMenu');
this.gameOverEl = document.getElementById('gameOver');
this.gameUIEl = document.getElementById('ui');
this.instructionsEl = document.getElementById('instructions');
// Set initial state
this.setState('menu');
}
/**
* Set UI state and update visibility
* @param {string} newState - New state: 'menu', 'playing', 'paused', 'gameover'
*/
setState(newState) {
this.state = newState;
// Hide all menus first
if (this.startMenuEl) this.startMenuEl.style.display = 'none';
if (this.pauseMenuEl) this.pauseMenuEl.style.display = 'none';
if (this.gameOverEl) this.gameOverEl.style.display = 'none';
if (this.gameUIEl) this.gameUIEl.style.display = 'none';
if (this.instructionsEl) this.instructionsEl.style.display = 'none';
// Show appropriate UI based on state
switch (newState) {
case 'menu':
if (this.startMenuEl) this.startMenuEl.style.display = 'block';
break;
case 'playing':
if (this.gameUIEl) this.gameUIEl.style.display = 'block';
if (this.instructionsEl) this.instructionsEl.style.display = 'block';
break;
case 'paused':
if (this.pauseMenuEl) this.pauseMenuEl.style.display = 'block';
if (this.gameUIEl) this.gameUIEl.style.display = 'block';
break;
case 'gameover':
if (this.gameOverEl) this.gameOverEl.style.display = 'block';
if (this.gameUIEl) this.gameUIEl.style.display = 'block';
break;
}
}
/**
* Get current UI state
* @returns {string} Current state
*/
getState() {
return this.state;
}
/**
* Update - called every frame (not used for UI system, but required by System interface)
* @param {number} _deltaTime - Time since last frame (not used)
*/
update(_deltaTime) {
// UI system doesn't need per-frame updates
// State changes are handled via setState() method
}
}