Merge pull request 'Feature/Sound, mangling and minification' (#6) from Feature/Sound-mangling-and-minification into main
Reviewed-on: #6
This commit is contained in:
commit
71c8129f37
20 changed files with 1097 additions and 85 deletions
234
src/components/Music.ts
Normal file
234
src/components/Music.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
73
src/components/SoundEffects.ts
Normal file
73
src/components/SoundEffects.ts
Normal 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
156
src/config/MusicConfig.ts
Normal 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
35
src/config/SFXConfig.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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',
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@ export class Entity {
|
|||
private components: Map<string, Component>;
|
||||
active: boolean;
|
||||
|
||||
// Optional dynamic properties for specific entity types
|
||||
owner?: number;
|
||||
startX?: number;
|
||||
startY?: number;
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
269
src/core/Music.ts
Normal file
269
src/core/Music.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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<string, number[]> = {
|
||||
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<string, readonly number[]>([
|
||||
['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) {
|
||||
|
|
|
|||
49
src/main.ts
49
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<Music>(ComponentType.MUSIC);
|
||||
if (music) {
|
||||
(window as WindowWithGame).music = music;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -143,6 +143,8 @@ export class AbsorptionSystem extends System {
|
|||
vfxSystem.createAbsorption(entityPos.x, entityPos.y);
|
||||
}
|
||||
}
|
||||
|
||||
this.engine.emit(Events.ABSORPTION, { entity });
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
76
src/systems/MusicSystem.ts
Normal file
76
src/systems/MusicSystem.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)');
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
105
src/systems/SoundEffectsSystem.ts
Normal file
105
src/systems/SoundEffectsSystem.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue