feat: implement Music and SoundEffects systems for enhanced audio management, including background music and sound effects playback
All checks were successful
Build and Publish Docker Image / Build and Validate (pull_request) Successful in 10s

This commit is contained in:
Juan Sebastián Montoya 2026-01-06 23:25:33 -05:00
parent 143072f0a0
commit 2213f64e60
16 changed files with 739 additions and 14 deletions

View file

@ -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<Position>(ComponentType.POSITION);
const config = GameConfig.AI;

View file

@ -143,6 +143,8 @@ export class AbsorptionSystem extends System {
vfxSystem.createAbsorption(entityPos.x, entityPos.y);
}
}
this.engine.emit(Events.ABSORPTION, { entity });
}
/**

View file

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

View file

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

View file

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

View file

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

View file

@ -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<SoundEffects>(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<SoundEffects>(ComponentType.SOUND_EFFECTS);
if (sfx) {
sfx.play(soundName);
return;
}
}
const entities = this.engine.getEntities();
for (const entity of entities) {
const sfx = entity.getComponent<SoundEffects>(ComponentType.SOUND_EFFECTS);
if (sfx) {
this.sfxEntity = entity;
sfx.play(soundName);
break;
}
}
}
}