diff --git a/src/components/Music.ts b/src/components/Music.ts new file mode 100644 index 0000000..35b9621 --- /dev/null +++ b/src/components/Music.ts @@ -0,0 +1,234 @@ +import { Component } from '../core/Component.ts'; +import { ComponentType } from '../core/Constants.ts'; +import type { Sequence } from '../core/Music.ts'; + +/** + * Component for managing background music and sound effects. + */ +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(); + } + + /** + * Add a music sequence. + * @param name - Unique identifier for the sequence + * @param sequence - The sequence instance + */ + addSequence(name: string, sequence: Sequence): void { + this.sequences.set(name, sequence); + if (sequence.gain) { + sequence.gain.gain.value = this.volume; + } + } + + /** + * Play a sequence by name. + * @param name - The sequence identifier + */ + playSequence(name: string): void { + if (!this.enabled) return; + + const sequence = this.sequences.get(name); + if (sequence) { + this.stop(); + this.currentSequence = sequence; + if (sequence.gain) { + sequence.gain.gain.value = this.volume; + } + sequence.play(); + } + } + + /** + * 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; + } + } + + /** + * Set the volume (0.0 to 1.0). + * @param volume - Volume level + */ + setVolume(volume: number): void { + this.volume = Math.max(0, Math.min(1, volume)); + if (this.currentSequence && this.currentSequence.gain) { + this.currentSequence.gain.gain.value = this.volume; + } + this.sequences.forEach((seq) => { + if (seq.gain) { + seq.gain.gain.value = this.volume; + } + }); + } + + /** + * Enable or disable music playback. + * @param enabled - Whether music should be enabled + */ + setEnabled(enabled: boolean): void { + this.enabled = enabled; + if (!enabled) { + 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/components/SoundEffects.ts b/src/components/SoundEffects.ts new file mode 100644 index 0000000..c605140 --- /dev/null +++ b/src/components/SoundEffects.ts @@ -0,0 +1,73 @@ +import { Component } from '../core/Component.ts'; +import { ComponentType } from '../core/Constants.ts'; +import type { Sequence } from '../core/Music.ts'; + +/** + * Component for managing sound effects. + * Sound effects are short, one-shot audio sequences. + */ +export class SoundEffects extends Component { + sounds: Map; + volume: number; + enabled: boolean; + audioContext: AudioContext | null; + + constructor(audioContext?: AudioContext) { + super(ComponentType.SOUND_EFFECTS); + this.sounds = new Map(); + this.volume = 0.15; // Reduced default volume + this.enabled = true; + this.audioContext = audioContext || null; + } + + /** + * Add a sound effect sequence. + * @param name - Unique identifier for the sound + * @param sequence - The sequence instance (should be short, non-looping) + */ + addSound(name: string, sequence: Sequence): void { + sequence.loop = false; // SFX should never loop + this.sounds.set(name, sequence); + if (sequence.gain) { + sequence.gain.gain.value = this.volume; + } + } + + /** + * Play a sound effect by name. + * @param name - The sound identifier + */ + play(name: string): void { + if (!this.enabled) return; + + const sound = this.sounds.get(name); + if (sound) { + sound.stop(); + if (sound.gain) { + sound.gain.gain.value = this.volume; + } + sound.play(); + } + } + + /** + * Set the volume (0.0 to 1.0). + * @param volume - Volume level + */ + setVolume(volume: number): void { + this.volume = Math.max(0, Math.min(1, volume)); + this.sounds.forEach((seq) => { + if (seq.gain) { + seq.gain.gain.value = this.volume; + } + }); + } + + /** + * Enable or disable sound effects. + * @param enabled - Whether sound effects should be enabled + */ + setEnabled(enabled: boolean): void { + this.enabled = enabled; + } +} diff --git a/src/config/MusicConfig.ts b/src/config/MusicConfig.ts new file mode 100644 index 0000000..e1798b2 --- /dev/null +++ b/src/config/MusicConfig.ts @@ -0,0 +1,156 @@ +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(); + }); +} 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/Constants.ts b/src/core/Constants.ts index 400c38e..c12a14f 100644 --- a/src/core/Constants.ts +++ b/src/core/Constants.ts @@ -30,6 +30,8 @@ export enum ComponentType { STEALTH = 'Stealth', INTENT = 'Intent', INVENTORY = 'Inventory', + MUSIC = 'Music', + SOUND_EFFECTS = 'SoundEffects', } /** @@ -79,4 +81,6 @@ export enum SystemName { SKILL = 'SkillSystem', STEALTH = 'StealthSystem', HEALTH_REGEN = 'HealthRegenerationSystem', + MUSIC = 'MusicSystem', + SOUND_EFFECTS = 'SoundEffectsSystem', } 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/core/Entity.ts b/src/core/Entity.ts index dedd19a..ae39174 100644 --- a/src/core/Entity.ts +++ b/src/core/Entity.ts @@ -11,7 +11,6 @@ export class Entity { private components: Map; active: boolean; - // Optional dynamic properties for specific entity types owner?: number; startX?: number; startY?: number; diff --git a/src/core/EventBus.ts b/src/core/EventBus.ts index 7f4e935..6ae2906 100644 --- a/src/core/EventBus.ts +++ b/src/core/EventBus.ts @@ -11,6 +11,9 @@ export enum Events { SKILL_LEARNED = 'skills:learned', ATTACK_PERFORMED = 'combat:attack_performed', SKILL_COOLDOWN_STARTED = 'skills:cooldown_started', + ABSORPTION = 'absorption:absorbed', + PROJECTILE_CREATED = 'projectile:created', + PROJECTILE_IMPACT = 'projectile:impact', } /** diff --git a/src/core/Music.ts b/src/core/Music.ts new file mode 100644 index 0000000..656fbf3 --- /dev/null +++ b/src/core/Music.ts @@ -0,0 +1,269 @@ +/** + * Note class - represents a single musical note + */ +export class Note { + frequency: number; + duration: number; + + constructor(str: string) { + const couple = str.split(/\s+/); + this.frequency = Note.getFrequency(couple[0]) || 0; + this.duration = Note.getDuration(couple[1]) || 0; + } + + /** + * Convert a note name (e.g. 'A4') to a frequency (e.g. 440.00) + */ + static getFrequency(name: string): number { + const enharmonics = 'B#-C|C#-Db|D|D#-Eb|E-Fb|E#-F|F#-Gb|G|G#-Ab|A|A#-Bb|B-Cb'; + const middleC = 440 * Math.pow(Math.pow(2, 1 / 12), -9); + const octaveOffset = 4; + const num = /(\d+)/; + const offsets: Record = {}; + + enharmonics.split('|').forEach((val, i) => { + val.split('-').forEach((note) => { + offsets[note] = i; + }); + }); + + const couple = name.split(num); + const distance = offsets[couple[0]] ?? 0; + const octaveDiff = parseInt(couple[1] || String(octaveOffset), 10) - octaveOffset; + const freq = middleC * Math.pow(Math.pow(2, 1 / 12), distance); + return freq * Math.pow(2, octaveDiff); + } + + /** + * Convert a duration string (e.g. 'q') to a number (e.g. 1) + */ + static getDuration(symbol: string): number { + const numeric = /^[0-9.]+$/; + if (numeric.test(symbol)) { + return parseFloat(symbol); + } + return symbol + .toLowerCase() + .split('') + .reduce((prev, curr) => { + return ( + prev + + (curr === 'w' + ? 4 + : curr === 'h' + ? 2 + : curr === 'q' + ? 1 + : curr === 'e' + ? 0.5 + : curr === 's' + ? 0.25 + : 0) + ); + }, 0); + } +} + +/** + * Sequence class - manages playback of musical sequences + */ +export class Sequence { + ac: AudioContext; + tempo: number; + loop: boolean; + smoothing: number; + staccato: number; + notes: Note[]; + gain: GainNode; + bass: BiquadFilterNode | null; + mid: BiquadFilterNode | null; + treble: BiquadFilterNode | null; + waveType: OscillatorType | 'custom'; + customWave?: [Float32Array, Float32Array]; + osc: OscillatorNode | null; + + constructor(ac?: AudioContext, tempo = 120, arr?: (Note | string)[]) { + this.ac = ac || new AudioContext(); + this.tempo = tempo; + this.loop = true; + this.smoothing = 0; + this.staccato = 0; + this.notes = []; + this.bass = null; + this.mid = null; + this.treble = null; + this.osc = null; + this.waveType = 'square'; + this.gain = this.ac.createGain(); + this.createFxNodes(); + if (arr) { + this.push(...arr); + } + } + + /** + * Create gain and EQ nodes, then connect them + */ + createFxNodes(): void { + const eq: Array<[string, number]> = [ + ['bass', 100], + ['mid', 1000], + ['treble', 2500], + ]; + let prev: AudioNode = this.gain; + + eq.forEach((config) => { + const filter = this.ac.createBiquadFilter(); + filter.type = 'peaking'; + filter.frequency.value = config[1]; + prev.connect(filter); + prev = filter; + + if (config[0] === 'bass') { + this.bass = filter; + } else if (config[0] === 'mid') { + this.mid = filter; + } else if (config[0] === 'treble') { + this.treble = filter; + } + }); + + prev.connect(this.ac.destination); + } + + /** + * Accepts Note instances or strings (e.g. 'A4 e') + */ + push(...notes: (Note | string)[]): this { + notes.forEach((note) => { + this.notes.push(note instanceof Note ? note : new Note(note)); + }); + return this; + } + + /** + * Create a custom waveform + */ + createCustomWave(real: number[], imag?: number[]): void { + if (!imag) { + imag = real; + } + this.waveType = 'custom'; + this.customWave = [new Float32Array(real), new Float32Array(imag)]; + } + + /** + * Recreate the oscillator node (happens on every play) + */ + createOscillator(): this { + this.stop(); + this.osc = this.ac.createOscillator(); + + if (this.customWave) { + this.osc.setPeriodicWave(this.ac.createPeriodicWave(this.customWave[0], this.customWave[1])); + } else { + this.osc.type = this.waveType === 'custom' ? 'square' : this.waveType; + } + + if (this.gain) { + this.osc.connect(this.gain); + } + return this; + } + + /** + * Schedule a note to play at the given time + */ + scheduleNote(index: number, when: number): number { + const duration = (60 / this.tempo) * this.notes[index].duration; + const cutoff = duration * (1 - (this.staccato || 0)); + + this.setFrequency(this.notes[index].frequency, when); + + if (this.smoothing && this.notes[index].frequency) { + this.slide(index, when, cutoff); + } + + this.setFrequency(0, when + cutoff); + return when + duration; + } + + /** + * Get the next note + */ + getNextNote(index: number): Note { + return this.notes[index < this.notes.length - 1 ? index + 1 : 0]; + } + + /** + * How long do we wait before beginning the slide? + */ + getSlideStartDelay(duration: number): number { + return duration - Math.min(duration, (60 / this.tempo) * this.smoothing); + } + + /** + * Slide the note at index into the next note + */ + slide(index: number, when: number, cutoff: number): this { + const next = this.getNextNote(index); + const start = this.getSlideStartDelay(cutoff); + this.setFrequency(this.notes[index].frequency, when + start); + this.rampFrequency(next.frequency, when + cutoff); + return this; + } + + /** + * Set frequency at time + */ + setFrequency(freq: number, when: number): this { + if (this.osc) { + this.osc.frequency.setValueAtTime(freq, when); + } + return this; + } + + /** + * Ramp to frequency at time + */ + rampFrequency(freq: number, when: number): this { + if (this.osc) { + this.osc.frequency.linearRampToValueAtTime(freq, when); + } + return this; + } + + /** + * Run through all notes in the sequence and schedule them + */ + play(when?: number): this { + const startTime = typeof when === 'number' ? when : this.ac.currentTime; + + this.createOscillator(); + if (this.osc) { + this.osc.start(startTime); + + let currentTime = startTime; + this.notes.forEach((_note, i) => { + currentTime = this.scheduleNote(i, currentTime); + }); + + this.osc.stop(currentTime); + this.osc.onended = this.loop ? () => this.play(currentTime) : null; + } + + return this; + } + + /** + * Stop playback + */ + stop(): this { + if (this.osc) { + this.osc.onended = null; + this.osc.disconnect(); + this.osc = null; + } + return this; + } +} diff --git a/src/core/PixelFont.ts b/src/core/PixelFont.ts index ea0463a..d4cd05c 100644 --- a/src/core/PixelFont.ts +++ b/src/core/PixelFont.ts @@ -1,57 +1,58 @@ /** * Simple 5x7 Matrix Pixel Font data. * Each character is represented by an array of 7 integers, where each integer is a 5-bit mask. + * Using Map for better minification/mangling support. */ -const FONT_DATA: Record = { - A: [0x0e, 0x11, 0x11, 0x1f, 0x11, 0x11, 0x11], - B: [0x1e, 0x11, 0x11, 0x1e, 0x11, 0x11, 0x1e], - C: [0x0e, 0x11, 0x11, 0x10, 0x11, 0x11, 0x0e], - D: [0x1e, 0x11, 0x11, 0x11, 0x11, 0x11, 0x1e], - E: [0x1f, 0x10, 0x10, 0x1e, 0x10, 0x10, 0x1f], - F: [0x1f, 0x10, 0x10, 0x1e, 0x10, 0x10, 0x10], - G: [0x0f, 0x10, 0x10, 0x17, 0x11, 0x11, 0x0f], - H: [0x11, 0x11, 0x11, 0x1f, 0x11, 0x11, 0x11], - I: [0x0e, 0x04, 0x04, 0x04, 0x04, 0x04, 0x0e], - J: [0x07, 0x02, 0x02, 0x02, 0x02, 0x12, 0x0c], - K: [0x11, 0x12, 0x14, 0x18, 0x14, 0x12, 0x11], - L: [0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x1f], - M: [0x11, 0x1b, 0x15, 0x15, 0x11, 0x11, 0x11], - N: [0x11, 0x11, 0x19, 0x15, 0x13, 0x11, 0x11], - O: [0x0e, 0x11, 0x11, 0x11, 0x11, 0x11, 0x0e], - P: [0x1e, 0x11, 0x11, 0x1e, 0x10, 0x10, 0x10], - Q: [0x0e, 0x11, 0x11, 0x11, 0x15, 0x12, 0x0d], - R: [0x1e, 0x11, 0x11, 0x1e, 0x14, 0x12, 0x11], - S: [0x0e, 0x11, 0x10, 0x0e, 0x01, 0x11, 0x0e], - T: [0x1f, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04], - U: [0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x0e], - V: [0x11, 0x11, 0x11, 0x11, 0x11, 0x0a, 0x04], - W: [0x11, 0x11, 0x11, 0x15, 0x15, 0x1b, 0x11], - X: [0x11, 0x11, 0x0a, 0x04, 0x0a, 0x11, 0x11], - Y: [0x11, 0x11, 0x0a, 0x04, 0x04, 0x04, 0x04], - Z: [0x1f, 0x01, 0x02, 0x04, 0x08, 0x10, 0x1f], - '0': [0x0e, 0x11, 0x13, 0x15, 0x19, 0x11, 0x0e], - '1': [0x04, 0x0c, 0x04, 0x04, 0x04, 0x04, 0x0e], - '2': [0x0e, 0x11, 0x01, 0x02, 0x04, 0x08, 0x1f], - '3': [0x1f, 0x02, 0x04, 0x02, 0x01, 0x11, 0x0e], - '4': [0x02, 0x06, 0x0a, 0x12, 0x1f, 0x02, 0x02], - '5': [0x1f, 0x10, 0x1e, 0x01, 0x01, 0x11, 0x0e], - '6': [0x06, 0x08, 0x10, 0x1e, 0x11, 0x11, 0x0e], - '7': [0x1f, 0x01, 0x02, 0x04, 0x08, 0x08, 0x08], - '8': [0x0e, 0x11, 0x11, 0x0e, 0x11, 0x11, 0x0e], - '9': [0x0e, 0x11, 0x11, 0x0f, 0x01, 0x02, 0x0c], - ':': [0x00, 0x04, 0x00, 0x00, 0x00, 0x04, 0x00], - '.': [0x00, 0x00, 0x00, 0x00, 0x00, 0x04, 0x00], - ',': [0x00, 0x00, 0x00, 0x00, 0x00, 0x04, 0x08], - '!': [0x04, 0x04, 0x04, 0x04, 0x04, 0x00, 0x04], - '?': [0x0e, 0x11, 0x01, 0x02, 0x04, 0x00, 0x04], - '+': [0x00, 0x04, 0x04, 0x1f, 0x04, 0x04, 0x00], - '-': [0x00, 0x00, 0x00, 0x1f, 0x00, 0x00, 0x00], - '/': [0x01, 0x02, 0x04, 0x08, 0x10, 0x10, 0x10], - '(': [0x02, 0x04, 0x04, 0x04, 0x04, 0x04, 0x02], - ')': [0x08, 0x04, 0x04, 0x04, 0x04, 0x04, 0x08], - ' ': [0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00], - '|': [0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04], -}; +const FONT_DATA = new Map([ + ['A', [0x0e, 0x11, 0x11, 0x1f, 0x11, 0x11, 0x11]], + ['B', [0x1e, 0x11, 0x11, 0x1e, 0x11, 0x11, 0x1e]], + ['C', [0x0e, 0x11, 0x11, 0x10, 0x11, 0x11, 0x0e]], + ['D', [0x1e, 0x11, 0x11, 0x11, 0x11, 0x11, 0x1e]], + ['E', [0x1f, 0x10, 0x10, 0x1e, 0x10, 0x10, 0x1f]], + ['F', [0x1f, 0x10, 0x10, 0x1e, 0x10, 0x10, 0x10]], + ['G', [0x0f, 0x10, 0x10, 0x17, 0x11, 0x11, 0x0f]], + ['H', [0x11, 0x11, 0x11, 0x1f, 0x11, 0x11, 0x11]], + ['I', [0x0e, 0x04, 0x04, 0x04, 0x04, 0x04, 0x0e]], + ['J', [0x07, 0x02, 0x02, 0x02, 0x02, 0x12, 0x0c]], + ['K', [0x11, 0x12, 0x14, 0x18, 0x14, 0x12, 0x11]], + ['L', [0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x1f]], + ['M', [0x11, 0x1b, 0x15, 0x15, 0x11, 0x11, 0x11]], + ['N', [0x11, 0x11, 0x19, 0x15, 0x13, 0x11, 0x11]], + ['O', [0x0e, 0x11, 0x11, 0x11, 0x11, 0x11, 0x0e]], + ['P', [0x1e, 0x11, 0x11, 0x1e, 0x10, 0x10, 0x10]], + ['Q', [0x0e, 0x11, 0x11, 0x11, 0x15, 0x12, 0x0d]], + ['R', [0x1e, 0x11, 0x11, 0x1e, 0x14, 0x12, 0x11]], + ['S', [0x0e, 0x11, 0x10, 0x0e, 0x01, 0x11, 0x0e]], + ['T', [0x1f, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04]], + ['U', [0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x0e]], + ['V', [0x11, 0x11, 0x11, 0x11, 0x11, 0x0a, 0x04]], + ['W', [0x11, 0x11, 0x11, 0x15, 0x15, 0x1b, 0x11]], + ['X', [0x11, 0x11, 0x0a, 0x04, 0x0a, 0x11, 0x11]], + ['Y', [0x11, 0x11, 0x0a, 0x04, 0x04, 0x04, 0x04]], + ['Z', [0x1f, 0x01, 0x02, 0x04, 0x08, 0x10, 0x1f]], + ['0', [0x0e, 0x11, 0x13, 0x15, 0x19, 0x11, 0x0e]], + ['1', [0x04, 0x0c, 0x04, 0x04, 0x04, 0x04, 0x0e]], + ['2', [0x0e, 0x11, 0x01, 0x02, 0x04, 0x08, 0x1f]], + ['3', [0x1f, 0x02, 0x04, 0x02, 0x01, 0x11, 0x0e]], + ['4', [0x02, 0x06, 0x0a, 0x12, 0x1f, 0x02, 0x02]], + ['5', [0x1f, 0x10, 0x1e, 0x01, 0x01, 0x11, 0x0e]], + ['6', [0x06, 0x08, 0x10, 0x1e, 0x11, 0x11, 0x0e]], + ['7', [0x1f, 0x01, 0x02, 0x04, 0x08, 0x08, 0x08]], + ['8', [0x0e, 0x11, 0x11, 0x0e, 0x11, 0x11, 0x0e]], + ['9', [0x0e, 0x11, 0x11, 0x0f, 0x01, 0x02, 0x0c]], + [':', [0x00, 0x04, 0x00, 0x00, 0x00, 0x04, 0x00]], + ['.', [0x00, 0x00, 0x00, 0x00, 0x00, 0x04, 0x00]], + [',', [0x00, 0x00, 0x00, 0x00, 0x00, 0x04, 0x08]], + ['!', [0x04, 0x04, 0x04, 0x04, 0x04, 0x00, 0x04]], + ['?', [0x0e, 0x11, 0x01, 0x02, 0x04, 0x00, 0x04]], + ['+', [0x00, 0x04, 0x04, 0x1f, 0x04, 0x04, 0x00]], + ['-', [0x00, 0x00, 0x00, 0x1f, 0x00, 0x00, 0x00]], + ['/', [0x01, 0x02, 0x04, 0x08, 0x10, 0x10, 0x10]], + ['(', [0x02, 0x04, 0x04, 0x04, 0x04, 0x04, 0x02]], + [')', [0x08, 0x04, 0x04, 0x04, 0x04, 0x04, 0x08]], + [' ', [0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]], + ['|', [0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04]], +]); /** * Utility class for rendering text using a custom pixel font. @@ -80,7 +81,8 @@ export const PixelFont = { const chars = text.toUpperCase().split(''); chars.forEach((char) => { - const glyph = FONT_DATA[char] || FONT_DATA['?']; + const glyph = FONT_DATA.get(char) || FONT_DATA.get('?'); + if (!glyph) return; for (let row = 0; row < 7; row++) { for (let col = 0; col < 5; col++) { if ((glyph[row] >> (4 - col)) & 1) { diff --git a/src/main.ts b/src/main.ts index 61cd138..9971c67 100644 --- a/src/main.ts +++ b/src/main.ts @@ -15,6 +15,8 @@ import { MenuSystem } from './systems/MenuSystem.ts'; import { RenderSystem } from './systems/RenderSystem.ts'; import { UISystem } from './systems/UISystem.ts'; import { VFXSystem } from './systems/VFXSystem.ts'; +import { MusicSystem } from './systems/MusicSystem.ts'; +import { SoundEffectsSystem } from './systems/SoundEffectsSystem.ts'; import { Position } from './components/Position.ts'; import { Velocity } from './components/Velocity.ts'; @@ -30,9 +32,13 @@ import { AI } from './components/AI.ts'; import { Absorbable } from './components/Absorbable.ts'; import { SkillProgress } from './components/SkillProgress.ts'; import { Intent } from './components/Intent.ts'; +import { Music } from './components/Music.ts'; +import { SoundEffects } from './components/SoundEffects.ts'; import { EntityType, ComponentType } from './core/Constants.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) { @@ -42,6 +48,8 @@ if (!canvas) { engine.addSystem(new MenuSystem(engine)); engine.addSystem(new InputSystem()); + engine.addSystem(new MusicSystem()); + engine.addSystem(new SoundEffectsSystem()); engine.addSystem(new PlayerControllerSystem()); engine.addSystem(new StealthSystem()); engine.addSystem(new AISystem()); @@ -143,17 +151,50 @@ if (!canvas) { } }, 5000); + const musicSystem = engine.systems.find((s) => s.name === 'MusicSystem') as + | MusicSystem + | undefined; + if (musicSystem) { + const musicEntity = engine.createEntity(); + const music = new Music(); + const audioCtx = musicSystem.getAudioContext(); + + setupMusic(music, audioCtx); + musicEntity.addComponent(music); + 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(audioCtx); + setupSFX(sfx, audioCtx); + sfxEntity.addComponent(sfx); + } + } else { + canvas.addEventListener('click', () => { + canvas.focus(); + }); + } + canvas.focus(); engine.start(); interface WindowWithGame { gameEngine?: Engine; player?: Entity; + music?: Music; } (window as WindowWithGame).gameEngine = engine; (window as WindowWithGame).player = player; - - canvas.addEventListener('click', () => { - canvas.focus(); - }); + if (musicSystem) { + const musicEntity = engine.getEntities().find((e) => e.hasComponent(ComponentType.MUSIC)); + if (musicEntity) { + const music = musicEntity.getComponent(ComponentType.MUSIC); + if (music) { + (window as WindowWithGame).music = music; + } + } + } } diff --git a/src/skills/skills/WaterGun.ts b/src/skills/skills/WaterGun.ts index 4572c5f..944c472 100644 --- a/src/skills/skills/WaterGun.ts +++ b/src/skills/skills/WaterGun.ts @@ -1,5 +1,6 @@ import { Skill } from '../Skill.ts'; import { ComponentType, SystemName, EntityType } from '../../core/Constants.ts'; +import { Events } from '../../core/EventBus.ts'; import { Position } from '../../components/Position.ts'; import { Velocity } from '../../components/Velocity.ts'; import { Sprite } from '../../components/Sprite.ts'; @@ -89,6 +90,12 @@ export class SlimeGun extends Skill { projectile.maxRange = this.range; projectile.lifetime = this.range / this.speed + 1.0; + engine.emit(Events.PROJECTILE_CREATED, { + x: startX, + y: startY, + angle: shootAngle, + }); + return true; } } diff --git a/src/systems/AISystem.ts b/src/systems/AISystem.ts index a38b394..15a3744 100644 --- a/src/systems/AISystem.ts +++ b/src/systems/AISystem.ts @@ -33,7 +33,8 @@ export class AISystem extends System { const playerController = this.engine.systems.find( (s) => s.name === SystemName.PLAYER_CONTROLLER ) as PlayerControllerSystem | undefined; - const player = playerController ? playerController.getPlayerEntity() : null; + const player = playerController?.getPlayerEntity(); + if (!player) return; const playerPos = player?.getComponent(ComponentType.POSITION); const config = GameConfig.AI; diff --git a/src/systems/AbsorptionSystem.ts b/src/systems/AbsorptionSystem.ts index fd57995..e7af650 100644 --- a/src/systems/AbsorptionSystem.ts +++ b/src/systems/AbsorptionSystem.ts @@ -143,6 +143,8 @@ export class AbsorptionSystem extends System { vfxSystem.createAbsorption(entityPos.x, entityPos.y); } } + + this.engine.emit(Events.ABSORPTION, { entity }); } /** diff --git a/src/systems/MusicSystem.ts b/src/systems/MusicSystem.ts new file mode 100644 index 0000000..439d31c --- /dev/null +++ b/src/systems/MusicSystem.ts @@ -0,0 +1,76 @@ +import { System } from '../core/System.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; + } + + /** + * Initialize the audio context when system is added to engine. + */ + init(engine: Engine): void { + super.init(engine); + } + + /** + * 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; + + 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; + } + }); + } + + /** + * Get or create the shared audio context. + */ + getAudioContext(): AudioContext { + if (!this.audioContext) { + this.audioContext = new AudioContext(); + } + return this.audioContext; + } + + /** + * Resume audio context (required after user interaction). + */ + resumeAudioContext(): void { + if (this.audioContext && this.audioContext.state === 'suspended') { + this.audioContext.resume(); + } + } +} diff --git a/src/systems/ProjectileSystem.ts b/src/systems/ProjectileSystem.ts index 4c92f48..c9d91f4 100644 --- a/src/systems/ProjectileSystem.ts +++ b/src/systems/ProjectileSystem.ts @@ -75,6 +75,12 @@ export class ProjectileSystem extends System { if (targetHealthComp) { targetHealthComp.takeDamage(damage); + this.engine.emit(Events.PROJECTILE_IMPACT, { + x: position.x, + y: position.y, + damage, + }); + const vfxSystem = this.engine.systems.find((s) => s.name === SystemName.VFX) as | VFXSystem | undefined; diff --git a/src/systems/RenderSystem.ts b/src/systems/RenderSystem.ts index d9656cd..b989c22 100644 --- a/src/systems/RenderSystem.ts +++ b/src/systems/RenderSystem.ts @@ -179,7 +179,6 @@ export class RenderSystem extends System { let frames = spriteData[sprite.animationState as string] || spriteData[AnimationState.IDLE]; if (!frames || !Array.isArray(frames)) { - // Fallback to default slime animation if data structure is unexpected frames = SpriteLibrary[EntityType.SLIME][AnimationState.IDLE]; } @@ -414,28 +413,24 @@ export class RenderSystem extends System { */ drawGlowEffect(sprite: Sprite): void { const ctx = this.ctx; - const time = Date.now() * 0.001; // Time in seconds for pulsing - const pulse = 0.5 + Math.sin(time * 3) * 0.3; // Pulsing between 0.2 and 0.8 + const time = Date.now() * 0.001; + const pulse = 0.5 + Math.sin(time * 3) * 0.3; const baseRadius = Math.max(sprite.width, sprite.height) / 2; const glowRadius = baseRadius + 4 + pulse * 2; - // Create radial gradient for soft glow const gradient = ctx.createRadialGradient(0, 0, baseRadius, 0, 0, glowRadius); gradient.addColorStop(0, `rgba(255, 255, 255, ${0.4 * pulse})`); gradient.addColorStop(0.5, `rgba(0, 230, 255, ${0.3 * pulse})`); gradient.addColorStop(1, 'rgba(0, 230, 255, 0)'); - // Draw multiple layers for a softer glow effect ctx.save(); ctx.globalCompositeOperation = 'screen'; - // Outer glow layer ctx.fillStyle = gradient; ctx.beginPath(); ctx.arc(0, 0, glowRadius, 0, Math.PI * 2); ctx.fill(); - // Inner bright core const innerGradient = ctx.createRadialGradient(0, 0, 0, 0, 0, baseRadius * 0.6); innerGradient.addColorStop(0, `rgba(255, 255, 255, ${0.6 * pulse})`); innerGradient.addColorStop(1, 'rgba(0, 230, 255, 0)'); diff --git a/src/systems/SkillSystem.ts b/src/systems/SkillSystem.ts index 6df2a26..107130c 100644 --- a/src/systems/SkillSystem.ts +++ b/src/systems/SkillSystem.ts @@ -1,6 +1,7 @@ import { System } from '../core/System.ts'; import { SkillRegistry } from '../skills/SkillRegistry.ts'; import { SystemName, ComponentType } from '../core/Constants.ts'; +import { Events } from '../core/EventBus.ts'; import type { Entity } from '../core/Entity.ts'; import type { Skills } from '../components/Skills.ts'; import type { Intent } from '../components/Intent.ts'; @@ -55,6 +56,7 @@ export class SkillSystem extends System { if (skills) { skills.setCooldown(skillId, skill.cooldown); } + this.engine.emit(Events.SKILL_COOLDOWN_STARTED, { skillId }); } } } diff --git a/src/systems/SoundEffectsSystem.ts b/src/systems/SoundEffectsSystem.ts new file mode 100644 index 0000000..bd4c753 --- /dev/null +++ b/src/systems/SoundEffectsSystem.ts @@ -0,0 +1,105 @@ +import { System } from '../core/System.ts'; +import { SystemName, ComponentType } from '../core/Constants.ts'; +import { Events } from '../core/EventBus.ts'; +import type { Entity } from '../core/Entity.ts'; +import type { Engine } from '../core/Engine.ts'; +import type { SoundEffects } from '../components/SoundEffects.ts'; +import type { MusicSystem } from './MusicSystem.ts'; + +/** + * System responsible for managing sound effects playback. + * Follows ECS pattern: system processes entities with SoundEffects component. + * Listens to game events and plays appropriate sound effects. + */ +export class SoundEffectsSystem extends System { + private sfxEntity: Entity | null; + + constructor() { + super(SystemName.SOUND_EFFECTS); + this.requiredComponents = [ComponentType.SOUND_EFFECTS]; + this.priority = 5; + this.sfxEntity = null; + } + + /** + * Initialize event listeners when system is added to engine. + */ + init(engine: Engine): void { + super.init(engine); + + this.engine.on(Events.ATTACK_PERFORMED, () => { + this.playSound('attack'); + }); + + this.engine.on(Events.DAMAGE_DEALT, () => { + this.playSound('damage'); + }); + + this.engine.on(Events.ABSORPTION, () => { + this.playSound('absorb'); + }); + + this.engine.on(Events.SKILL_LEARNED, () => { + this.playSound('skill'); + }); + + this.engine.on(Events.SKILL_COOLDOWN_STARTED, () => { + this.playSound('skill'); + }); + + this.engine.on(Events.PROJECTILE_CREATED, () => { + this.playSound('shoot'); + }); + + this.engine.on(Events.PROJECTILE_IMPACT, () => { + this.playSound('impact'); + }); + } + + /** + * Process sound effect entities - ensures audio context is available. + */ + process(_deltaTime: number, entities: Entity[]): void { + entities.forEach((entity) => { + const sfx = entity.getComponent(ComponentType.SOUND_EFFECTS); + if (!sfx) return; + + if (!this.sfxEntity) { + this.sfxEntity = entity; + } + + if (!sfx.audioContext) { + const musicSystem = this.engine.systems.find((s) => s.name === SystemName.MUSIC) as + | MusicSystem + | undefined; + if (musicSystem) { + sfx.audioContext = musicSystem.getAudioContext(); + } + } + }); + } + + /** + * Play a sound effect from any entity with SoundEffects component. + * @param soundName - The name of the sound to play + */ + playSound(soundName: string): void { + if (this.sfxEntity) { + const sfx = this.sfxEntity.getComponent(ComponentType.SOUND_EFFECTS); + if (sfx) { + sfx.play(soundName); + return; + } + } + + const entities = this.engine.getEntities(); + for (const entity of entities) { + const sfx = entity.getComponent(ComponentType.SOUND_EFFECTS); + if (sfx) { + this.sfxEntity = entity; + sfx.play(soundName); + break; + } + } + } +} diff --git a/vite.config.js b/vite.config.js index 5f09b7e..3dfac46 100644 --- a/vite.config.js +++ b/vite.config.js @@ -1,25 +1,27 @@ import { defineConfig } from 'vite'; export default defineConfig({ - build: { - minify: 'terser', - terserOptions: { - compress: { - drop_console: true, - drop_debugger: true, - }, - mangle: { - toplevel: true, - }, - format: { - comments: false, - }, - }, - rollupOptions: { - output: { - manualChunks: undefined, - }, - }, - sourcemap: false, + build: { + minify: 'terser', + terserOptions: { + ecma: 2020, + compress: { + drop_console: true, + drop_debugger: true, + }, + mangle: { + toplevel: true, + properties: true, + }, + format: { + comments: false, + }, }, + rollupOptions: { + output: { + manualChunks: undefined, + }, + }, + sourcemap: false, + }, });