From 2213f64e60d8309f115db424cdaed2d7ffb04205 Mon Sep 17 00:00:00 2001 From: Juan Sebastian Montoya Date: Tue, 6 Jan 2026 23:25:33 -0500 Subject: [PATCH] feat: implement Music and SoundEffects systems for enhanced audio management, including background music and sound effects playback --- src/components/Music.ts | 88 ++++++++++ src/components/SoundEffects.ts | 73 ++++++++ src/core/Constants.ts | 4 + src/core/Entity.ts | 1 - src/core/EventBus.ts | 3 + src/core/Music.ts | 269 ++++++++++++++++++++++++++++++ src/core/PixelFont.ts | 3 +- src/main.ts | 119 ++++++++++++- src/skills/skills/WaterGun.ts | 7 + src/systems/AISystem.ts | 3 +- src/systems/AbsorptionSystem.ts | 2 + src/systems/MusicSystem.ts | 59 +++++++ src/systems/ProjectileSystem.ts | 6 + src/systems/RenderSystem.ts | 9 +- src/systems/SkillSystem.ts | 2 + src/systems/SoundEffectsSystem.ts | 105 ++++++++++++ 16 files changed, 739 insertions(+), 14 deletions(-) create mode 100644 src/components/Music.ts create mode 100644 src/components/SoundEffects.ts create mode 100644 src/core/Music.ts create mode 100644 src/systems/MusicSystem.ts create mode 100644 src/systems/SoundEffectsSystem.ts diff --git a/src/components/Music.ts b/src/components/Music.ts new file mode 100644 index 0000000..332aeab --- /dev/null +++ b/src/components/Music.ts @@ -0,0 +1,88 @@ +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; + volume: number; + enabled: boolean; + + constructor() { + super(ComponentType.MUSIC); + this.sequences = new Map(); + this.currentSequence = null; + this.volume = 0.5; + this.enabled = true; + } + + /** + * 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(); + } + } + + /** + * Stop current playback. + */ + stop(): void { + 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(); + } + } +} 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/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/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 d895c87..d4cd05c 100644 --- a/src/core/PixelFont.ts +++ b/src/core/PixelFont.ts @@ -81,7 +81,8 @@ export const PixelFont = { const chars = text.toUpperCase().split(''); chars.forEach((char) => { - const glyph = FONT_DATA.get(char) || FONT_DATA.get('?')!; + 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..3c0aed4 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,8 +32,11 @@ 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 { Sequence } from './core/Music.ts'; import type { Entity } from './core/Entity.ts'; const canvas = document.getElementById('game-canvas') as HTMLCanvasElement; @@ -42,6 +47,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 +150,121 @@ 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 ac = 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); + 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); + + 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); + + 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..529c319 --- /dev/null +++ b/src/systems/MusicSystem.ts @@ -0,0 +1,59 @@ +import { System } from '../core/System.ts'; +import { SystemName, ComponentType } 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'; + +/** + * System responsible for managing background music playback. + */ +export class MusicSystem extends System { + private audioContext: AudioContext | null; + + constructor() { + super(SystemName.MUSIC); + this.requiredComponents = [ComponentType.MUSIC]; + this.priority = 5; + this.audioContext = null; + } + + /** + * Initialize the audio context when system is added to engine. + */ + init(engine: Engine): void { + super.init(engine); + } + + /** + * Process music entities - currently just ensures audio context exists. + */ + process(_deltaTime: number, entities: Entity[]): void { + entities.forEach((entity) => { + const music = entity.getComponent(ComponentType.MUSIC); + if (!music) return; + + if (!this.audioContext) { + this.audioContext = new AudioContext(); + } + }); + } + + /** + * 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; + } + } + } +}