diff --git a/src/components/Music.ts b/src/components/Music.ts index 332aeab..35b9621 100644 --- a/src/components/Music.ts +++ b/src/components/Music.ts @@ -8,15 +8,23 @@ import type { Sequence } from '../core/Music.ts'; export class Music extends Component { sequences: Map; currentSequence: Sequence | null; + activeSequences: Set; volume: number; enabled: boolean; + private sequenceChain: string[]; + private currentChainIndex: number; + private sequenceVolumes: Map; constructor() { super(ComponentType.MUSIC); this.sequences = new Map(); this.currentSequence = null; + this.activeSequences = new Set(); this.volume = 0.5; this.enabled = true; + this.sequenceChain = []; + this.currentChainIndex = 0; + this.sequenceVolumes = new Map(); } /** @@ -49,10 +57,91 @@ export class Music extends Component { } } + /** + * Play multiple sequences simultaneously (polyphony). + * @param sequenceConfigs - Array of configs with name, optional delay in beats, and optional loop + */ + playSequences(sequenceConfigs: Array<{ name: string; delay?: number; loop?: boolean }>): void { + if (!this.enabled || sequenceConfigs.length === 0) return; + + const firstSeq = this.sequences.get(sequenceConfigs[0].name); + if (!firstSeq || !firstSeq.ac) return; + + const ac = firstSeq.ac; + const when = ac.currentTime; + const tempo = firstSeq.tempo || 120; + + sequenceConfigs.forEach((config) => { + const sequence = this.sequences.get(config.name); + if (!sequence) return; + + if (config.loop !== undefined) { + sequence.loop = config.loop; + } + if (sequence.gain) { + sequence.gain.gain.value = this.volume; + } + + const delaySeconds = config.delay ? (60 / tempo) * config.delay : 0; + sequence.play(when + delaySeconds); + this.activeSequences.add(sequence); + + if (!this.currentSequence) { + this.currentSequence = sequence; + } + }); + } + + /** + * Chain multiple sequences together in order (sequential playback). + * @param sequenceNames - Array of sequence names to play in order + */ + chainSequences(sequenceNames: string[]): void { + if (!this.enabled || sequenceNames.length === 0) return; + + this.stop(); + this.sequenceChain = sequenceNames; + this.currentChainIndex = 0; + + this.playNextInChain(); + } + + /** + * Play the next sequence in the chain. + */ + private playNextInChain(): void { + if (!this.enabled || this.sequenceChain.length === 0) return; + + const seqName = this.sequenceChain[this.currentChainIndex]; + const sequence = this.sequences.get(seqName); + if (!sequence) return; + + this.currentSequence = sequence; + sequence.loop = false; + if (sequence.gain) { + sequence.gain.gain.value = this.volume; + } + + sequence.play(); + if (sequence.osc) { + const nextIndex = (this.currentChainIndex + 1) % this.sequenceChain.length; + sequence.osc.onended = () => { + if (this.enabled) { + this.currentChainIndex = nextIndex; + this.playNextInChain(); + } + }; + } + } + /** * Stop current playback. */ stop(): void { + this.activeSequences.forEach((seq) => { + seq.stop(); + }); + this.activeSequences.clear(); if (this.currentSequence) { this.currentSequence.stop(); this.currentSequence = null; @@ -85,4 +174,61 @@ export class Music extends Component { this.stop(); } } + + /** + * Pause all active sequences by setting their gain to 0. + */ + pause(): void { + this.activeSequences.forEach((seq) => { + if (seq.gain) { + const currentVolume = seq.gain.gain.value; + this.sequenceVolumes.set(seq, currentVolume); + seq.gain.gain.value = 0; + } + }); + if (this.currentSequence && this.currentSequence.gain) { + const currentVolume = this.currentSequence.gain.gain.value; + this.sequenceVolumes.set(this.currentSequence, currentVolume); + this.currentSequence.gain.gain.value = 0; + } + this.sequences.forEach((seq) => { + if (seq.gain && seq.gain.gain.value > 0) { + const currentVolume = seq.gain.gain.value; + this.sequenceVolumes.set(seq, currentVolume); + seq.gain.gain.value = 0; + } + }); + } + + /** + * Resume all active sequences by restoring their volume. + */ + resume(): void { + this.activeSequences.forEach((seq) => { + if (seq.gain) { + const savedVolume = this.sequenceVolumes.get(seq); + if (savedVolume !== undefined) { + seq.gain.gain.value = savedVolume; + } else { + seq.gain.gain.value = this.volume; + } + } + }); + if (this.currentSequence && this.currentSequence.gain) { + const savedVolume = this.sequenceVolumes.get(this.currentSequence); + if (savedVolume !== undefined) { + this.currentSequence.gain.gain.value = savedVolume; + } else { + this.currentSequence.gain.gain.value = this.volume; + } + } + this.sequences.forEach((seq) => { + if (seq.gain) { + const savedVolume = this.sequenceVolumes.get(seq); + if (savedVolume !== undefined) { + seq.gain.gain.value = savedVolume; + } + } + }); + } } diff --git a/src/config/MusicConfig.ts b/src/config/MusicConfig.ts new file mode 100644 index 0000000..dbf8863 --- /dev/null +++ b/src/config/MusicConfig.ts @@ -0,0 +1,167 @@ +import { Sequence } from '../core/Music.ts'; +import type { Music } from '../components/Music.ts'; +import type { MusicSystem } from '../systems/MusicSystem.ts'; + +/** + * Configure and setup background music. + * @param music - Music component instance + * @param audioCtx - AudioContext instance + */ +export function setupMusic(music: Music, audioCtx: AudioContext): void { + const tempo = 132; + + const lead = new Sequence(audioCtx, tempo, [ + 'F4 e', + 'Ab4 e', + 'C5 e', + 'F5 e', + 'C5 e', + 'Ab4 e', + 'F4 e', + 'C4 e', + 'F4 e', + 'Ab4 e', + 'C5 e', + 'F5 e', + 'C5 e', + 'Ab4 e', + 'F4 e', + 'C4 e', + 'G4 e', + 'Bb4 e', + 'D5 e', + 'G5 e', + 'D5 e', + 'Bb4 e', + 'G4 e', + 'D4 e', + 'F4 e', + 'Ab4 e', + 'C5 e', + 'F5 e', + 'C5 e', + 'Ab4 e', + 'F4 e', + 'C4 e', + ]); + lead.staccato = 0.1; + lead.smoothing = 0.3; + lead.waveType = 'triangle'; + lead.loop = true; + if (lead.gain) { + lead.gain.gain.value = 0.8; + } + music.addSequence('lead', lead); + + const harmony = new Sequence(audioCtx, tempo, [ + 'C4 e', + 'Eb4 e', + 'F4 e', + 'Ab4 e', + 'F4 e', + 'Eb4 e', + 'C4 e', + 'Ab3 e', + 'C4 e', + 'Eb4 e', + 'F4 e', + 'Ab4 e', + 'F4 e', + 'Eb4 e', + 'C4 e', + 'Ab3 e', + 'D4 e', + 'F4 e', + 'G4 e', + 'Bb4 e', + 'G4 e', + 'F4 e', + 'D4 e', + 'Bb3 e', + 'C4 e', + 'Eb4 e', + 'F4 e', + 'Ab4 e', + 'F4 e', + 'Eb4 e', + 'C4 e', + 'Ab3 e', + ]); + harmony.staccato = 0.15; + harmony.smoothing = 0.4; + harmony.waveType = 'triangle'; + harmony.loop = true; + if (harmony.gain) { + harmony.gain.gain.value = 0.6; + } + music.addSequence('harmony', harmony); + + const bass = new Sequence(audioCtx, tempo, [ + 'F2 q', + 'C3 q', + 'F2 q', + 'C3 q', + 'G2 q', + 'D3 q', + 'G2 q', + 'D3 q', + 'F2 q', + 'C3 q', + 'F2 q', + 'C3 q', + ]); + bass.staccato = 0.05; + bass.smoothing = 0.5; + bass.waveType = 'triangle'; + bass.loop = true; + if (bass.gain) { + bass.gain.gain.value = 0.7; + } + if (bass.bass) { + bass.bass.gain.value = 4; + bass.bass.frequency.value = 80; + } + music.addSequence('bass', bass); + + music.playSequences([ + { name: 'lead', loop: true }, + { name: 'harmony', loop: true }, + { name: 'bass', loop: true }, + ]); + music.setVolume(0.02); +} + +/** + * Setup music event handlers for canvas interaction. + * @param music - Music component instance + * @param musicSystem - MusicSystem instance + * @param canvas - Canvas element + */ +export function setupMusicHandlers( + music: Music, + musicSystem: MusicSystem, + canvas: HTMLCanvasElement +): void { + canvas.addEventListener('click', () => { + musicSystem.resumeAudioContext(); + if (music.enabled && music.activeSequences.size === 0) { + music.playSequences([ + { name: 'lead', loop: true }, + { name: 'harmony', loop: true }, + { name: 'bass', loop: true }, + ]); + } + canvas.focus(); + }); + + setTimeout(() => { + musicSystem.resumeAudioContext(); + if (music.enabled) { + music.playSequences([ + { name: 'lead', loop: true }, + { name: 'harmony', loop: true }, + { name: 'bass', loop: true }, + ]); + } + }, 1000); +} diff --git a/src/config/SFXConfig.ts b/src/config/SFXConfig.ts new file mode 100644 index 0000000..0ade875 --- /dev/null +++ b/src/config/SFXConfig.ts @@ -0,0 +1,35 @@ +import { Sequence } from '../core/Music.ts'; +import type { SoundEffects } from '../components/SoundEffects.ts'; + +/** + * Configure and setup sound effects. + * @param sfx - SoundEffects component instance + * @param ac - AudioContext instance + */ +export function setupSFX(sfx: SoundEffects, ac: AudioContext): void { + const attackSound = new Sequence(ac, 120, ['C5 s']); + attackSound.staccato = 0.8; + sfx.addSound('attack', attackSound); + + const absorbSound = new Sequence(ac, 120, ['G4 e']); + absorbSound.staccato = 0.5; + sfx.addSound('absorb', absorbSound); + + const skillSound = new Sequence(ac, 120, ['A4 e']); + skillSound.staccato = 0.6; + sfx.addSound('skill', skillSound); + + const damageSound = new Sequence(ac, 120, ['F4 s']); + damageSound.staccato = 0.8; + sfx.addSound('damage', damageSound); + + const shootSound = new Sequence(ac, 120, ['C5 s']); + shootSound.staccato = 0.9; + sfx.addSound('shoot', shootSound); + + const impactSound = new Sequence(ac, 120, ['G4 s']); + impactSound.staccato = 0.7; + sfx.addSound('impact', impactSound); + + sfx.setVolume(0.02); +} diff --git a/src/core/Engine.ts b/src/core/Engine.ts index 2010c0e..d795cb3 100644 --- a/src/core/Engine.ts +++ b/src/core/Engine.ts @@ -143,7 +143,7 @@ export class Engine { | undefined; const gameState = menuSystem ? menuSystem.getGameState() : GameState.PLAYING; const isPaused = [GameState.PAUSED, GameState.START, GameState.GAME_OVER].includes(gameState); - const unskippedSystems = [SystemName.MENU, SystemName.UI, SystemName.RENDER]; + const unskippedSystems = [SystemName.MENU, SystemName.UI, SystemName.RENDER, SystemName.MUSIC]; this.systems.forEach((system) => { if (isPaused && !unskippedSystems.includes(system.name as SystemName)) { diff --git a/src/main.ts b/src/main.ts index 3c0aed4..9971c67 100644 --- a/src/main.ts +++ b/src/main.ts @@ -36,8 +36,9 @@ import { Music } from './components/Music.ts'; import { SoundEffects } from './components/SoundEffects.ts'; import { EntityType, ComponentType } from './core/Constants.ts'; -import { Sequence } from './core/Music.ts'; import type { Entity } from './core/Entity.ts'; +import { setupMusic, setupMusicHandlers } from './config/MusicConfig.ts'; +import { setupSFX } from './config/SFXConfig.ts'; const canvas = document.getElementById('game-canvas') as HTMLCanvasElement; if (!canvas) { @@ -156,90 +157,19 @@ if (!canvas) { if (musicSystem) { const musicEntity = engine.createEntity(); const music = new Music(); - const ac = musicSystem.getAudioContext(); + const audioCtx = musicSystem.getAudioContext(); - const bgMusic = new Sequence(ac, 140, [ - 'C4 e', - 'E4 e', - 'G4 q', - 'C5 e', - 'G4 e', - 'E4 q', - 'A3 e', - 'C4 e', - 'E4 q', - 'A4 e', - 'E4 e', - 'C4 q', - 'F3 e', - 'A3 e', - 'C4 q', - 'F4 e', - 'C4 e', - 'A3 q', - 'G3 e', - 'B3 e', - 'D4 q', - 'G4 e', - 'D4 e', - 'B3 q', - ]); - bgMusic.loop = true; - bgMusic.staccato = 0.2; - bgMusic.smoothing = 0.4; - bgMusic.waveType = 'triangle'; - music.addSequence('background', bgMusic); - music.setVolume(0.02); + setupMusic(music, audioCtx); musicEntity.addComponent(music); - - canvas.addEventListener('click', () => { - musicSystem.resumeAudioContext(); - if (music.enabled && !music.currentSequence) { - music.playSequence('background'); - } - canvas.focus(); - }); - - setTimeout(() => { - musicSystem.resumeAudioContext(); - if (music.enabled) { - music.playSequence('background'); - } - }, 1000); + setupMusicHandlers(music, musicSystem, canvas); const sfxSystem = engine.systems.find((s) => s.name === 'SoundEffectsSystem') as | SoundEffectsSystem | undefined; if (sfxSystem) { const sfxEntity = engine.createEntity(); - const sfx = new SoundEffects(ac); - - const attackSound = new Sequence(ac, 120, ['C5 s']); - attackSound.staccato = 0.8; - sfx.addSound('attack', attackSound); - - const absorbSound = new Sequence(ac, 120, ['G4 e']); - absorbSound.staccato = 0.5; - sfx.addSound('absorb', absorbSound); - - const skillSound = new Sequence(ac, 120, ['A4 e']); - skillSound.staccato = 0.6; - sfx.addSound('skill', skillSound); - - const damageSound = new Sequence(ac, 120, ['F4 s']); - damageSound.staccato = 0.8; - sfx.addSound('damage', damageSound); - - const shootSound = new Sequence(ac, 120, ['C5 s']); - shootSound.staccato = 0.9; - sfx.addSound('shoot', shootSound); - - const impactSound = new Sequence(ac, 120, ['G4 s']); - impactSound.staccato = 0.7; - sfx.addSound('impact', impactSound); - - sfx.setVolume(0.02); - + const sfx = new SoundEffects(audioCtx); + setupSFX(sfx, audioCtx); sfxEntity.addComponent(sfx); } } else { diff --git a/src/systems/MusicSystem.ts b/src/systems/MusicSystem.ts index 529c319..439d31c 100644 --- a/src/systems/MusicSystem.ts +++ b/src/systems/MusicSystem.ts @@ -1,20 +1,23 @@ import { System } from '../core/System.ts'; -import { SystemName, ComponentType } from '../core/Constants.ts'; +import { SystemName, ComponentType, GameState } from '../core/Constants.ts'; import type { Entity } from '../core/Entity.ts'; import type { Engine } from '../core/Engine.ts'; import type { Music } from '../components/Music.ts'; +import type { MenuSystem } from './MenuSystem.ts'; /** * System responsible for managing background music playback. */ export class MusicSystem extends System { private audioContext: AudioContext | null; + private wasPaused: boolean; constructor() { super(SystemName.MUSIC); this.requiredComponents = [ComponentType.MUSIC]; this.priority = 5; this.audioContext = null; + this.wasPaused = false; } /** @@ -25,9 +28,15 @@ export class MusicSystem extends System { } /** - * Process music entities - currently just ensures audio context exists. + * Process music entities - ensures audio context exists and handles pause/resume. */ process(_deltaTime: number, entities: Entity[]): void { + const menuSystem = this.engine.systems.find((s) => s.name === SystemName.MENU) as + | MenuSystem + | undefined; + const gameState = menuSystem ? menuSystem.getGameState() : GameState.PLAYING; + const isPaused = gameState === GameState.PAUSED; + entities.forEach((entity) => { const music = entity.getComponent(ComponentType.MUSIC); if (!music) return; @@ -35,6 +44,14 @@ export class MusicSystem extends System { if (!this.audioContext) { this.audioContext = new AudioContext(); } + + if (isPaused && !this.wasPaused) { + music.pause(); + this.wasPaused = true; + } else if (!isPaused && this.wasPaused) { + music.resume(); + this.wasPaused = false; + } }); }