Feature/Sound, mangling and minification #6

Merged
jusemon merged 4 commits from Feature/Sound-mangling-and-minification into main 2026-01-07 00:04:17 -05:00
6 changed files with 375 additions and 80 deletions
Showing only changes of commit 5a24d6a2af - Show all commits

View file

@ -8,15 +8,23 @@ import type { Sequence } from '../core/Music.ts';
export class Music extends Component { export class Music extends Component {
sequences: Map<string, Sequence>; sequences: Map<string, Sequence>;
currentSequence: Sequence | null; currentSequence: Sequence | null;
activeSequences: Set<Sequence>;
volume: number; volume: number;
enabled: boolean; enabled: boolean;
private sequenceChain: string[];
private currentChainIndex: number;
private sequenceVolumes: Map<Sequence, number>;
constructor() { constructor() {
super(ComponentType.MUSIC); super(ComponentType.MUSIC);
this.sequences = new Map(); this.sequences = new Map();
this.currentSequence = null; this.currentSequence = null;
this.activeSequences = new Set();
this.volume = 0.5; this.volume = 0.5;
this.enabled = true; 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 current playback.
*/ */
stop(): void { stop(): void {
this.activeSequences.forEach((seq) => {
seq.stop();
});
this.activeSequences.clear();
if (this.currentSequence) { if (this.currentSequence) {
this.currentSequence.stop(); this.currentSequence.stop();
this.currentSequence = null; this.currentSequence = null;
@ -85,4 +174,61 @@ export class Music extends Component {
this.stop(); 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;
}
}
});
}
} }

167
src/config/MusicConfig.ts Normal file
View file

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

35
src/config/SFXConfig.ts Normal file
View file

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

View file

@ -143,7 +143,7 @@ export class Engine {
| undefined; | undefined;
const gameState = menuSystem ? menuSystem.getGameState() : GameState.PLAYING; const gameState = menuSystem ? menuSystem.getGameState() : GameState.PLAYING;
const isPaused = [GameState.PAUSED, GameState.START, GameState.GAME_OVER].includes(gameState); 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) => { this.systems.forEach((system) => {
if (isPaused && !unskippedSystems.includes(system.name as SystemName)) { if (isPaused && !unskippedSystems.includes(system.name as SystemName)) {

View file

@ -36,8 +36,9 @@ import { Music } from './components/Music.ts';
import { SoundEffects } from './components/SoundEffects.ts'; import { SoundEffects } from './components/SoundEffects.ts';
import { EntityType, ComponentType } from './core/Constants.ts'; import { EntityType, ComponentType } from './core/Constants.ts';
import { Sequence } from './core/Music.ts';
import type { Entity } from './core/Entity.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; const canvas = document.getElementById('game-canvas') as HTMLCanvasElement;
if (!canvas) { if (!canvas) {
@ -156,90 +157,19 @@ if (!canvas) {
if (musicSystem) { if (musicSystem) {
const musicEntity = engine.createEntity(); const musicEntity = engine.createEntity();
const music = new Music(); const music = new Music();
const ac = musicSystem.getAudioContext(); const audioCtx = musicSystem.getAudioContext();
const bgMusic = new Sequence(ac, 140, [ setupMusic(music, audioCtx);
'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);
musicEntity.addComponent(music); musicEntity.addComponent(music);
setupMusicHandlers(music, musicSystem, canvas);
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);
const sfxSystem = engine.systems.find((s) => s.name === 'SoundEffectsSystem') as const sfxSystem = engine.systems.find((s) => s.name === 'SoundEffectsSystem') as
| SoundEffectsSystem | SoundEffectsSystem
| undefined; | undefined;
if (sfxSystem) { if (sfxSystem) {
const sfxEntity = engine.createEntity(); const sfxEntity = engine.createEntity();
const sfx = new SoundEffects(ac); const sfx = new SoundEffects(audioCtx);
setupSFX(sfx, audioCtx);
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);
sfxEntity.addComponent(sfx); sfxEntity.addComponent(sfx);
} }
} else { } else {

View file

@ -1,20 +1,23 @@
import { System } from '../core/System.ts'; 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 { Entity } from '../core/Entity.ts';
import type { Engine } from '../core/Engine.ts'; import type { Engine } from '../core/Engine.ts';
import type { Music } from '../components/Music.ts'; import type { Music } from '../components/Music.ts';
import type { MenuSystem } from './MenuSystem.ts';
/** /**
* System responsible for managing background music playback. * System responsible for managing background music playback.
*/ */
export class MusicSystem extends System { export class MusicSystem extends System {
private audioContext: AudioContext | null; private audioContext: AudioContext | null;
private wasPaused: boolean;
constructor() { constructor() {
super(SystemName.MUSIC); super(SystemName.MUSIC);
this.requiredComponents = [ComponentType.MUSIC]; this.requiredComponents = [ComponentType.MUSIC];
this.priority = 5; this.priority = 5;
this.audioContext = null; 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 { 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) => { entities.forEach((entity) => {
const music = entity.getComponent<Music>(ComponentType.MUSIC); const music = entity.getComponent<Music>(ComponentType.MUSIC);
if (!music) return; if (!music) return;
@ -35,6 +44,14 @@ export class MusicSystem extends System {
if (!this.audioContext) { if (!this.audioContext) {
this.audioContext = new AudioContext(); this.audioContext = new AudioContext();
} }
if (isPaused && !this.wasPaused) {
music.pause();
this.wasPaused = true;
} else if (!isPaused && this.wasPaused) {
music.resume();
this.wasPaused = false;
}
}); });
} }