Merge pull request 'Feature/Sound, mangling and minification' (#6) from Feature/Sound-mangling-and-minification into main
All checks were successful
Build and Publish Docker Image / Publish to Registry (push) Successful in 8s
Build and Publish Docker Image / Deploy to Portainer (push) Successful in 1s

Reviewed-on: #6
This commit is contained in:
Juan Sebastián Montoya 2026-01-07 00:04:17 -05:00
commit 71c8129f37
20 changed files with 1097 additions and 85 deletions

234
src/components/Music.ts Normal file
View file

@ -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<string, Sequence>;
currentSequence: Sequence | null;
activeSequences: Set<Sequence>;
volume: number;
enabled: boolean;
private sequenceChain: string[];
private currentChainIndex: number;
private sequenceVolumes: Map<Sequence, number>;
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;
}
}
});
}
}

View file

@ -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<string, Sequence>;
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;
}
}

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

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

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

@ -30,6 +30,8 @@ export enum ComponentType {
STEALTH = 'Stealth', STEALTH = 'Stealth',
INTENT = 'Intent', INTENT = 'Intent',
INVENTORY = 'Inventory', INVENTORY = 'Inventory',
MUSIC = 'Music',
SOUND_EFFECTS = 'SoundEffects',
} }
/** /**
@ -79,4 +81,6 @@ export enum SystemName {
SKILL = 'SkillSystem', SKILL = 'SkillSystem',
STEALTH = 'StealthSystem', STEALTH = 'StealthSystem',
HEALTH_REGEN = 'HealthRegenerationSystem', HEALTH_REGEN = 'HealthRegenerationSystem',
MUSIC = 'MusicSystem',
SOUND_EFFECTS = 'SoundEffectsSystem',
} }

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

@ -11,7 +11,6 @@ export class Entity {
private components: Map<string, Component>; private components: Map<string, Component>;
active: boolean; active: boolean;
// Optional dynamic properties for specific entity types
owner?: number; owner?: number;
startX?: number; startX?: number;
startY?: number; startY?: number;

View file

@ -11,6 +11,9 @@ export enum Events {
SKILL_LEARNED = 'skills:learned', SKILL_LEARNED = 'skills:learned',
ATTACK_PERFORMED = 'combat:attack_performed', ATTACK_PERFORMED = 'combat:attack_performed',
SKILL_COOLDOWN_STARTED = 'skills:cooldown_started', SKILL_COOLDOWN_STARTED = 'skills:cooldown_started',
ABSORPTION = 'absorption:absorbed',
PROJECTILE_CREATED = 'projectile:created',
PROJECTILE_IMPACT = 'projectile:impact',
} }
/** /**

269
src/core/Music.ts Normal file
View file

@ -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<string, number> = {};
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;
}
}

View file

@ -1,57 +1,58 @@
/** /**
* Simple 5x7 Matrix Pixel Font data. * Simple 5x7 Matrix Pixel Font data.
* Each character is represented by an array of 7 integers, where each integer is a 5-bit mask. * 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<string, number[]> = { const FONT_DATA = new Map<string, readonly number[]>([
A: [0x0e, 0x11, 0x11, 0x1f, 0x11, 0x11, 0x11], ['A', [0x0e, 0x11, 0x11, 0x1f, 0x11, 0x11, 0x11]],
B: [0x1e, 0x11, 0x11, 0x1e, 0x11, 0x11, 0x1e], ['B', [0x1e, 0x11, 0x11, 0x1e, 0x11, 0x11, 0x1e]],
C: [0x0e, 0x11, 0x11, 0x10, 0x11, 0x11, 0x0e], ['C', [0x0e, 0x11, 0x11, 0x10, 0x11, 0x11, 0x0e]],
D: [0x1e, 0x11, 0x11, 0x11, 0x11, 0x11, 0x1e], ['D', [0x1e, 0x11, 0x11, 0x11, 0x11, 0x11, 0x1e]],
E: [0x1f, 0x10, 0x10, 0x1e, 0x10, 0x10, 0x1f], ['E', [0x1f, 0x10, 0x10, 0x1e, 0x10, 0x10, 0x1f]],
F: [0x1f, 0x10, 0x10, 0x1e, 0x10, 0x10, 0x10], ['F', [0x1f, 0x10, 0x10, 0x1e, 0x10, 0x10, 0x10]],
G: [0x0f, 0x10, 0x10, 0x17, 0x11, 0x11, 0x0f], ['G', [0x0f, 0x10, 0x10, 0x17, 0x11, 0x11, 0x0f]],
H: [0x11, 0x11, 0x11, 0x1f, 0x11, 0x11, 0x11], ['H', [0x11, 0x11, 0x11, 0x1f, 0x11, 0x11, 0x11]],
I: [0x0e, 0x04, 0x04, 0x04, 0x04, 0x04, 0x0e], ['I', [0x0e, 0x04, 0x04, 0x04, 0x04, 0x04, 0x0e]],
J: [0x07, 0x02, 0x02, 0x02, 0x02, 0x12, 0x0c], ['J', [0x07, 0x02, 0x02, 0x02, 0x02, 0x12, 0x0c]],
K: [0x11, 0x12, 0x14, 0x18, 0x14, 0x12, 0x11], ['K', [0x11, 0x12, 0x14, 0x18, 0x14, 0x12, 0x11]],
L: [0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x1f], ['L', [0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x1f]],
M: [0x11, 0x1b, 0x15, 0x15, 0x11, 0x11, 0x11], ['M', [0x11, 0x1b, 0x15, 0x15, 0x11, 0x11, 0x11]],
N: [0x11, 0x11, 0x19, 0x15, 0x13, 0x11, 0x11], ['N', [0x11, 0x11, 0x19, 0x15, 0x13, 0x11, 0x11]],
O: [0x0e, 0x11, 0x11, 0x11, 0x11, 0x11, 0x0e], ['O', [0x0e, 0x11, 0x11, 0x11, 0x11, 0x11, 0x0e]],
P: [0x1e, 0x11, 0x11, 0x1e, 0x10, 0x10, 0x10], ['P', [0x1e, 0x11, 0x11, 0x1e, 0x10, 0x10, 0x10]],
Q: [0x0e, 0x11, 0x11, 0x11, 0x15, 0x12, 0x0d], ['Q', [0x0e, 0x11, 0x11, 0x11, 0x15, 0x12, 0x0d]],
R: [0x1e, 0x11, 0x11, 0x1e, 0x14, 0x12, 0x11], ['R', [0x1e, 0x11, 0x11, 0x1e, 0x14, 0x12, 0x11]],
S: [0x0e, 0x11, 0x10, 0x0e, 0x01, 0x11, 0x0e], ['S', [0x0e, 0x11, 0x10, 0x0e, 0x01, 0x11, 0x0e]],
T: [0x1f, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04], ['T', [0x1f, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04]],
U: [0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x0e], ['U', [0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x0e]],
V: [0x11, 0x11, 0x11, 0x11, 0x11, 0x0a, 0x04], ['V', [0x11, 0x11, 0x11, 0x11, 0x11, 0x0a, 0x04]],
W: [0x11, 0x11, 0x11, 0x15, 0x15, 0x1b, 0x11], ['W', [0x11, 0x11, 0x11, 0x15, 0x15, 0x1b, 0x11]],
X: [0x11, 0x11, 0x0a, 0x04, 0x0a, 0x11, 0x11], ['X', [0x11, 0x11, 0x0a, 0x04, 0x0a, 0x11, 0x11]],
Y: [0x11, 0x11, 0x0a, 0x04, 0x04, 0x04, 0x04], ['Y', [0x11, 0x11, 0x0a, 0x04, 0x04, 0x04, 0x04]],
Z: [0x1f, 0x01, 0x02, 0x04, 0x08, 0x10, 0x1f], ['Z', [0x1f, 0x01, 0x02, 0x04, 0x08, 0x10, 0x1f]],
'0': [0x0e, 0x11, 0x13, 0x15, 0x19, 0x11, 0x0e], ['0', [0x0e, 0x11, 0x13, 0x15, 0x19, 0x11, 0x0e]],
'1': [0x04, 0x0c, 0x04, 0x04, 0x04, 0x04, 0x0e], ['1', [0x04, 0x0c, 0x04, 0x04, 0x04, 0x04, 0x0e]],
'2': [0x0e, 0x11, 0x01, 0x02, 0x04, 0x08, 0x1f], ['2', [0x0e, 0x11, 0x01, 0x02, 0x04, 0x08, 0x1f]],
'3': [0x1f, 0x02, 0x04, 0x02, 0x01, 0x11, 0x0e], ['3', [0x1f, 0x02, 0x04, 0x02, 0x01, 0x11, 0x0e]],
'4': [0x02, 0x06, 0x0a, 0x12, 0x1f, 0x02, 0x02], ['4', [0x02, 0x06, 0x0a, 0x12, 0x1f, 0x02, 0x02]],
'5': [0x1f, 0x10, 0x1e, 0x01, 0x01, 0x11, 0x0e], ['5', [0x1f, 0x10, 0x1e, 0x01, 0x01, 0x11, 0x0e]],
'6': [0x06, 0x08, 0x10, 0x1e, 0x11, 0x11, 0x0e], ['6', [0x06, 0x08, 0x10, 0x1e, 0x11, 0x11, 0x0e]],
'7': [0x1f, 0x01, 0x02, 0x04, 0x08, 0x08, 0x08], ['7', [0x1f, 0x01, 0x02, 0x04, 0x08, 0x08, 0x08]],
'8': [0x0e, 0x11, 0x11, 0x0e, 0x11, 0x11, 0x0e], ['8', [0x0e, 0x11, 0x11, 0x0e, 0x11, 0x11, 0x0e]],
'9': [0x0e, 0x11, 0x11, 0x0f, 0x01, 0x02, 0x0c], ['9', [0x0e, 0x11, 0x11, 0x0f, 0x01, 0x02, 0x0c]],
':': [0x00, 0x04, 0x00, 0x00, 0x00, 0x04, 0x00], [':', [0x00, 0x04, 0x00, 0x00, 0x00, 0x04, 0x00]],
'.': [0x00, 0x00, 0x00, 0x00, 0x00, 0x04, 0x00], ['.', [0x00, 0x00, 0x00, 0x00, 0x00, 0x04, 0x00]],
',': [0x00, 0x00, 0x00, 0x00, 0x00, 0x04, 0x08], [',', [0x00, 0x00, 0x00, 0x00, 0x00, 0x04, 0x08]],
'!': [0x04, 0x04, 0x04, 0x04, 0x04, 0x00, 0x04], ['!', [0x04, 0x04, 0x04, 0x04, 0x04, 0x00, 0x04]],
'?': [0x0e, 0x11, 0x01, 0x02, 0x04, 0x00, 0x04], ['?', [0x0e, 0x11, 0x01, 0x02, 0x04, 0x00, 0x04]],
'+': [0x00, 0x04, 0x04, 0x1f, 0x04, 0x04, 0x00], ['+', [0x00, 0x04, 0x04, 0x1f, 0x04, 0x04, 0x00]],
'-': [0x00, 0x00, 0x00, 0x1f, 0x00, 0x00, 0x00], ['-', [0x00, 0x00, 0x00, 0x1f, 0x00, 0x00, 0x00]],
'/': [0x01, 0x02, 0x04, 0x08, 0x10, 0x10, 0x10], ['/', [0x01, 0x02, 0x04, 0x08, 0x10, 0x10, 0x10]],
'(': [0x02, 0x04, 0x04, 0x04, 0x04, 0x04, 0x02], ['(', [0x02, 0x04, 0x04, 0x04, 0x04, 0x04, 0x02]],
')': [0x08, 0x04, 0x04, 0x04, 0x04, 0x04, 0x08], [')', [0x08, 0x04, 0x04, 0x04, 0x04, 0x04, 0x08]],
' ': [0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00], [' ', [0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]],
'|': [0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04], ['|', [0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04]],
}; ]);
/** /**
* Utility class for rendering text using a custom pixel font. * Utility class for rendering text using a custom pixel font.
@ -80,7 +81,8 @@ export const PixelFont = {
const chars = text.toUpperCase().split(''); const chars = text.toUpperCase().split('');
chars.forEach((char) => { 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 row = 0; row < 7; row++) {
for (let col = 0; col < 5; col++) { for (let col = 0; col < 5; col++) {
if ((glyph[row] >> (4 - col)) & 1) { if ((glyph[row] >> (4 - col)) & 1) {

View file

@ -15,6 +15,8 @@ import { MenuSystem } from './systems/MenuSystem.ts';
import { RenderSystem } from './systems/RenderSystem.ts'; import { RenderSystem } from './systems/RenderSystem.ts';
import { UISystem } from './systems/UISystem.ts'; import { UISystem } from './systems/UISystem.ts';
import { VFXSystem } from './systems/VFXSystem.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 { Position } from './components/Position.ts';
import { Velocity } from './components/Velocity.ts'; import { Velocity } from './components/Velocity.ts';
@ -30,9 +32,13 @@ import { AI } from './components/AI.ts';
import { Absorbable } from './components/Absorbable.ts'; import { Absorbable } from './components/Absorbable.ts';
import { SkillProgress } from './components/SkillProgress.ts'; import { SkillProgress } from './components/SkillProgress.ts';
import { Intent } from './components/Intent.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 { EntityType, ComponentType } from './core/Constants.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) {
@ -42,6 +48,8 @@ if (!canvas) {
engine.addSystem(new MenuSystem(engine)); engine.addSystem(new MenuSystem(engine));
engine.addSystem(new InputSystem()); engine.addSystem(new InputSystem());
engine.addSystem(new MusicSystem());
engine.addSystem(new SoundEffectsSystem());
engine.addSystem(new PlayerControllerSystem()); engine.addSystem(new PlayerControllerSystem());
engine.addSystem(new StealthSystem()); engine.addSystem(new StealthSystem());
engine.addSystem(new AISystem()); engine.addSystem(new AISystem());
@ -143,17 +151,50 @@ if (!canvas) {
} }
}, 5000); }, 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(); canvas.focus();
engine.start(); engine.start();
interface WindowWithGame { interface WindowWithGame {
gameEngine?: Engine; gameEngine?: Engine;
player?: Entity; player?: Entity;
music?: Music;
} }
(window as WindowWithGame).gameEngine = engine; (window as WindowWithGame).gameEngine = engine;
(window as WindowWithGame).player = player; (window as WindowWithGame).player = player;
if (musicSystem) {
canvas.addEventListener('click', () => { const musicEntity = engine.getEntities().find((e) => e.hasComponent(ComponentType.MUSIC));
canvas.focus(); if (musicEntity) {
}); const music = musicEntity.getComponent<Music>(ComponentType.MUSIC);
if (music) {
(window as WindowWithGame).music = music;
}
}
}
} }

View file

@ -1,5 +1,6 @@
import { Skill } from '../Skill.ts'; import { Skill } from '../Skill.ts';
import { ComponentType, SystemName, EntityType } from '../../core/Constants.ts'; import { ComponentType, SystemName, EntityType } from '../../core/Constants.ts';
import { Events } from '../../core/EventBus.ts';
import { Position } from '../../components/Position.ts'; import { Position } from '../../components/Position.ts';
import { Velocity } from '../../components/Velocity.ts'; import { Velocity } from '../../components/Velocity.ts';
import { Sprite } from '../../components/Sprite.ts'; import { Sprite } from '../../components/Sprite.ts';
@ -89,6 +90,12 @@ export class SlimeGun extends Skill {
projectile.maxRange = this.range; projectile.maxRange = this.range;
projectile.lifetime = this.range / this.speed + 1.0; projectile.lifetime = this.range / this.speed + 1.0;
engine.emit(Events.PROJECTILE_CREATED, {
x: startX,
y: startY,
angle: shootAngle,
});
return true; return true;
} }
} }

View file

@ -33,7 +33,8 @@ export class AISystem extends System {
const playerController = this.engine.systems.find( const playerController = this.engine.systems.find(
(s) => s.name === SystemName.PLAYER_CONTROLLER (s) => s.name === SystemName.PLAYER_CONTROLLER
) as PlayerControllerSystem | undefined; ) as PlayerControllerSystem | undefined;
const player = playerController ? playerController.getPlayerEntity() : null; const player = playerController?.getPlayerEntity();
if (!player) return;
const playerPos = player?.getComponent<Position>(ComponentType.POSITION); const playerPos = player?.getComponent<Position>(ComponentType.POSITION);
const config = GameConfig.AI; const config = GameConfig.AI;

View file

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

View file

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

View file

@ -75,6 +75,12 @@ export class ProjectileSystem extends System {
if (targetHealthComp) { if (targetHealthComp) {
targetHealthComp.takeDamage(damage); 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 const vfxSystem = this.engine.systems.find((s) => s.name === SystemName.VFX) as
| VFXSystem | VFXSystem
| undefined; | undefined;

View file

@ -179,7 +179,6 @@ export class RenderSystem extends System {
let frames = spriteData[sprite.animationState as string] || spriteData[AnimationState.IDLE]; let frames = spriteData[sprite.animationState as string] || spriteData[AnimationState.IDLE];
if (!frames || !Array.isArray(frames)) { if (!frames || !Array.isArray(frames)) {
// Fallback to default slime animation if data structure is unexpected
frames = SpriteLibrary[EntityType.SLIME][AnimationState.IDLE]; frames = SpriteLibrary[EntityType.SLIME][AnimationState.IDLE];
} }
@ -414,28 +413,24 @@ export class RenderSystem extends System {
*/ */
drawGlowEffect(sprite: Sprite): void { drawGlowEffect(sprite: Sprite): void {
const ctx = this.ctx; const ctx = this.ctx;
const time = Date.now() * 0.001; // Time in seconds for pulsing const time = Date.now() * 0.001;
const pulse = 0.5 + Math.sin(time * 3) * 0.3; // Pulsing between 0.2 and 0.8 const pulse = 0.5 + Math.sin(time * 3) * 0.3;
const baseRadius = Math.max(sprite.width, sprite.height) / 2; const baseRadius = Math.max(sprite.width, sprite.height) / 2;
const glowRadius = baseRadius + 4 + pulse * 2; const glowRadius = baseRadius + 4 + pulse * 2;
// Create radial gradient for soft glow
const gradient = ctx.createRadialGradient(0, 0, baseRadius, 0, 0, glowRadius); const gradient = ctx.createRadialGradient(0, 0, baseRadius, 0, 0, glowRadius);
gradient.addColorStop(0, `rgba(255, 255, 255, ${0.4 * pulse})`); gradient.addColorStop(0, `rgba(255, 255, 255, ${0.4 * pulse})`);
gradient.addColorStop(0.5, `rgba(0, 230, 255, ${0.3 * pulse})`); gradient.addColorStop(0.5, `rgba(0, 230, 255, ${0.3 * pulse})`);
gradient.addColorStop(1, 'rgba(0, 230, 255, 0)'); gradient.addColorStop(1, 'rgba(0, 230, 255, 0)');
// Draw multiple layers for a softer glow effect
ctx.save(); ctx.save();
ctx.globalCompositeOperation = 'screen'; ctx.globalCompositeOperation = 'screen';
// Outer glow layer
ctx.fillStyle = gradient; ctx.fillStyle = gradient;
ctx.beginPath(); ctx.beginPath();
ctx.arc(0, 0, glowRadius, 0, Math.PI * 2); ctx.arc(0, 0, glowRadius, 0, Math.PI * 2);
ctx.fill(); ctx.fill();
// Inner bright core
const innerGradient = ctx.createRadialGradient(0, 0, 0, 0, 0, baseRadius * 0.6); const innerGradient = ctx.createRadialGradient(0, 0, 0, 0, 0, baseRadius * 0.6);
innerGradient.addColorStop(0, `rgba(255, 255, 255, ${0.6 * pulse})`); innerGradient.addColorStop(0, `rgba(255, 255, 255, ${0.6 * pulse})`);
innerGradient.addColorStop(1, 'rgba(0, 230, 255, 0)'); innerGradient.addColorStop(1, 'rgba(0, 230, 255, 0)');

View file

@ -1,6 +1,7 @@
import { System } from '../core/System.ts'; import { System } from '../core/System.ts';
import { SkillRegistry } from '../skills/SkillRegistry.ts'; import { SkillRegistry } from '../skills/SkillRegistry.ts';
import { SystemName, ComponentType } from '../core/Constants.ts'; import { SystemName, ComponentType } from '../core/Constants.ts';
import { Events } from '../core/EventBus.ts';
import type { Entity } from '../core/Entity.ts'; import type { Entity } from '../core/Entity.ts';
import type { Skills } from '../components/Skills.ts'; import type { Skills } from '../components/Skills.ts';
import type { Intent } from '../components/Intent.ts'; import type { Intent } from '../components/Intent.ts';
@ -55,6 +56,7 @@ export class SkillSystem extends System {
if (skills) { if (skills) {
skills.setCooldown(skillId, skill.cooldown); 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;
}
}
}
}

View file

@ -4,12 +4,14 @@ export default defineConfig({
build: { build: {
minify: 'terser', minify: 'terser',
terserOptions: { terserOptions: {
ecma: 2020,
compress: { compress: {
drop_console: true, drop_console: true,
drop_debugger: true, drop_debugger: true,
}, },
mangle: { mangle: {
toplevel: true, toplevel: true,
properties: true,
}, },
format: { format: {
comments: false, comments: false,