Release/First full release version (#18)

- 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.

Reviewed-on: #18
Co-authored-by: Juan Sebastian Montoya <juansmm@outlook.com>
Co-committed-by: Juan Sebastian Montoya <juansmm@outlook.com>
This commit is contained in:
Juan Sebastián Montoya 2025-11-26 18:47:41 -05:00 committed by Juan Sebastián Montoya
parent a95a079d0b
commit c7b179e8d4
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();

File diff suppressed because it is too large Load diff

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 console.log('Background music stopped');
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');
} }
/** /**
* 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
}
}