Release/First full release version #18
14 changed files with 1467 additions and 751 deletions
64
index.html
64
index.html
|
|
@ -41,29 +41,43 @@
|
|||
0%, 100% { transform: translateX(-50%) scale(1); }
|
||||
50% { transform: translateX(-50%) scale(1.2); }
|
||||
}
|
||||
#gameOver {
|
||||
#startMenu, #pauseMenu, #gameOver {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
background: rgba(0,0,0,0.8);
|
||||
padding: 40px;
|
||||
background: rgba(0,0,0,0.9);
|
||||
padding: 50px;
|
||||
border-radius: 20px;
|
||||
text-align: center;
|
||||
color: white;
|
||||
display: none;
|
||||
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;
|
||||
margin-bottom: 20px;
|
||||
color: #4CAF50;
|
||||
}
|
||||
#pauseMenu h1 {
|
||||
color: #FFA500;
|
||||
}
|
||||
#gameOver h1 {
|
||||
color: #ff6b6b;
|
||||
}
|
||||
#gameOver p {
|
||||
font-size: 24px;
|
||||
#startMenu p, #pauseMenu p, #gameOver p {
|
||||
font-size: 20px;
|
||||
margin-bottom: 30px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
#restartBtn {
|
||||
#startMenu button, #pauseMenu button, #restartBtn {
|
||||
background: #4CAF50;
|
||||
border: none;
|
||||
color: white;
|
||||
|
|
@ -72,11 +86,18 @@
|
|||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
margin: 10px;
|
||||
}
|
||||
#restartBtn:hover {
|
||||
#startMenu button:hover, #pauseMenu button:hover, #restartBtn:hover {
|
||||
background: #45a049;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
#pauseMenu button.resumeBtn {
|
||||
background: #2196F3;
|
||||
}
|
||||
#pauseMenu button.resumeBtn:hover {
|
||||
background: #1976D2;
|
||||
}
|
||||
#instructions {
|
||||
position: absolute;
|
||||
bottom: 20px;
|
||||
|
|
@ -153,7 +174,7 @@
|
|||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="ui">
|
||||
<div id="ui" style="display: none;">
|
||||
<div>Score: <span id="score">0</span></div>
|
||||
<div>High Score: <span id="highScore">0</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>
|
||||
|
||||
<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">
|
||||
<h1>Game Over!</h1>
|
||||
<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>
|
||||
</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 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>
|
||||
|
||||
<script type="module">
|
||||
|
|
|
|||
11
src/components/Camera.js
Normal file
11
src/components/Camera.js
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
21
src/components/CollisionEvent.js
Normal file
21
src/components/CollisionEvent.js
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
11
src/components/GameOver.js
Normal file
11
src/components/GameOver.js
Normal 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
23
src/components/Score.js
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -154,9 +154,18 @@ export class World {
|
|||
}
|
||||
|
||||
/**
|
||||
* Cleanup all systems and clear all entities/components
|
||||
* Cleanup all entities/components but keep systems
|
||||
*/
|
||||
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) {
|
||||
if (system.cleanup) {
|
||||
system.cleanup();
|
||||
|
|
|
|||
827
src/game/Game.js
827
src/game/Game.js
File diff suppressed because it is too large
Load diff
285
src/systems/BackgroundMusicManager.js
Normal file
285
src/systems/BackgroundMusicManager.js
Normal 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
109
src/systems/CameraSystem.js
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
323
src/systems/CollisionResponseSystem.js
Normal file
323
src/systems/CollisionResponseSystem.js
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1,34 +1,21 @@
|
|||
import { System } from '../ecs/System.js';
|
||||
import { Transform } from '../components/Transform.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 {
|
||||
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
|
||||
* 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)
|
||||
*/
|
||||
update(_deltaTime) {
|
||||
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();
|
||||
|
||||
// Check all pairs of collidable entities
|
||||
|
|
@ -47,7 +34,7 @@ export class CollisionSystem extends System {
|
|||
const collidable1 = this.getComponent(entity1, 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) {
|
||||
continue;
|
||||
}
|
||||
|
|
@ -55,22 +42,25 @@ export class CollisionSystem extends System {
|
|||
// Calculate distance between entities
|
||||
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;
|
||||
if (collidable1.layer === 'player') {
|
||||
collisionRadius = collidable2.radius; // Use other entity's radius
|
||||
collisionRadius = collidable2.radius;
|
||||
} else if (collidable2.layer === 'player') {
|
||||
collisionRadius = collidable1.radius; // Use other entity's radius
|
||||
collisionRadius = collidable1.radius;
|
||||
} else {
|
||||
// Both are non-player, use sum of radii
|
||||
collisionRadius = collidable1.radius + collidable2.radius;
|
||||
}
|
||||
|
||||
// Check if colliding
|
||||
if (distance < collisionRadius) {
|
||||
// Notify all collision callbacks
|
||||
for (const callback of this.collisionCallbacks) {
|
||||
callback(entity1, entity2, collidable1.layer, collidable2.layer);
|
||||
// Add CollisionEvent components to both entities
|
||||
// Only add if not already present (avoid duplicates)
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
151
src/systems/GameStateSystem.js
Normal file
151
src/systems/GameStateSystem.js
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import { System } from '../ecs/System.js';
|
||||
import { SoundEvent } from '../components/SoundEvent.js';
|
||||
import { BackgroundMusicManager } from './BackgroundMusicManager.js';
|
||||
|
||||
/**
|
||||
* 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 */
|
||||
this.initialized = false;
|
||||
|
||||
/** @type {OscillatorNode[]} Active background music oscillators */
|
||||
this.bgMusicOscillators = [];
|
||||
|
||||
/** @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
|
||||
/** @type {BackgroundMusicManager|null} Background music manager */
|
||||
this.bgMusicManager = null;
|
||||
|
||||
// Initialize on first user interaction (browser requirement)
|
||||
this.initOnInteraction();
|
||||
|
|
@ -39,11 +31,6 @@ export class SoundSystem extends System {
|
|||
* Called when system is added to world
|
||||
*/
|
||||
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)
|
||||
if (this.audioContext.state === 'suspended') {
|
||||
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() {
|
||||
if (this.bgMusicPlaying) {
|
||||
console.log('Background music already playing');
|
||||
return;
|
||||
}
|
||||
|
||||
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());
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this.bgMusicPlaying = true;
|
||||
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();
|
||||
|
||||
await this.bgMusicManager.start();
|
||||
console.log('Background music started, audio context state:', this.audioContext.state);
|
||||
console.log('Background music volume:', this.bgMusicVolume);
|
||||
} catch (error) {
|
||||
console.warn('Failed to start background music:', error);
|
||||
this.bgMusicPlaying = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop background music
|
||||
* Stop background music - delegates to BackgroundMusicManager
|
||||
*/
|
||||
stopBackgroundMusic() {
|
||||
this.bgMusicPlaying = false;
|
||||
|
||||
// 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;
|
||||
if (this.bgMusicManager) {
|
||||
this.bgMusicManager.stop();
|
||||
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)
|
||||
*/
|
||||
setBackgroundMusicVolume(volume) {
|
||||
this.bgMusicVolume = Math.max(0, Math.min(1, volume));
|
||||
if (this.bgMusicGain) {
|
||||
this.bgMusicGain.gain.value = this.bgMusicVolume;
|
||||
if (this.bgMusicManager) {
|
||||
this.bgMusicManager.setVolume(volume);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get background music playing state
|
||||
* @returns {boolean}
|
||||
*/
|
||||
get bgMusicPlaying() {
|
||||
return this.bgMusicManager ? this.bgMusicManager.getIsPlaying() : false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
96
src/systems/UISystem.js
Normal file
96
src/systems/UISystem.js
Normal 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
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Add table
Add a link
Reference in a new issue