From 143072f0a07e480fc37f9f871b26da3aec08d3a0 Mon Sep 17 00:00:00 2001 From: Juan Sebastian Montoya Date: Tue, 6 Jan 2026 22:40:19 -0500 Subject: [PATCH 1/8] feat: enhance minification and mangling support by switching FONT_DATA to a Map and updating Vite configuration for ECMAScript 2020 compatibility --- src/core/PixelFont.ts | 103 +++++++++++++++++++++--------------------- vite.config.js | 42 +++++++++-------- 2 files changed, 74 insertions(+), 71 deletions(-) diff --git a/src/core/PixelFont.ts b/src/core/PixelFont.ts index ea0463a..d895c87 100644 --- a/src/core/PixelFont.ts +++ b/src/core/PixelFont.ts @@ -1,57 +1,58 @@ /** * Simple 5x7 Matrix Pixel Font data. * Each character is represented by an array of 7 integers, where each integer is a 5-bit mask. + * Using Map for better minification/mangling support. */ -const FONT_DATA: Record = { - A: [0x0e, 0x11, 0x11, 0x1f, 0x11, 0x11, 0x11], - B: [0x1e, 0x11, 0x11, 0x1e, 0x11, 0x11, 0x1e], - C: [0x0e, 0x11, 0x11, 0x10, 0x11, 0x11, 0x0e], - D: [0x1e, 0x11, 0x11, 0x11, 0x11, 0x11, 0x1e], - E: [0x1f, 0x10, 0x10, 0x1e, 0x10, 0x10, 0x1f], - F: [0x1f, 0x10, 0x10, 0x1e, 0x10, 0x10, 0x10], - G: [0x0f, 0x10, 0x10, 0x17, 0x11, 0x11, 0x0f], - H: [0x11, 0x11, 0x11, 0x1f, 0x11, 0x11, 0x11], - I: [0x0e, 0x04, 0x04, 0x04, 0x04, 0x04, 0x0e], - J: [0x07, 0x02, 0x02, 0x02, 0x02, 0x12, 0x0c], - K: [0x11, 0x12, 0x14, 0x18, 0x14, 0x12, 0x11], - L: [0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x1f], - M: [0x11, 0x1b, 0x15, 0x15, 0x11, 0x11, 0x11], - N: [0x11, 0x11, 0x19, 0x15, 0x13, 0x11, 0x11], - O: [0x0e, 0x11, 0x11, 0x11, 0x11, 0x11, 0x0e], - P: [0x1e, 0x11, 0x11, 0x1e, 0x10, 0x10, 0x10], - Q: [0x0e, 0x11, 0x11, 0x11, 0x15, 0x12, 0x0d], - R: [0x1e, 0x11, 0x11, 0x1e, 0x14, 0x12, 0x11], - S: [0x0e, 0x11, 0x10, 0x0e, 0x01, 0x11, 0x0e], - T: [0x1f, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04], - U: [0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x0e], - V: [0x11, 0x11, 0x11, 0x11, 0x11, 0x0a, 0x04], - W: [0x11, 0x11, 0x11, 0x15, 0x15, 0x1b, 0x11], - X: [0x11, 0x11, 0x0a, 0x04, 0x0a, 0x11, 0x11], - Y: [0x11, 0x11, 0x0a, 0x04, 0x04, 0x04, 0x04], - Z: [0x1f, 0x01, 0x02, 0x04, 0x08, 0x10, 0x1f], - '0': [0x0e, 0x11, 0x13, 0x15, 0x19, 0x11, 0x0e], - '1': [0x04, 0x0c, 0x04, 0x04, 0x04, 0x04, 0x0e], - '2': [0x0e, 0x11, 0x01, 0x02, 0x04, 0x08, 0x1f], - '3': [0x1f, 0x02, 0x04, 0x02, 0x01, 0x11, 0x0e], - '4': [0x02, 0x06, 0x0a, 0x12, 0x1f, 0x02, 0x02], - '5': [0x1f, 0x10, 0x1e, 0x01, 0x01, 0x11, 0x0e], - '6': [0x06, 0x08, 0x10, 0x1e, 0x11, 0x11, 0x0e], - '7': [0x1f, 0x01, 0x02, 0x04, 0x08, 0x08, 0x08], - '8': [0x0e, 0x11, 0x11, 0x0e, 0x11, 0x11, 0x0e], - '9': [0x0e, 0x11, 0x11, 0x0f, 0x01, 0x02, 0x0c], - ':': [0x00, 0x04, 0x00, 0x00, 0x00, 0x04, 0x00], - '.': [0x00, 0x00, 0x00, 0x00, 0x00, 0x04, 0x00], - ',': [0x00, 0x00, 0x00, 0x00, 0x00, 0x04, 0x08], - '!': [0x04, 0x04, 0x04, 0x04, 0x04, 0x00, 0x04], - '?': [0x0e, 0x11, 0x01, 0x02, 0x04, 0x00, 0x04], - '+': [0x00, 0x04, 0x04, 0x1f, 0x04, 0x04, 0x00], - '-': [0x00, 0x00, 0x00, 0x1f, 0x00, 0x00, 0x00], - '/': [0x01, 0x02, 0x04, 0x08, 0x10, 0x10, 0x10], - '(': [0x02, 0x04, 0x04, 0x04, 0x04, 0x04, 0x02], - ')': [0x08, 0x04, 0x04, 0x04, 0x04, 0x04, 0x08], - ' ': [0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00], - '|': [0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04], -}; +const FONT_DATA = new Map([ + ['A', [0x0e, 0x11, 0x11, 0x1f, 0x11, 0x11, 0x11]], + ['B', [0x1e, 0x11, 0x11, 0x1e, 0x11, 0x11, 0x1e]], + ['C', [0x0e, 0x11, 0x11, 0x10, 0x11, 0x11, 0x0e]], + ['D', [0x1e, 0x11, 0x11, 0x11, 0x11, 0x11, 0x1e]], + ['E', [0x1f, 0x10, 0x10, 0x1e, 0x10, 0x10, 0x1f]], + ['F', [0x1f, 0x10, 0x10, 0x1e, 0x10, 0x10, 0x10]], + ['G', [0x0f, 0x10, 0x10, 0x17, 0x11, 0x11, 0x0f]], + ['H', [0x11, 0x11, 0x11, 0x1f, 0x11, 0x11, 0x11]], + ['I', [0x0e, 0x04, 0x04, 0x04, 0x04, 0x04, 0x0e]], + ['J', [0x07, 0x02, 0x02, 0x02, 0x02, 0x12, 0x0c]], + ['K', [0x11, 0x12, 0x14, 0x18, 0x14, 0x12, 0x11]], + ['L', [0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x1f]], + ['M', [0x11, 0x1b, 0x15, 0x15, 0x11, 0x11, 0x11]], + ['N', [0x11, 0x11, 0x19, 0x15, 0x13, 0x11, 0x11]], + ['O', [0x0e, 0x11, 0x11, 0x11, 0x11, 0x11, 0x0e]], + ['P', [0x1e, 0x11, 0x11, 0x1e, 0x10, 0x10, 0x10]], + ['Q', [0x0e, 0x11, 0x11, 0x11, 0x15, 0x12, 0x0d]], + ['R', [0x1e, 0x11, 0x11, 0x1e, 0x14, 0x12, 0x11]], + ['S', [0x0e, 0x11, 0x10, 0x0e, 0x01, 0x11, 0x0e]], + ['T', [0x1f, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04]], + ['U', [0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x0e]], + ['V', [0x11, 0x11, 0x11, 0x11, 0x11, 0x0a, 0x04]], + ['W', [0x11, 0x11, 0x11, 0x15, 0x15, 0x1b, 0x11]], + ['X', [0x11, 0x11, 0x0a, 0x04, 0x0a, 0x11, 0x11]], + ['Y', [0x11, 0x11, 0x0a, 0x04, 0x04, 0x04, 0x04]], + ['Z', [0x1f, 0x01, 0x02, 0x04, 0x08, 0x10, 0x1f]], + ['0', [0x0e, 0x11, 0x13, 0x15, 0x19, 0x11, 0x0e]], + ['1', [0x04, 0x0c, 0x04, 0x04, 0x04, 0x04, 0x0e]], + ['2', [0x0e, 0x11, 0x01, 0x02, 0x04, 0x08, 0x1f]], + ['3', [0x1f, 0x02, 0x04, 0x02, 0x01, 0x11, 0x0e]], + ['4', [0x02, 0x06, 0x0a, 0x12, 0x1f, 0x02, 0x02]], + ['5', [0x1f, 0x10, 0x1e, 0x01, 0x01, 0x11, 0x0e]], + ['6', [0x06, 0x08, 0x10, 0x1e, 0x11, 0x11, 0x0e]], + ['7', [0x1f, 0x01, 0x02, 0x04, 0x08, 0x08, 0x08]], + ['8', [0x0e, 0x11, 0x11, 0x0e, 0x11, 0x11, 0x0e]], + ['9', [0x0e, 0x11, 0x11, 0x0f, 0x01, 0x02, 0x0c]], + [':', [0x00, 0x04, 0x00, 0x00, 0x00, 0x04, 0x00]], + ['.', [0x00, 0x00, 0x00, 0x00, 0x00, 0x04, 0x00]], + [',', [0x00, 0x00, 0x00, 0x00, 0x00, 0x04, 0x08]], + ['!', [0x04, 0x04, 0x04, 0x04, 0x04, 0x00, 0x04]], + ['?', [0x0e, 0x11, 0x01, 0x02, 0x04, 0x00, 0x04]], + ['+', [0x00, 0x04, 0x04, 0x1f, 0x04, 0x04, 0x00]], + ['-', [0x00, 0x00, 0x00, 0x1f, 0x00, 0x00, 0x00]], + ['/', [0x01, 0x02, 0x04, 0x08, 0x10, 0x10, 0x10]], + ['(', [0x02, 0x04, 0x04, 0x04, 0x04, 0x04, 0x02]], + [')', [0x08, 0x04, 0x04, 0x04, 0x04, 0x04, 0x08]], + [' ', [0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]], + ['|', [0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04]], +]); /** * Utility class for rendering text using a custom pixel font. @@ -80,7 +81,7 @@ 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('?')!; for (let row = 0; row < 7; row++) { for (let col = 0; col < 5; col++) { if ((glyph[row] >> (4 - col)) & 1) { diff --git a/vite.config.js b/vite.config.js index 5f09b7e..3dfac46 100644 --- a/vite.config.js +++ b/vite.config.js @@ -1,25 +1,27 @@ import { defineConfig } from 'vite'; export default defineConfig({ - build: { - minify: 'terser', - terserOptions: { - compress: { - drop_console: true, - drop_debugger: true, - }, - mangle: { - toplevel: true, - }, - format: { - comments: false, - }, - }, - rollupOptions: { - output: { - manualChunks: undefined, - }, - }, - sourcemap: false, + build: { + minify: 'terser', + terserOptions: { + ecma: 2020, + compress: { + drop_console: true, + drop_debugger: true, + }, + mangle: { + toplevel: true, + properties: true, + }, + format: { + comments: false, + }, }, + rollupOptions: { + output: { + manualChunks: undefined, + }, + }, + sourcemap: false, + }, }); From 2213f64e60d8309f115db424cdaed2d7ffb04205 Mon Sep 17 00:00:00 2001 From: Juan Sebastian Montoya Date: Tue, 6 Jan 2026 23:25:33 -0500 Subject: [PATCH 2/8] feat: implement Music and SoundEffects systems for enhanced audio management, including background music and sound effects playback --- src/components/Music.ts | 88 ++++++++++ src/components/SoundEffects.ts | 73 ++++++++ src/core/Constants.ts | 4 + src/core/Entity.ts | 1 - src/core/EventBus.ts | 3 + src/core/Music.ts | 269 ++++++++++++++++++++++++++++++ src/core/PixelFont.ts | 3 +- src/main.ts | 119 ++++++++++++- src/skills/skills/WaterGun.ts | 7 + src/systems/AISystem.ts | 3 +- src/systems/AbsorptionSystem.ts | 2 + src/systems/MusicSystem.ts | 59 +++++++ src/systems/ProjectileSystem.ts | 6 + src/systems/RenderSystem.ts | 9 +- src/systems/SkillSystem.ts | 2 + src/systems/SoundEffectsSystem.ts | 105 ++++++++++++ 16 files changed, 739 insertions(+), 14 deletions(-) create mode 100644 src/components/Music.ts create mode 100644 src/components/SoundEffects.ts create mode 100644 src/core/Music.ts create mode 100644 src/systems/MusicSystem.ts create mode 100644 src/systems/SoundEffectsSystem.ts diff --git a/src/components/Music.ts b/src/components/Music.ts new file mode 100644 index 0000000..332aeab --- /dev/null +++ b/src/components/Music.ts @@ -0,0 +1,88 @@ +import { Component } from '../core/Component.ts'; +import { ComponentType } from '../core/Constants.ts'; +import type { Sequence } from '../core/Music.ts'; + +/** + * Component for managing background music and sound effects. + */ +export class Music extends Component { + sequences: Map; + currentSequence: Sequence | null; + volume: number; + enabled: boolean; + + constructor() { + super(ComponentType.MUSIC); + this.sequences = new Map(); + this.currentSequence = null; + this.volume = 0.5; + this.enabled = true; + } + + /** + * Add a music sequence. + * @param name - Unique identifier for the sequence + * @param sequence - The sequence instance + */ + addSequence(name: string, sequence: Sequence): void { + this.sequences.set(name, sequence); + if (sequence.gain) { + sequence.gain.gain.value = this.volume; + } + } + + /** + * Play a sequence by name. + * @param name - The sequence identifier + */ + playSequence(name: string): void { + if (!this.enabled) return; + + const sequence = this.sequences.get(name); + if (sequence) { + this.stop(); + this.currentSequence = sequence; + if (sequence.gain) { + sequence.gain.gain.value = this.volume; + } + sequence.play(); + } + } + + /** + * Stop current playback. + */ + stop(): void { + if (this.currentSequence) { + this.currentSequence.stop(); + this.currentSequence = null; + } + } + + /** + * Set the volume (0.0 to 1.0). + * @param volume - Volume level + */ + setVolume(volume: number): void { + this.volume = Math.max(0, Math.min(1, volume)); + if (this.currentSequence && this.currentSequence.gain) { + this.currentSequence.gain.gain.value = this.volume; + } + this.sequences.forEach((seq) => { + if (seq.gain) { + seq.gain.gain.value = this.volume; + } + }); + } + + /** + * Enable or disable music playback. + * @param enabled - Whether music should be enabled + */ + setEnabled(enabled: boolean): void { + this.enabled = enabled; + if (!enabled) { + this.stop(); + } + } +} diff --git a/src/components/SoundEffects.ts b/src/components/SoundEffects.ts new file mode 100644 index 0000000..c605140 --- /dev/null +++ b/src/components/SoundEffects.ts @@ -0,0 +1,73 @@ +import { Component } from '../core/Component.ts'; +import { ComponentType } from '../core/Constants.ts'; +import type { Sequence } from '../core/Music.ts'; + +/** + * Component for managing sound effects. + * Sound effects are short, one-shot audio sequences. + */ +export class SoundEffects extends Component { + sounds: Map; + volume: number; + enabled: boolean; + audioContext: AudioContext | null; + + constructor(audioContext?: AudioContext) { + super(ComponentType.SOUND_EFFECTS); + this.sounds = new Map(); + this.volume = 0.15; // Reduced default volume + this.enabled = true; + this.audioContext = audioContext || null; + } + + /** + * Add a sound effect sequence. + * @param name - Unique identifier for the sound + * @param sequence - The sequence instance (should be short, non-looping) + */ + addSound(name: string, sequence: Sequence): void { + sequence.loop = false; // SFX should never loop + this.sounds.set(name, sequence); + if (sequence.gain) { + sequence.gain.gain.value = this.volume; + } + } + + /** + * Play a sound effect by name. + * @param name - The sound identifier + */ + play(name: string): void { + if (!this.enabled) return; + + const sound = this.sounds.get(name); + if (sound) { + sound.stop(); + if (sound.gain) { + sound.gain.gain.value = this.volume; + } + sound.play(); + } + } + + /** + * Set the volume (0.0 to 1.0). + * @param volume - Volume level + */ + setVolume(volume: number): void { + this.volume = Math.max(0, Math.min(1, volume)); + this.sounds.forEach((seq) => { + if (seq.gain) { + seq.gain.gain.value = this.volume; + } + }); + } + + /** + * Enable or disable sound effects. + * @param enabled - Whether sound effects should be enabled + */ + setEnabled(enabled: boolean): void { + this.enabled = enabled; + } +} diff --git a/src/core/Constants.ts b/src/core/Constants.ts index 400c38e..c12a14f 100644 --- a/src/core/Constants.ts +++ b/src/core/Constants.ts @@ -30,6 +30,8 @@ export enum ComponentType { STEALTH = 'Stealth', INTENT = 'Intent', INVENTORY = 'Inventory', + MUSIC = 'Music', + SOUND_EFFECTS = 'SoundEffects', } /** @@ -79,4 +81,6 @@ export enum SystemName { SKILL = 'SkillSystem', STEALTH = 'StealthSystem', HEALTH_REGEN = 'HealthRegenerationSystem', + MUSIC = 'MusicSystem', + SOUND_EFFECTS = 'SoundEffectsSystem', } diff --git a/src/core/Entity.ts b/src/core/Entity.ts index dedd19a..ae39174 100644 --- a/src/core/Entity.ts +++ b/src/core/Entity.ts @@ -11,7 +11,6 @@ export class Entity { private components: Map; active: boolean; - // Optional dynamic properties for specific entity types owner?: number; startX?: number; startY?: number; diff --git a/src/core/EventBus.ts b/src/core/EventBus.ts index 7f4e935..6ae2906 100644 --- a/src/core/EventBus.ts +++ b/src/core/EventBus.ts @@ -11,6 +11,9 @@ export enum Events { SKILL_LEARNED = 'skills:learned', ATTACK_PERFORMED = 'combat:attack_performed', SKILL_COOLDOWN_STARTED = 'skills:cooldown_started', + ABSORPTION = 'absorption:absorbed', + PROJECTILE_CREATED = 'projectile:created', + PROJECTILE_IMPACT = 'projectile:impact', } /** diff --git a/src/core/Music.ts b/src/core/Music.ts new file mode 100644 index 0000000..656fbf3 --- /dev/null +++ b/src/core/Music.ts @@ -0,0 +1,269 @@ +/** + * Note class - represents a single musical note + */ +export class Note { + frequency: number; + duration: number; + + constructor(str: string) { + const couple = str.split(/\s+/); + this.frequency = Note.getFrequency(couple[0]) || 0; + this.duration = Note.getDuration(couple[1]) || 0; + } + + /** + * Convert a note name (e.g. 'A4') to a frequency (e.g. 440.00) + */ + static getFrequency(name: string): number { + const enharmonics = 'B#-C|C#-Db|D|D#-Eb|E-Fb|E#-F|F#-Gb|G|G#-Ab|A|A#-Bb|B-Cb'; + const middleC = 440 * Math.pow(Math.pow(2, 1 / 12), -9); + const octaveOffset = 4; + const num = /(\d+)/; + const offsets: Record = {}; + + enharmonics.split('|').forEach((val, i) => { + val.split('-').forEach((note) => { + offsets[note] = i; + }); + }); + + const couple = name.split(num); + const distance = offsets[couple[0]] ?? 0; + const octaveDiff = parseInt(couple[1] || String(octaveOffset), 10) - octaveOffset; + const freq = middleC * Math.pow(Math.pow(2, 1 / 12), distance); + return freq * Math.pow(2, octaveDiff); + } + + /** + * Convert a duration string (e.g. 'q') to a number (e.g. 1) + */ + static getDuration(symbol: string): number { + const numeric = /^[0-9.]+$/; + if (numeric.test(symbol)) { + return parseFloat(symbol); + } + return symbol + .toLowerCase() + .split('') + .reduce((prev, curr) => { + return ( + prev + + (curr === 'w' + ? 4 + : curr === 'h' + ? 2 + : curr === 'q' + ? 1 + : curr === 'e' + ? 0.5 + : curr === 's' + ? 0.25 + : 0) + ); + }, 0); + } +} + +/** + * Sequence class - manages playback of musical sequences + */ +export class Sequence { + ac: AudioContext; + tempo: number; + loop: boolean; + smoothing: number; + staccato: number; + notes: Note[]; + gain: GainNode; + bass: BiquadFilterNode | null; + mid: BiquadFilterNode | null; + treble: BiquadFilterNode | null; + waveType: OscillatorType | 'custom'; + customWave?: [Float32Array, Float32Array]; + osc: OscillatorNode | null; + + constructor(ac?: AudioContext, tempo = 120, arr?: (Note | string)[]) { + this.ac = ac || new AudioContext(); + this.tempo = tempo; + this.loop = true; + this.smoothing = 0; + this.staccato = 0; + this.notes = []; + this.bass = null; + this.mid = null; + this.treble = null; + this.osc = null; + this.waveType = 'square'; + this.gain = this.ac.createGain(); + this.createFxNodes(); + if (arr) { + this.push(...arr); + } + } + + /** + * Create gain and EQ nodes, then connect them + */ + createFxNodes(): void { + const eq: Array<[string, number]> = [ + ['bass', 100], + ['mid', 1000], + ['treble', 2500], + ]; + let prev: AudioNode = this.gain; + + eq.forEach((config) => { + const filter = this.ac.createBiquadFilter(); + filter.type = 'peaking'; + filter.frequency.value = config[1]; + prev.connect(filter); + prev = filter; + + if (config[0] === 'bass') { + this.bass = filter; + } else if (config[0] === 'mid') { + this.mid = filter; + } else if (config[0] === 'treble') { + this.treble = filter; + } + }); + + prev.connect(this.ac.destination); + } + + /** + * Accepts Note instances or strings (e.g. 'A4 e') + */ + push(...notes: (Note | string)[]): this { + notes.forEach((note) => { + this.notes.push(note instanceof Note ? note : new Note(note)); + }); + return this; + } + + /** + * Create a custom waveform + */ + createCustomWave(real: number[], imag?: number[]): void { + if (!imag) { + imag = real; + } + this.waveType = 'custom'; + this.customWave = [new Float32Array(real), new Float32Array(imag)]; + } + + /** + * Recreate the oscillator node (happens on every play) + */ + createOscillator(): this { + this.stop(); + this.osc = this.ac.createOscillator(); + + if (this.customWave) { + this.osc.setPeriodicWave(this.ac.createPeriodicWave(this.customWave[0], this.customWave[1])); + } else { + this.osc.type = this.waveType === 'custom' ? 'square' : this.waveType; + } + + if (this.gain) { + this.osc.connect(this.gain); + } + return this; + } + + /** + * Schedule a note to play at the given time + */ + scheduleNote(index: number, when: number): number { + const duration = (60 / this.tempo) * this.notes[index].duration; + const cutoff = duration * (1 - (this.staccato || 0)); + + this.setFrequency(this.notes[index].frequency, when); + + if (this.smoothing && this.notes[index].frequency) { + this.slide(index, when, cutoff); + } + + this.setFrequency(0, when + cutoff); + return when + duration; + } + + /** + * Get the next note + */ + getNextNote(index: number): Note { + return this.notes[index < this.notes.length - 1 ? index + 1 : 0]; + } + + /** + * How long do we wait before beginning the slide? + */ + getSlideStartDelay(duration: number): number { + return duration - Math.min(duration, (60 / this.tempo) * this.smoothing); + } + + /** + * Slide the note at index into the next note + */ + slide(index: number, when: number, cutoff: number): this { + const next = this.getNextNote(index); + const start = this.getSlideStartDelay(cutoff); + this.setFrequency(this.notes[index].frequency, when + start); + this.rampFrequency(next.frequency, when + cutoff); + return this; + } + + /** + * Set frequency at time + */ + setFrequency(freq: number, when: number): this { + if (this.osc) { + this.osc.frequency.setValueAtTime(freq, when); + } + return this; + } + + /** + * Ramp to frequency at time + */ + rampFrequency(freq: number, when: number): this { + if (this.osc) { + this.osc.frequency.linearRampToValueAtTime(freq, when); + } + return this; + } + + /** + * Run through all notes in the sequence and schedule them + */ + play(when?: number): this { + const startTime = typeof when === 'number' ? when : this.ac.currentTime; + + this.createOscillator(); + if (this.osc) { + this.osc.start(startTime); + + let currentTime = startTime; + this.notes.forEach((_note, i) => { + currentTime = this.scheduleNote(i, currentTime); + }); + + this.osc.stop(currentTime); + this.osc.onended = this.loop ? () => this.play(currentTime) : null; + } + + return this; + } + + /** + * Stop playback + */ + stop(): this { + if (this.osc) { + this.osc.onended = null; + this.osc.disconnect(); + this.osc = null; + } + return this; + } +} diff --git a/src/core/PixelFont.ts b/src/core/PixelFont.ts index d895c87..d4cd05c 100644 --- a/src/core/PixelFont.ts +++ b/src/core/PixelFont.ts @@ -81,7 +81,8 @@ export const PixelFont = { const chars = text.toUpperCase().split(''); chars.forEach((char) => { - const glyph = FONT_DATA.get(char) || FONT_DATA.get('?')!; + const glyph = FONT_DATA.get(char) || FONT_DATA.get('?'); + if (!glyph) return; for (let row = 0; row < 7; row++) { for (let col = 0; col < 5; col++) { if ((glyph[row] >> (4 - col)) & 1) { diff --git a/src/main.ts b/src/main.ts index 61cd138..3c0aed4 100644 --- a/src/main.ts +++ b/src/main.ts @@ -15,6 +15,8 @@ import { MenuSystem } from './systems/MenuSystem.ts'; import { RenderSystem } from './systems/RenderSystem.ts'; import { UISystem } from './systems/UISystem.ts'; import { VFXSystem } from './systems/VFXSystem.ts'; +import { MusicSystem } from './systems/MusicSystem.ts'; +import { SoundEffectsSystem } from './systems/SoundEffectsSystem.ts'; import { Position } from './components/Position.ts'; import { Velocity } from './components/Velocity.ts'; @@ -30,8 +32,11 @@ import { AI } from './components/AI.ts'; import { Absorbable } from './components/Absorbable.ts'; import { SkillProgress } from './components/SkillProgress.ts'; import { Intent } from './components/Intent.ts'; +import { Music } from './components/Music.ts'; +import { SoundEffects } from './components/SoundEffects.ts'; import { EntityType, ComponentType } from './core/Constants.ts'; +import { Sequence } from './core/Music.ts'; import type { Entity } from './core/Entity.ts'; const canvas = document.getElementById('game-canvas') as HTMLCanvasElement; @@ -42,6 +47,8 @@ if (!canvas) { engine.addSystem(new MenuSystem(engine)); engine.addSystem(new InputSystem()); + engine.addSystem(new MusicSystem()); + engine.addSystem(new SoundEffectsSystem()); engine.addSystem(new PlayerControllerSystem()); engine.addSystem(new StealthSystem()); engine.addSystem(new AISystem()); @@ -143,17 +150,121 @@ if (!canvas) { } }, 5000); + const musicSystem = engine.systems.find((s) => s.name === 'MusicSystem') as + | MusicSystem + | undefined; + if (musicSystem) { + const musicEntity = engine.createEntity(); + const music = new Music(); + const ac = musicSystem.getAudioContext(); + + const bgMusic = new Sequence(ac, 140, [ + 'C4 e', + 'E4 e', + 'G4 q', + 'C5 e', + 'G4 e', + 'E4 q', + 'A3 e', + 'C4 e', + 'E4 q', + 'A4 e', + 'E4 e', + 'C4 q', + 'F3 e', + 'A3 e', + 'C4 q', + 'F4 e', + 'C4 e', + 'A3 q', + 'G3 e', + 'B3 e', + 'D4 q', + 'G4 e', + 'D4 e', + 'B3 q', + ]); + bgMusic.loop = true; + bgMusic.staccato = 0.2; + bgMusic.smoothing = 0.4; + bgMusic.waveType = 'triangle'; + music.addSequence('background', bgMusic); + music.setVolume(0.02); + musicEntity.addComponent(music); + + canvas.addEventListener('click', () => { + musicSystem.resumeAudioContext(); + if (music.enabled && !music.currentSequence) { + music.playSequence('background'); + } + canvas.focus(); + }); + + setTimeout(() => { + musicSystem.resumeAudioContext(); + if (music.enabled) { + music.playSequence('background'); + } + }, 1000); + + const sfxSystem = engine.systems.find((s) => s.name === 'SoundEffectsSystem') as + | SoundEffectsSystem + | undefined; + if (sfxSystem) { + const sfxEntity = engine.createEntity(); + const sfx = new SoundEffects(ac); + + const attackSound = new Sequence(ac, 120, ['C5 s']); + attackSound.staccato = 0.8; + sfx.addSound('attack', attackSound); + + const absorbSound = new Sequence(ac, 120, ['G4 e']); + absorbSound.staccato = 0.5; + sfx.addSound('absorb', absorbSound); + + const skillSound = new Sequence(ac, 120, ['A4 e']); + skillSound.staccato = 0.6; + sfx.addSound('skill', skillSound); + + const damageSound = new Sequence(ac, 120, ['F4 s']); + damageSound.staccato = 0.8; + sfx.addSound('damage', damageSound); + + const shootSound = new Sequence(ac, 120, ['C5 s']); + shootSound.staccato = 0.9; + sfx.addSound('shoot', shootSound); + + const impactSound = new Sequence(ac, 120, ['G4 s']); + impactSound.staccato = 0.7; + sfx.addSound('impact', impactSound); + + sfx.setVolume(0.02); + + sfxEntity.addComponent(sfx); + } + } else { + canvas.addEventListener('click', () => { + canvas.focus(); + }); + } + canvas.focus(); engine.start(); interface WindowWithGame { gameEngine?: Engine; player?: Entity; + music?: Music; } (window as WindowWithGame).gameEngine = engine; (window as WindowWithGame).player = player; - - canvas.addEventListener('click', () => { - canvas.focus(); - }); + if (musicSystem) { + const musicEntity = engine.getEntities().find((e) => e.hasComponent(ComponentType.MUSIC)); + if (musicEntity) { + const music = musicEntity.getComponent(ComponentType.MUSIC); + if (music) { + (window as WindowWithGame).music = music; + } + } + } } diff --git a/src/skills/skills/WaterGun.ts b/src/skills/skills/WaterGun.ts index 4572c5f..944c472 100644 --- a/src/skills/skills/WaterGun.ts +++ b/src/skills/skills/WaterGun.ts @@ -1,5 +1,6 @@ import { Skill } from '../Skill.ts'; import { ComponentType, SystemName, EntityType } from '../../core/Constants.ts'; +import { Events } from '../../core/EventBus.ts'; import { Position } from '../../components/Position.ts'; import { Velocity } from '../../components/Velocity.ts'; import { Sprite } from '../../components/Sprite.ts'; @@ -89,6 +90,12 @@ export class SlimeGun extends Skill { projectile.maxRange = this.range; projectile.lifetime = this.range / this.speed + 1.0; + engine.emit(Events.PROJECTILE_CREATED, { + x: startX, + y: startY, + angle: shootAngle, + }); + return true; } } diff --git a/src/systems/AISystem.ts b/src/systems/AISystem.ts index a38b394..15a3744 100644 --- a/src/systems/AISystem.ts +++ b/src/systems/AISystem.ts @@ -33,7 +33,8 @@ export class AISystem extends System { const playerController = this.engine.systems.find( (s) => s.name === SystemName.PLAYER_CONTROLLER ) as PlayerControllerSystem | undefined; - const player = playerController ? playerController.getPlayerEntity() : null; + const player = playerController?.getPlayerEntity(); + if (!player) return; const playerPos = player?.getComponent(ComponentType.POSITION); const config = GameConfig.AI; diff --git a/src/systems/AbsorptionSystem.ts b/src/systems/AbsorptionSystem.ts index fd57995..e7af650 100644 --- a/src/systems/AbsorptionSystem.ts +++ b/src/systems/AbsorptionSystem.ts @@ -143,6 +143,8 @@ export class AbsorptionSystem extends System { vfxSystem.createAbsorption(entityPos.x, entityPos.y); } } + + this.engine.emit(Events.ABSORPTION, { entity }); } /** diff --git a/src/systems/MusicSystem.ts b/src/systems/MusicSystem.ts new file mode 100644 index 0000000..529c319 --- /dev/null +++ b/src/systems/MusicSystem.ts @@ -0,0 +1,59 @@ +import { System } from '../core/System.ts'; +import { SystemName, ComponentType } from '../core/Constants.ts'; +import type { Entity } from '../core/Entity.ts'; +import type { Engine } from '../core/Engine.ts'; +import type { Music } from '../components/Music.ts'; + +/** + * System responsible for managing background music playback. + */ +export class MusicSystem extends System { + private audioContext: AudioContext | null; + + constructor() { + super(SystemName.MUSIC); + this.requiredComponents = [ComponentType.MUSIC]; + this.priority = 5; + this.audioContext = null; + } + + /** + * Initialize the audio context when system is added to engine. + */ + init(engine: Engine): void { + super.init(engine); + } + + /** + * Process music entities - currently just ensures audio context exists. + */ + process(_deltaTime: number, entities: Entity[]): void { + entities.forEach((entity) => { + const music = entity.getComponent(ComponentType.MUSIC); + if (!music) return; + + if (!this.audioContext) { + this.audioContext = new AudioContext(); + } + }); + } + + /** + * Get or create the shared audio context. + */ + getAudioContext(): AudioContext { + if (!this.audioContext) { + this.audioContext = new AudioContext(); + } + return this.audioContext; + } + + /** + * Resume audio context (required after user interaction). + */ + resumeAudioContext(): void { + if (this.audioContext && this.audioContext.state === 'suspended') { + this.audioContext.resume(); + } + } +} diff --git a/src/systems/ProjectileSystem.ts b/src/systems/ProjectileSystem.ts index 4c92f48..c9d91f4 100644 --- a/src/systems/ProjectileSystem.ts +++ b/src/systems/ProjectileSystem.ts @@ -75,6 +75,12 @@ export class ProjectileSystem extends System { if (targetHealthComp) { targetHealthComp.takeDamage(damage); + this.engine.emit(Events.PROJECTILE_IMPACT, { + x: position.x, + y: position.y, + damage, + }); + const vfxSystem = this.engine.systems.find((s) => s.name === SystemName.VFX) as | VFXSystem | undefined; diff --git a/src/systems/RenderSystem.ts b/src/systems/RenderSystem.ts index d9656cd..b989c22 100644 --- a/src/systems/RenderSystem.ts +++ b/src/systems/RenderSystem.ts @@ -179,7 +179,6 @@ export class RenderSystem extends System { let frames = spriteData[sprite.animationState as string] || spriteData[AnimationState.IDLE]; if (!frames || !Array.isArray(frames)) { - // Fallback to default slime animation if data structure is unexpected frames = SpriteLibrary[EntityType.SLIME][AnimationState.IDLE]; } @@ -414,28 +413,24 @@ export class RenderSystem extends System { */ drawGlowEffect(sprite: Sprite): void { const ctx = this.ctx; - const time = Date.now() * 0.001; // Time in seconds for pulsing - const pulse = 0.5 + Math.sin(time * 3) * 0.3; // Pulsing between 0.2 and 0.8 + const time = Date.now() * 0.001; + const pulse = 0.5 + Math.sin(time * 3) * 0.3; const baseRadius = Math.max(sprite.width, sprite.height) / 2; const glowRadius = baseRadius + 4 + pulse * 2; - // Create radial gradient for soft glow const gradient = ctx.createRadialGradient(0, 0, baseRadius, 0, 0, glowRadius); gradient.addColorStop(0, `rgba(255, 255, 255, ${0.4 * pulse})`); gradient.addColorStop(0.5, `rgba(0, 230, 255, ${0.3 * pulse})`); gradient.addColorStop(1, 'rgba(0, 230, 255, 0)'); - // Draw multiple layers for a softer glow effect ctx.save(); ctx.globalCompositeOperation = 'screen'; - // Outer glow layer ctx.fillStyle = gradient; ctx.beginPath(); ctx.arc(0, 0, glowRadius, 0, Math.PI * 2); ctx.fill(); - // Inner bright core const innerGradient = ctx.createRadialGradient(0, 0, 0, 0, 0, baseRadius * 0.6); innerGradient.addColorStop(0, `rgba(255, 255, 255, ${0.6 * pulse})`); innerGradient.addColorStop(1, 'rgba(0, 230, 255, 0)'); diff --git a/src/systems/SkillSystem.ts b/src/systems/SkillSystem.ts index 6df2a26..107130c 100644 --- a/src/systems/SkillSystem.ts +++ b/src/systems/SkillSystem.ts @@ -1,6 +1,7 @@ import { System } from '../core/System.ts'; import { SkillRegistry } from '../skills/SkillRegistry.ts'; import { SystemName, ComponentType } from '../core/Constants.ts'; +import { Events } from '../core/EventBus.ts'; import type { Entity } from '../core/Entity.ts'; import type { Skills } from '../components/Skills.ts'; import type { Intent } from '../components/Intent.ts'; @@ -55,6 +56,7 @@ export class SkillSystem extends System { if (skills) { skills.setCooldown(skillId, skill.cooldown); } + this.engine.emit(Events.SKILL_COOLDOWN_STARTED, { skillId }); } } } diff --git a/src/systems/SoundEffectsSystem.ts b/src/systems/SoundEffectsSystem.ts new file mode 100644 index 0000000..bd4c753 --- /dev/null +++ b/src/systems/SoundEffectsSystem.ts @@ -0,0 +1,105 @@ +import { System } from '../core/System.ts'; +import { SystemName, ComponentType } from '../core/Constants.ts'; +import { Events } from '../core/EventBus.ts'; +import type { Entity } from '../core/Entity.ts'; +import type { Engine } from '../core/Engine.ts'; +import type { SoundEffects } from '../components/SoundEffects.ts'; +import type { MusicSystem } from './MusicSystem.ts'; + +/** + * System responsible for managing sound effects playback. + * Follows ECS pattern: system processes entities with SoundEffects component. + * Listens to game events and plays appropriate sound effects. + */ +export class SoundEffectsSystem extends System { + private sfxEntity: Entity | null; + + constructor() { + super(SystemName.SOUND_EFFECTS); + this.requiredComponents = [ComponentType.SOUND_EFFECTS]; + this.priority = 5; + this.sfxEntity = null; + } + + /** + * Initialize event listeners when system is added to engine. + */ + init(engine: Engine): void { + super.init(engine); + + this.engine.on(Events.ATTACK_PERFORMED, () => { + this.playSound('attack'); + }); + + this.engine.on(Events.DAMAGE_DEALT, () => { + this.playSound('damage'); + }); + + this.engine.on(Events.ABSORPTION, () => { + this.playSound('absorb'); + }); + + this.engine.on(Events.SKILL_LEARNED, () => { + this.playSound('skill'); + }); + + this.engine.on(Events.SKILL_COOLDOWN_STARTED, () => { + this.playSound('skill'); + }); + + this.engine.on(Events.PROJECTILE_CREATED, () => { + this.playSound('shoot'); + }); + + this.engine.on(Events.PROJECTILE_IMPACT, () => { + this.playSound('impact'); + }); + } + + /** + * Process sound effect entities - ensures audio context is available. + */ + process(_deltaTime: number, entities: Entity[]): void { + entities.forEach((entity) => { + const sfx = entity.getComponent(ComponentType.SOUND_EFFECTS); + if (!sfx) return; + + if (!this.sfxEntity) { + this.sfxEntity = entity; + } + + if (!sfx.audioContext) { + const musicSystem = this.engine.systems.find((s) => s.name === SystemName.MUSIC) as + | MusicSystem + | undefined; + if (musicSystem) { + sfx.audioContext = musicSystem.getAudioContext(); + } + } + }); + } + + /** + * Play a sound effect from any entity with SoundEffects component. + * @param soundName - The name of the sound to play + */ + playSound(soundName: string): void { + if (this.sfxEntity) { + const sfx = this.sfxEntity.getComponent(ComponentType.SOUND_EFFECTS); + if (sfx) { + sfx.play(soundName); + return; + } + } + + const entities = this.engine.getEntities(); + for (const entity of entities) { + const sfx = entity.getComponent(ComponentType.SOUND_EFFECTS); + if (sfx) { + this.sfxEntity = entity; + sfx.play(soundName); + break; + } + } + } +} From 5a24d6a2af299c3909bcde61b5499746edc1211b Mon Sep 17 00:00:00 2001 From: Juan Sebastian Montoya Date: Tue, 6 Jan 2026 23:58:26 -0500 Subject: [PATCH 3/8] feat: refactor audio management by introducing setup functions for music and sound effects, enhancing modularity and maintainability --- src/components/Music.ts | 146 ++++++++++++++++++++++++++++++++ src/config/MusicConfig.ts | 167 +++++++++++++++++++++++++++++++++++++ src/config/SFXConfig.ts | 35 ++++++++ src/core/Engine.ts | 2 +- src/main.ts | 84 ++----------------- src/systems/MusicSystem.ts | 21 ++++- 6 files changed, 375 insertions(+), 80 deletions(-) create mode 100644 src/config/MusicConfig.ts create mode 100644 src/config/SFXConfig.ts diff --git a/src/components/Music.ts b/src/components/Music.ts index 332aeab..35b9621 100644 --- a/src/components/Music.ts +++ b/src/components/Music.ts @@ -8,15 +8,23 @@ import type { Sequence } from '../core/Music.ts'; export class Music extends Component { sequences: Map; currentSequence: Sequence | null; + activeSequences: Set; volume: number; enabled: boolean; + private sequenceChain: string[]; + private currentChainIndex: number; + private sequenceVolumes: Map; constructor() { super(ComponentType.MUSIC); this.sequences = new Map(); this.currentSequence = null; + this.activeSequences = new Set(); this.volume = 0.5; this.enabled = true; + this.sequenceChain = []; + this.currentChainIndex = 0; + this.sequenceVolumes = new Map(); } /** @@ -49,10 +57,91 @@ export class Music extends Component { } } + /** + * 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; @@ -85,4 +174,61 @@ export class Music extends Component { this.stop(); } } + + /** + * Pause all active sequences by setting their gain to 0. + */ + pause(): void { + this.activeSequences.forEach((seq) => { + if (seq.gain) { + const currentVolume = seq.gain.gain.value; + this.sequenceVolumes.set(seq, currentVolume); + seq.gain.gain.value = 0; + } + }); + if (this.currentSequence && this.currentSequence.gain) { + const currentVolume = this.currentSequence.gain.gain.value; + this.sequenceVolumes.set(this.currentSequence, currentVolume); + this.currentSequence.gain.gain.value = 0; + } + this.sequences.forEach((seq) => { + if (seq.gain && seq.gain.gain.value > 0) { + const currentVolume = seq.gain.gain.value; + this.sequenceVolumes.set(seq, currentVolume); + seq.gain.gain.value = 0; + } + }); + } + + /** + * Resume all active sequences by restoring their volume. + */ + resume(): void { + this.activeSequences.forEach((seq) => { + if (seq.gain) { + const savedVolume = this.sequenceVolumes.get(seq); + if (savedVolume !== undefined) { + seq.gain.gain.value = savedVolume; + } else { + seq.gain.gain.value = this.volume; + } + } + }); + if (this.currentSequence && this.currentSequence.gain) { + const savedVolume = this.sequenceVolumes.get(this.currentSequence); + if (savedVolume !== undefined) { + this.currentSequence.gain.gain.value = savedVolume; + } else { + this.currentSequence.gain.gain.value = this.volume; + } + } + this.sequences.forEach((seq) => { + if (seq.gain) { + const savedVolume = this.sequenceVolumes.get(seq); + if (savedVolume !== undefined) { + seq.gain.gain.value = savedVolume; + } + } + }); + } } diff --git a/src/config/MusicConfig.ts b/src/config/MusicConfig.ts new file mode 100644 index 0000000..dbf8863 --- /dev/null +++ b/src/config/MusicConfig.ts @@ -0,0 +1,167 @@ +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(); + }); + + setTimeout(() => { + musicSystem.resumeAudioContext(); + if (music.enabled) { + music.playSequences([ + { name: 'lead', loop: true }, + { name: 'harmony', loop: true }, + { name: 'bass', loop: true }, + ]); + } + }, 1000); +} diff --git a/src/config/SFXConfig.ts b/src/config/SFXConfig.ts new file mode 100644 index 0000000..0ade875 --- /dev/null +++ b/src/config/SFXConfig.ts @@ -0,0 +1,35 @@ +import { Sequence } from '../core/Music.ts'; +import type { SoundEffects } from '../components/SoundEffects.ts'; + +/** + * Configure and setup sound effects. + * @param sfx - SoundEffects component instance + * @param ac - AudioContext instance + */ +export function setupSFX(sfx: SoundEffects, ac: AudioContext): void { + const attackSound = new Sequence(ac, 120, ['C5 s']); + attackSound.staccato = 0.8; + sfx.addSound('attack', attackSound); + + const absorbSound = new Sequence(ac, 120, ['G4 e']); + absorbSound.staccato = 0.5; + sfx.addSound('absorb', absorbSound); + + const skillSound = new Sequence(ac, 120, ['A4 e']); + skillSound.staccato = 0.6; + sfx.addSound('skill', skillSound); + + const damageSound = new Sequence(ac, 120, ['F4 s']); + damageSound.staccato = 0.8; + sfx.addSound('damage', damageSound); + + const shootSound = new Sequence(ac, 120, ['C5 s']); + shootSound.staccato = 0.9; + sfx.addSound('shoot', shootSound); + + const impactSound = new Sequence(ac, 120, ['G4 s']); + impactSound.staccato = 0.7; + sfx.addSound('impact', impactSound); + + sfx.setVolume(0.02); +} diff --git a/src/core/Engine.ts b/src/core/Engine.ts index 2010c0e..d795cb3 100644 --- a/src/core/Engine.ts +++ b/src/core/Engine.ts @@ -143,7 +143,7 @@ export class Engine { | undefined; const gameState = menuSystem ? menuSystem.getGameState() : GameState.PLAYING; const isPaused = [GameState.PAUSED, GameState.START, GameState.GAME_OVER].includes(gameState); - const unskippedSystems = [SystemName.MENU, SystemName.UI, SystemName.RENDER]; + const unskippedSystems = [SystemName.MENU, SystemName.UI, SystemName.RENDER, SystemName.MUSIC]; this.systems.forEach((system) => { if (isPaused && !unskippedSystems.includes(system.name as SystemName)) { diff --git a/src/main.ts b/src/main.ts index 3c0aed4..9971c67 100644 --- a/src/main.ts +++ b/src/main.ts @@ -36,8 +36,9 @@ import { Music } from './components/Music.ts'; import { SoundEffects } from './components/SoundEffects.ts'; import { EntityType, ComponentType } from './core/Constants.ts'; -import { Sequence } from './core/Music.ts'; import type { Entity } from './core/Entity.ts'; +import { setupMusic, setupMusicHandlers } from './config/MusicConfig.ts'; +import { setupSFX } from './config/SFXConfig.ts'; const canvas = document.getElementById('game-canvas') as HTMLCanvasElement; if (!canvas) { @@ -156,90 +157,19 @@ if (!canvas) { if (musicSystem) { const musicEntity = engine.createEntity(); const music = new Music(); - const ac = musicSystem.getAudioContext(); + const audioCtx = musicSystem.getAudioContext(); - const bgMusic = new Sequence(ac, 140, [ - 'C4 e', - 'E4 e', - 'G4 q', - 'C5 e', - 'G4 e', - 'E4 q', - 'A3 e', - 'C4 e', - 'E4 q', - 'A4 e', - 'E4 e', - 'C4 q', - 'F3 e', - 'A3 e', - 'C4 q', - 'F4 e', - 'C4 e', - 'A3 q', - 'G3 e', - 'B3 e', - 'D4 q', - 'G4 e', - 'D4 e', - 'B3 q', - ]); - bgMusic.loop = true; - bgMusic.staccato = 0.2; - bgMusic.smoothing = 0.4; - bgMusic.waveType = 'triangle'; - music.addSequence('background', bgMusic); - music.setVolume(0.02); + setupMusic(music, audioCtx); musicEntity.addComponent(music); - - canvas.addEventListener('click', () => { - musicSystem.resumeAudioContext(); - if (music.enabled && !music.currentSequence) { - music.playSequence('background'); - } - canvas.focus(); - }); - - setTimeout(() => { - musicSystem.resumeAudioContext(); - if (music.enabled) { - music.playSequence('background'); - } - }, 1000); + 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(ac); - - const attackSound = new Sequence(ac, 120, ['C5 s']); - attackSound.staccato = 0.8; - sfx.addSound('attack', attackSound); - - const absorbSound = new Sequence(ac, 120, ['G4 e']); - absorbSound.staccato = 0.5; - sfx.addSound('absorb', absorbSound); - - const skillSound = new Sequence(ac, 120, ['A4 e']); - skillSound.staccato = 0.6; - sfx.addSound('skill', skillSound); - - const damageSound = new Sequence(ac, 120, ['F4 s']); - damageSound.staccato = 0.8; - sfx.addSound('damage', damageSound); - - const shootSound = new Sequence(ac, 120, ['C5 s']); - shootSound.staccato = 0.9; - sfx.addSound('shoot', shootSound); - - const impactSound = new Sequence(ac, 120, ['G4 s']); - impactSound.staccato = 0.7; - sfx.addSound('impact', impactSound); - - sfx.setVolume(0.02); - + const sfx = new SoundEffects(audioCtx); + setupSFX(sfx, audioCtx); sfxEntity.addComponent(sfx); } } else { diff --git a/src/systems/MusicSystem.ts b/src/systems/MusicSystem.ts index 529c319..439d31c 100644 --- a/src/systems/MusicSystem.ts +++ b/src/systems/MusicSystem.ts @@ -1,20 +1,23 @@ import { System } from '../core/System.ts'; -import { SystemName, ComponentType } from '../core/Constants.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; } /** @@ -25,9 +28,15 @@ export class MusicSystem extends System { } /** - * Process music entities - currently just ensures audio context exists. + * Process music entities - ensures audio context exists and handles pause/resume. */ process(_deltaTime: number, entities: Entity[]): void { + const menuSystem = this.engine.systems.find((s) => s.name === SystemName.MENU) as + | MenuSystem + | undefined; + const gameState = menuSystem ? menuSystem.getGameState() : GameState.PLAYING; + const isPaused = gameState === GameState.PAUSED; + entities.forEach((entity) => { const music = entity.getComponent(ComponentType.MUSIC); if (!music) return; @@ -35,6 +44,14 @@ export class MusicSystem extends System { 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; + } }); } From 66719912ba360329ca734201f49638efe1289e36 Mon Sep 17 00:00:00 2001 From: Juan Sebastian Montoya Date: Wed, 7 Jan 2026 00:03:50 -0500 Subject: [PATCH 4/8] refactor: remove unused audio playback logic from setupMusicHandlers, streamlining music configuration --- src/config/MusicConfig.ts | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/src/config/MusicConfig.ts b/src/config/MusicConfig.ts index dbf8863..e1798b2 100644 --- a/src/config/MusicConfig.ts +++ b/src/config/MusicConfig.ts @@ -153,15 +153,4 @@ export function setupMusicHandlers( } canvas.focus(); }); - - setTimeout(() => { - musicSystem.resumeAudioContext(); - if (music.enabled) { - music.playSequences([ - { name: 'lead', loop: true }, - { name: 'harmony', loop: true }, - { name: 'bass', loop: true }, - ]); - } - }, 1000); } From b32ac22be848cae48edead5a92a2b39d209c5c2b Mon Sep 17 00:00:00 2001 From: forgebot Date: Wed, 7 Jan 2026 05:04:22 +0000 Subject: [PATCH 5/8] chore: update version to 0.4.0 [skip ci] --- VERSION | 2 +- portainer.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/VERSION b/VERSION index 0d91a54..1d0ba9e 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.3.0 +0.4.0 diff --git a/portainer.yml b/portainer.yml index eb86fcf..d2a3e11 100644 --- a/portainer.yml +++ b/portainer.yml @@ -2,7 +2,7 @@ name: slime services: app: - image: git.jusemon.com/jusemon/slime:0.3.0 + image: git.jusemon.com/jusemon/slime:0.4.0 restart: unless-stopped networks: From 62e58f77ae055e6eacab5479d91bf6bdc49d3b57 Mon Sep 17 00:00:00 2001 From: Juan Sebastian Montoya Date: Wed, 7 Jan 2026 01:25:53 -0500 Subject: [PATCH 6/8] chore: add eslint-config-prettier to ESLint configuration for improved formatting compatibility --- eslint.config.js | 3 ++- package-lock.json | 19 +++++++++++++++++-- package.json | 1 + 3 files changed, 20 insertions(+), 3 deletions(-) diff --git a/eslint.config.js b/eslint.config.js index 87f9419..4165506 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -1,10 +1,12 @@ import js from '@eslint/js'; import globals from 'globals'; import tseslint from 'typescript-eslint'; +import prettier from 'eslint-config-prettier'; export default [ js.configs.recommended, ...tseslint.configs.recommended, + prettier, { languageOptions: { ecmaVersion: 2022, @@ -27,7 +29,6 @@ export default [ ], '@typescript-eslint/no-explicit-any': 'warn', 'no-console': 'off', - indent: ['error', 2], '@typescript-eslint/explicit-module-boundary-types': 'off', '@typescript-eslint/no-non-null-assertion': 'warn', }, diff --git a/package-lock.json b/package-lock.json index c187459..c895d29 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,12 +7,12 @@ "": { "name": "slime-genesis-poc", "version": "0.1.0", - "hasInstallScript": true, "devDependencies": { "@eslint/js": "^9.39.2", "@typescript-eslint/eslint-plugin": "^8.52.0", "@typescript-eslint/parser": "^8.52.0", "eslint": "^9.39.2", + "eslint-config-prettier": "^10.1.8", "globals": "^17.0.0", "husky": "^9.1.7", "lint-staged": "^16.2.7", @@ -1753,6 +1753,22 @@ } } }, + "node_modules/eslint-config-prettier": { + "version": "10.1.8", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz", + "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", + "dev": true, + "license": "MIT", + "bin": { + "eslint-config-prettier": "bin/cli.js" + }, + "funding": { + "url": "https://opencollective.com/eslint-config-prettier" + }, + "peerDependencies": { + "eslint": ">=7.0.0" + } + }, "node_modules/eslint-scope": { "version": "8.4.0", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", @@ -3120,7 +3136,6 @@ "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", "dev": true, "license": "ISC", - "peer": true, "bin": { "yaml": "bin.mjs" }, diff --git a/package.json b/package.json index 4c6fc04..514ccca 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "@typescript-eslint/eslint-plugin": "^8.52.0", "@typescript-eslint/parser": "^8.52.0", "eslint": "^9.39.2", + "eslint-config-prettier": "^10.1.8", "globals": "^17.0.0", "husky": "^9.1.7", "lint-staged": "^16.2.7", From c859e20ffcd132231a8584d52417ed7c58c95eef Mon Sep 17 00:00:00 2001 From: Juan Sebastian Montoya Date: Wed, 7 Jan 2026 01:25:56 -0500 Subject: [PATCH 7/8] feat: implement Camera system and component for improved viewport management and player tracking --- src/components/Camera.ts | 58 +++++++++++++++ src/components/Stealth.ts | 22 +++++- src/core/ColorSampler.ts | 102 ++++++++++++++++++++++++++ src/core/Constants.ts | 2 + src/core/Engine.ts | 2 +- src/core/LevelLoader.ts | 95 ++++++++++++++++++++++++ src/main.ts | 62 ++++++++++++---- src/systems/CameraSystem.ts | 50 +++++++++++++ src/systems/InputSystem.ts | 23 +++++- src/systems/MovementSystem.ts | 49 +++++++++---- src/systems/ProjectileSystem.ts | 25 +++++-- src/systems/RenderSystem.ts | 126 +++++++++++++++++++++++++------- src/systems/StealthSystem.ts | 52 +++++++++++-- 13 files changed, 596 insertions(+), 72 deletions(-) create mode 100644 src/components/Camera.ts create mode 100644 src/core/ColorSampler.ts create mode 100644 src/systems/CameraSystem.ts diff --git a/src/components/Camera.ts b/src/components/Camera.ts new file mode 100644 index 0000000..e8afa88 --- /dev/null +++ b/src/components/Camera.ts @@ -0,0 +1,58 @@ +import { Component } from '../core/Component.ts'; +import { ComponentType } from '../core/Constants.ts'; + +/** + * Component for camera/viewport management. + */ +export class Camera extends Component { + x: number; + y: number; + targetX: number; + targetY: number; + smoothness: number; + bounds: { + minX: number; + maxX: number; + minY: number; + maxY: number; + }; + viewportWidth: number; + viewportHeight: number; + + constructor(viewportWidth: number, viewportHeight: number, smoothness = 0.15) { + super(ComponentType.CAMERA); + this.x = 0; + this.y = 0; + this.targetX = 0; + this.targetY = 0; + this.smoothness = smoothness; + this.viewportWidth = viewportWidth; + this.viewportHeight = viewportHeight; + this.bounds = { + minX: 0, + maxX: 0, + minY: 0, + maxY: 0, + }; + } + + /** + * Set camera bounds based on map size. + * @param mapWidth - Total map width in pixels + * @param mapHeight - Total map height in pixels + */ + setBounds(mapWidth: number, mapHeight: number): void { + this.bounds.minX = this.viewportWidth / 2; + this.bounds.maxX = mapWidth - this.viewportWidth / 2; + this.bounds.minY = this.viewportHeight / 2; + this.bounds.maxY = mapHeight - this.viewportHeight / 2; + } + + /** + * Clamp camera position to bounds. + */ + clampToBounds(): void { + this.x = Math.max(this.bounds.minX, Math.min(this.bounds.maxX, this.x)); + this.y = Math.max(this.bounds.minY, Math.min(this.bounds.maxY, this.y)); + } +} diff --git a/src/components/Stealth.ts b/src/components/Stealth.ts index 40f6655..490db21 100644 --- a/src/components/Stealth.ts +++ b/src/components/Stealth.ts @@ -10,6 +10,10 @@ export class Stealth extends Component { isStealthed: boolean; stealthLevel: number; detectionRadius: number; + camouflageColor: string | null; + baseColor: string | null; + sizeMultiplier: number; + formAppearance: string | null; constructor() { super(ComponentType.STEALTH); @@ -18,16 +22,29 @@ export class Stealth extends Component { this.isStealthed = false; this.stealthLevel = 0; this.detectionRadius = 100; + this.camouflageColor = null; + this.baseColor = null; + this.sizeMultiplier = 1.0; + this.formAppearance = null; } /** * Enter stealth mode. * @param type - The type of stealth (e.g., 'slime', 'human') + * @param baseColor - Original entity color to restore later */ - enterStealth(type: string): void { + enterStealth(type: string, baseColor?: string): void { this.stealthType = type; this.isStealthed = true; this.visibility = 0.3; + if (baseColor) { + this.baseColor = baseColor; + } + if (type === 'slime') { + this.sizeMultiplier = 0.6; + } else { + this.sizeMultiplier = 1.0; + } } /** @@ -36,6 +53,9 @@ export class Stealth extends Component { exitStealth(): void { this.isStealthed = false; this.visibility = 1.0; + this.camouflageColor = null; + this.sizeMultiplier = 1.0; + this.formAppearance = null; } /** diff --git a/src/core/ColorSampler.ts b/src/core/ColorSampler.ts new file mode 100644 index 0000000..43081ab --- /dev/null +++ b/src/core/ColorSampler.ts @@ -0,0 +1,102 @@ +import type { TileMap } from './TileMap.ts'; +import { Palette } from './Palette.ts'; + +/** + * Utility for sampling colors from the background and tile map. + */ +export class ColorSampler { + private static cache: Map = new Map(); + private static cacheFrame: number = 0; + + /** + * Sample the dominant color from a region around a position based on tile map and background. + * @param tileMap - The tile map to sample from + * @param x - Center X coordinate in world space + * @param y - Center Y coordinate in world space + * @param radius - Sampling radius in pixels + * @returns Dominant color as hex string (e.g., '#1a1a2e') + */ + static sampleDominantColor( + tileMap: TileMap | null, + x: number, + y: number, + radius: number + ): string { + const cacheKey = `${Math.floor(x / 20)}_${Math.floor(y / 20)}`; + const currentFrame = Math.floor(Date.now() / 200); + + if (currentFrame !== this.cacheFrame) { + this.cache.clear(); + this.cacheFrame = currentFrame; + } + + if (this.cache.has(cacheKey)) { + return this.cache.get(cacheKey) || Palette.VOID; + } + + if (!tileMap) { + return Palette.VOID; + } + + const tileSize = tileMap.tileSize; + const startCol = Math.max(0, Math.floor((x - radius) / tileSize)); + const endCol = Math.min(tileMap.cols, Math.ceil((x + radius) / tileSize)); + const startRow = Math.max(0, Math.floor((y - radius) / tileSize)); + const endRow = Math.min(tileMap.rows, Math.ceil((y + radius) / tileSize)); + + const colorCounts: Map = new Map(); + let totalTiles = 0; + + for (let r = startRow; r < endRow; r++) { + for (let c = startCol; c < endCol; c++) { + const tileType = tileMap.getTile(c, r); + let color: string; + + if (tileType === 1) { + color = Palette.DARK_BLUE; + } else { + const distFromCenter = Math.sqrt( + Math.pow(c * tileSize - x, 2) + Math.pow(r * tileSize - y, 2) + ); + if (distFromCenter < radius) { + const noise = Math.sin(c * 0.1 + r * 0.1) * 0.1; + if (Math.random() < 0.3 + noise) { + color = Palette.DARKER_BLUE; + } else { + color = Palette.VOID; + } + } else { + continue; + } + } + + colorCounts.set(color, (colorCounts.get(color) || 0) + 1); + totalTiles++; + } + } + + if (totalTiles === 0) { + return Palette.VOID; + } + + let dominantColor = Palette.VOID; + let maxCount = 0; + + colorCounts.forEach((count, color) => { + if (count > maxCount) { + maxCount = count; + dominantColor = color; + } + }); + + this.cache.set(cacheKey, dominantColor); + return dominantColor; + } + + /** + * Clear the color sampling cache. + */ + static clearCache(): void { + this.cache.clear(); + } +} diff --git a/src/core/Constants.ts b/src/core/Constants.ts index c12a14f..d64e312 100644 --- a/src/core/Constants.ts +++ b/src/core/Constants.ts @@ -32,6 +32,7 @@ export enum ComponentType { INVENTORY = 'Inventory', MUSIC = 'Music', SOUND_EFFECTS = 'SoundEffects', + CAMERA = 'Camera', } /** @@ -83,4 +84,5 @@ export enum SystemName { HEALTH_REGEN = 'HealthRegenerationSystem', MUSIC = 'MusicSystem', SOUND_EFFECTS = 'SoundEffectsSystem', + CAMERA = 'CameraSystem', } diff --git a/src/core/Engine.ts b/src/core/Engine.ts index d795cb3..50b07f7 100644 --- a/src/core/Engine.ts +++ b/src/core/Engine.ts @@ -44,7 +44,7 @@ export class Engine { this.ctx.imageSmoothingEnabled = false; this.deltaTime = 0; - this.tileMap = LevelLoader.loadSimpleLevel(20, 15, 16); + this.tileMap = LevelLoader.loadDesignedLevel(200, 150, 16); } /** diff --git a/src/core/LevelLoader.ts b/src/core/LevelLoader.ts index 1b211e3..fe11fef 100644 --- a/src/core/LevelLoader.ts +++ b/src/core/LevelLoader.ts @@ -27,4 +27,99 @@ export class LevelLoader { } return map; } + + /** + * Generates a larger designed map with rooms, corridors, and interesting layout. + * @param cols - Map width in tiles (default 200) + * @param rows - Map height in tiles (default 150) + * @param tileSize - Tile size in pixels (default 16) + * @returns The generated tile map + */ + static loadDesignedLevel(cols = 200, rows = 150, tileSize = 16): TileMap { + const map = new TileMap(cols, rows, tileSize); + + for (let r = 0; r < rows; r++) { + for (let c = 0; c < cols; c++) { + if (r === 0 || r === rows - 1 || c === 0 || c === cols - 1) { + map.setTile(c, r, 1); + } else { + map.setTile(c, r, 0); + } + } + } + + const roomCount = 15; + const rooms: Array<{ x: number; y: number; w: number; h: number }> = []; + + for (let i = 0; i < roomCount; i++) { + const roomW = 8 + Math.floor(Math.random() * 12); + const roomH = 8 + Math.floor(Math.random() * 12); + const roomX = 2 + Math.floor(Math.random() * (cols - roomW - 4)); + const roomY = 2 + Math.floor(Math.random() * (rows - roomH - 4)); + + let overlaps = false; + for (const existingRoom of rooms) { + if ( + roomX < existingRoom.x + existingRoom.w + 2 && + roomX + roomW + 2 > existingRoom.x && + roomY < existingRoom.y + existingRoom.h + 2 && + roomY + roomH + 2 > existingRoom.y + ) { + overlaps = true; + break; + } + } + + if (!overlaps) { + rooms.push({ x: roomX, y: roomY, w: roomW, h: roomH }); + + for (let ry = roomY; ry < roomY + roomH; ry++) { + for (let rx = roomX; rx < roomX + roomW; rx++) { + if (rx > 0 && rx < cols - 1 && ry > 0 && ry < rows - 1) { + map.setTile(rx, ry, 0); + } + } + } + } + } + + for (let i = 1; i < rooms.length; i++) { + const prevRoom = rooms[i - 1]; + const currRoom = rooms[i]; + + const startX = Math.floor(prevRoom.x + prevRoom.w / 2); + const startY = Math.floor(prevRoom.y + prevRoom.h / 2); + const endX = Math.floor(currRoom.x + currRoom.w / 2); + const endY = Math.floor(currRoom.y + currRoom.h / 2); + + let x = startX; + let y = startY; + + while (x !== endX || y !== endY) { + if (x > 0 && x < cols - 1 && y > 0 && y < rows - 1) { + map.setTile(x, y, 0); + } + + if (x < endX) x++; + else if (x > endX) x--; + + if (y < endY) y++; + else if (y > endY) y--; + } + + if (x > 0 && x < cols - 1 && y > 0 && y < rows - 1) { + map.setTile(x, y, 0); + } + } + + for (let r = 1; r < rows - 1; r++) { + for (let c = 1; c < cols - 1; c++) { + if (map.getTile(c, r) === 0 && Math.random() < 0.03) { + map.setTile(c, r, 1); + } + } + } + + return map; + } } diff --git a/src/main.ts b/src/main.ts index 9971c67..dc16e33 100644 --- a/src/main.ts +++ b/src/main.ts @@ -17,6 +17,7 @@ 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 { CameraSystem } from './systems/CameraSystem.ts'; import { Position } from './components/Position.ts'; import { Velocity } from './components/Velocity.ts'; @@ -34,6 +35,7 @@ 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 { Camera } from './components/Camera.ts'; import { EntityType, ComponentType } from './core/Constants.ts'; import type { Entity } from './core/Entity.ts'; @@ -50,6 +52,7 @@ if (!canvas) { engine.addSystem(new InputSystem()); engine.addSystem(new MusicSystem()); engine.addSystem(new SoundEffectsSystem()); + engine.addSystem(new CameraSystem()); engine.addSystem(new PlayerControllerSystem()); engine.addSystem(new StealthSystem()); engine.addSystem(new AISystem()); @@ -66,7 +69,9 @@ if (!canvas) { engine.addSystem(new UISystem(engine)); const player = engine.createEntity(); - player.addComponent(new Position(160, 120)); + const startX = engine.tileMap ? (engine.tileMap.cols * engine.tileMap.tileSize) / 2 : 160; + const startY = engine.tileMap ? (engine.tileMap.rows * engine.tileMap.tileSize) / 2 : 120; + player.addComponent(new Position(startX, startY)); player.addComponent(new Velocity(0, 0)); player.addComponent(new Sprite('#00ff96', 14, 14, EntityType.SLIME)); player.addComponent(new Health(100)); @@ -83,6 +88,17 @@ if (!canvas) { player.addComponent(new SkillProgress()); player.addComponent(new Intent()); + const cameraEntity = engine.createEntity(); + const camera = new Camera(canvas.width, canvas.height, 0.15); + if (engine.tileMap) { + const mapWidth = engine.tileMap.cols * engine.tileMap.tileSize; + const mapHeight = engine.tileMap.rows * engine.tileMap.tileSize; + camera.setBounds(mapWidth, mapHeight); + camera.x = startX; + camera.y = startY; + } + cameraEntity.addComponent(camera); + function createCreature(engine: Engine, x: number, y: number, type: EntityType): Entity { const creature = engine.createEntity(); creature.addComponent(new Position(x, y)); @@ -129,12 +145,36 @@ if (!canvas) { return creature; } - for (let i = 0; i < 8; i++) { - const x = 20 + Math.random() * 280; - const y = 20 + Math.random() * 200; - const types = [EntityType.HUMANOID, EntityType.BEAST, EntityType.ELEMENTAL]; - const type = types[Math.floor(Math.random() * types.length)]; - createCreature(engine, x, y, type); + const mapWidth = engine.tileMap ? engine.tileMap.cols * engine.tileMap.tileSize : 320; + const mapHeight = engine.tileMap ? engine.tileMap.rows * engine.tileMap.tileSize : 240; + + function spawnEnemyNearPlayer(): void { + const playerPos = player.getComponent(ComponentType.POSITION); + if (!playerPos) return; + + const spawnRadius = 150; + const minDistance = 80; + const maxAttempts = 10; + + for (let attempt = 0; attempt < maxAttempts; attempt++) { + const angle = Math.random() * Math.PI * 2; + const distance = minDistance + Math.random() * (spawnRadius - minDistance); + const x = playerPos.x + Math.cos(angle) * distance; + const y = playerPos.y + Math.sin(angle) * distance; + + if (x >= 50 && x <= mapWidth - 50 && y >= 50 && y <= mapHeight - 50) { + const types = [EntityType.HUMANOID, EntityType.BEAST, EntityType.ELEMENTAL]; + const type = types[Math.floor(Math.random() * types.length)]; + createCreature(engine, x, y, type); + return; + } + } + } + + const numberOfEnemies = 20; + + for (let i = 0; i < numberOfEnemies / 2; i++) { + spawnEnemyNearPlayer(); } setInterval(() => { @@ -142,12 +182,8 @@ if (!canvas) { .getEntities() .filter((e) => e.hasComponent(ComponentType.AI) && e !== player); - if (existingCreatures.length < 10) { - const x = 20 + Math.random() * 280; - const y = 20 + Math.random() * 200; - const types = [EntityType.HUMANOID, EntityType.BEAST, EntityType.ELEMENTAL]; - const type = types[Math.floor(Math.random() * types.length)]; - createCreature(engine, x, y, type); + if (existingCreatures.length < numberOfEnemies) { + spawnEnemyNearPlayer(); } }, 5000); diff --git a/src/systems/CameraSystem.ts b/src/systems/CameraSystem.ts new file mode 100644 index 0000000..316a3e9 --- /dev/null +++ b/src/systems/CameraSystem.ts @@ -0,0 +1,50 @@ +import { System } from '../core/System.ts'; +import { SystemName, ComponentType } from '../core/Constants.ts'; +import type { Entity } from '../core/Entity.ts'; +import type { Camera } from '../components/Camera.ts'; +import type { Position } from '../components/Position.ts'; +import type { PlayerControllerSystem } from './PlayerControllerSystem.ts'; + +/** + * System responsible for camera movement and following the player. + */ +export class CameraSystem extends System { + constructor() { + super(SystemName.CAMERA); + this.requiredComponents = [ComponentType.CAMERA]; + this.priority = 0; + } + + /** + * Update camera position to smoothly follow the player. + * @param deltaTime - Time elapsed since last frame in seconds + * @param entities - Filtered entities with Camera component + */ + process(deltaTime: number, entities: Entity[]): void { + const playerController = this.engine.systems.find( + (s) => s.name === SystemName.PLAYER_CONTROLLER + ) as PlayerControllerSystem | undefined; + const player = playerController ? playerController.getPlayerEntity() : null; + + if (!player) return; + + const playerPos = player.getComponent(ComponentType.POSITION); + if (!playerPos) return; + + entities.forEach((entity) => { + const camera = entity.getComponent(ComponentType.CAMERA); + if (!camera) return; + + camera.targetX = playerPos.x; + camera.targetY = playerPos.y; + + const dx = camera.targetX - camera.x; + const dy = camera.targetY - camera.y; + + camera.x += dx * camera.smoothness; + camera.y += dy * camera.smoothness; + + camera.clampToBounds(); + }); + } +} diff --git a/src/systems/InputSystem.ts b/src/systems/InputSystem.ts index 9c046b1..e9f069c 100644 --- a/src/systems/InputSystem.ts +++ b/src/systems/InputSystem.ts @@ -1,7 +1,8 @@ import { System } from '../core/System.ts'; -import { SystemName } from '../core/Constants.ts'; +import { SystemName, ComponentType } from '../core/Constants.ts'; import type { Engine } from '../core/Engine.ts'; import type { Entity } from '../core/Entity.ts'; +import type { Camera } from '../components/Camera.ts'; interface MouseState { x: number; @@ -155,10 +156,26 @@ export class InputSystem extends System { /** * Get the current mouse position in world coordinates. - * @returns The mouse coordinates + * @returns The mouse coordinates in world space */ getMousePosition(): { x: number; y: number } { - return { x: this.mouse.x, y: this.mouse.y }; + if (!this.engine) { + return { x: this.mouse.x, y: this.mouse.y }; + } + + const cameraEntity = this.engine.entities.find((e) => e.hasComponent(ComponentType.CAMERA)); + if (!cameraEntity) { + return { x: this.mouse.x, y: this.mouse.y }; + } + + const camera = cameraEntity.getComponent(ComponentType.CAMERA); + if (!camera) { + return { x: this.mouse.x, y: this.mouse.y }; + } + + const worldX = this.mouse.x + camera.x - camera.viewportWidth / 2; + const worldY = this.mouse.y + camera.y - camera.viewportHeight / 2; + return { x: worldX, y: worldY }; } /** diff --git a/src/systems/MovementSystem.ts b/src/systems/MovementSystem.ts index 6d8b193..6008ee8 100644 --- a/src/systems/MovementSystem.ts +++ b/src/systems/MovementSystem.ts @@ -77,21 +77,42 @@ export class MovementSystem extends System { velocity.vy *= Math.pow(friction, deltaTime * 60); } - const canvas = this.engine.canvas; - if (position.x < 0) { - position.x = 0; - velocity.vx = 0; - } else if (position.x > canvas.width) { - position.x = canvas.width; - velocity.vx = 0; - } + if (tileMap) { + const mapWidth = tileMap.cols * tileMap.tileSize; + const mapHeight = tileMap.rows * tileMap.tileSize; - if (position.y < 0) { - position.y = 0; - velocity.vy = 0; - } else if (position.y > canvas.height) { - position.y = canvas.height; - velocity.vy = 0; + if (position.x < 0) { + position.x = 0; + velocity.vx = 0; + } else if (position.x > mapWidth) { + position.x = mapWidth; + velocity.vx = 0; + } + + if (position.y < 0) { + position.y = 0; + velocity.vy = 0; + } else if (position.y > mapHeight) { + position.y = mapHeight; + velocity.vy = 0; + } + } else { + const canvas = this.engine.canvas; + if (position.x < 0) { + position.x = 0; + velocity.vx = 0; + } else if (position.x > canvas.width) { + position.x = canvas.width; + velocity.vx = 0; + } + + if (position.y < 0) { + position.y = 0; + velocity.vy = 0; + } else if (position.y > canvas.height) { + position.y = canvas.height; + velocity.vy = 0; + } } }); } diff --git a/src/systems/ProjectileSystem.ts b/src/systems/ProjectileSystem.ts index c9d91f4..44aa4bf 100644 --- a/src/systems/ProjectileSystem.ts +++ b/src/systems/ProjectileSystem.ts @@ -99,14 +99,23 @@ export class ProjectileSystem extends System { } }); - const canvas = this.engine.canvas; - if ( - position.x < 0 || - position.x > canvas.width || - position.y < 0 || - position.y > canvas.height - ) { - this.engine.removeEntity(entity); + const tileMap = this.engine.tileMap; + if (tileMap) { + const mapWidth = tileMap.cols * tileMap.tileSize; + const mapHeight = tileMap.rows * tileMap.tileSize; + if (position.x < 0 || position.x > mapWidth || position.y < 0 || position.y > mapHeight) { + this.engine.removeEntity(entity); + } + } else { + const canvas = this.engine.canvas; + if ( + position.x < 0 || + position.x > canvas.width || + position.y < 0 || + position.y > canvas.height + ) { + this.engine.removeEntity(entity); + } } }); } diff --git a/src/systems/RenderSystem.ts b/src/systems/RenderSystem.ts index b989c22..e73820a 100644 --- a/src/systems/RenderSystem.ts +++ b/src/systems/RenderSystem.ts @@ -18,6 +18,7 @@ import type { Combat } from '../components/Combat.ts'; import type { Stealth } from '../components/Stealth.ts'; import type { Evolution } from '../components/Evolution.ts'; import type { Absorbable } from '../components/Absorbable.ts'; +import type { Camera } from '../components/Camera.ts'; import type { VFXSystem } from './VFXSystem.ts'; import type { SkillEffectSystem, SkillEffect } from './SkillEffectSystem.ts'; @@ -26,6 +27,7 @@ import type { SkillEffectSystem, SkillEffect } from './SkillEffectSystem.ts'; */ export class RenderSystem extends System { ctx: CanvasRenderingContext2D; + private camera: Camera | null; /** * @param engine - The game engine instance @@ -36,6 +38,37 @@ export class RenderSystem extends System { this.priority = 100; this.engine = engine; this.ctx = engine.ctx; + this.camera = null; + } + + /** + * Get the active camera from the engine. + */ + private getCamera(): Camera | null { + if (this.camera) return this.camera; + + const cameraEntity = this.engine.entities.find((e) => e.hasComponent(ComponentType.CAMERA)); + if (cameraEntity) { + this.camera = cameraEntity.getComponent(ComponentType.CAMERA); + } + return this.camera; + } + + /** + * Transform world coordinates to screen coordinates using camera. + * @param worldX - World X coordinate + * @param worldY - World Y coordinate + * @returns Screen coordinates {x, y} + */ + private worldToScreen(worldX: number, worldY: number): { x: number; y: number } { + const camera = this.getCamera(); + if (!camera) { + return { x: worldX, y: worldY }; + } + + const screenX = worldX - camera.x + camera.viewportWidth / 2; + const screenY = worldY - camera.y + camera.viewportHeight / 2; + return { x: screenX, y: screenY }; } /** @@ -90,11 +123,14 @@ export class RenderSystem extends System { ctx.fillStyle = Palette.DARKER_BLUE; for (let i = 0; i < 20; i++) { - const x = Math.floor((i * 70 + Math.sin(i) * 30) % width); - const y = Math.floor((i * 50 + Math.cos(i) * 40) % height); + const worldX = (i * 70 + Math.sin(i) * 30) % 2000; + const worldY = (i * 50 + Math.cos(i) * 40) % 1500; + const screen = this.worldToScreen(worldX, worldY); const size = Math.floor(25 + (i % 4) * 15); - ctx.fillRect(x, y, size, size); + if (screen.x + size > 0 && screen.x < width && screen.y + size > 0 && screen.y < height) { + ctx.fillRect(screen.x, screen.y, size, size); + } } } @@ -105,18 +141,35 @@ export class RenderSystem extends System { const tileMap = this.engine.tileMap; if (!tileMap) return; + const camera = this.getCamera(); const ctx = this.ctx; const tileSize = tileMap.tileSize; + const viewportLeft = camera ? camera.x - camera.viewportWidth / 2 : 0; + const viewportRight = camera ? camera.x + camera.viewportWidth / 2 : this.engine.canvas.width; + const viewportTop = camera ? camera.y - camera.viewportHeight / 2 : 0; + const viewportBottom = camera + ? camera.y + camera.viewportHeight / 2 + : this.engine.canvas.height; + + const startCol = Math.max(0, Math.floor(viewportLeft / tileSize) - 1); + const endCol = Math.min(tileMap.cols, Math.ceil(viewportRight / tileSize) + 1); + const startRow = Math.max(0, Math.floor(viewportTop / tileSize) - 1); + const endRow = Math.min(tileMap.rows, Math.ceil(viewportBottom / tileSize) + 1); + ctx.fillStyle = Palette.DARK_BLUE; - for (let r = 0; r < tileMap.rows; r++) { - for (let c = 0; c < tileMap.cols; c++) { + for (let r = startRow; r < endRow; r++) { + for (let c = startCol; c < endCol; c++) { if (tileMap.getTile(c, r) === 1) { - ctx.fillRect(c * tileSize, r * tileSize, tileSize, tileSize); + const worldX = c * tileSize; + const worldY = r * tileSize; + const screen = this.worldToScreen(worldX, worldY); + + ctx.fillRect(screen.x, screen.y, tileSize, tileSize); ctx.fillStyle = Palette.ROYAL_BLUE; - ctx.fillRect(c * tileSize, r * tileSize, tileSize, 2); + ctx.fillRect(screen.x, screen.y, tileSize, 2); ctx.fillStyle = Palette.DARK_BLUE; } } @@ -138,8 +191,9 @@ export class RenderSystem extends System { this.ctx.save(); - const drawX = Math.floor(position.x); - const drawY = Math.floor(position.y); + const screen = this.worldToScreen(position.x, position.y); + const drawX = Math.floor(screen.x); + const drawY = Math.floor(screen.y); let alpha = sprite.alpha; if (isDeadFade && health && health.isDead()) { @@ -155,13 +209,21 @@ export class RenderSystem extends System { this.ctx.translate(drawX, drawY + (sprite.yOffset || 0)); this.ctx.scale(sprite.scale, sprite.scale); - if (sprite.shape === EntityType.SLIME) { + const stealth = entity.getComponent(ComponentType.STEALTH); + let effectiveShape = sprite.shape; + if (stealth && stealth.isStealthed && stealth.formAppearance) { + effectiveShape = stealth.formAppearance; + } + + if (effectiveShape === EntityType.SLIME) { sprite.animationTime += deltaTime; sprite.morphAmount = Math.sin(sprite.animationTime * 3) * 0.2 + 0.8; } let drawColor = sprite.color; - if (sprite.shape === EntityType.SLIME) drawColor = Palette.CYAN; + if (effectiveShape === EntityType.SLIME && (!stealth || !stealth.isStealthed)) { + drawColor = Palette.CYAN; + } this.ctx.fillStyle = drawColor; @@ -171,7 +233,7 @@ export class RenderSystem extends System { sprite.animationState = isMoving ? AnimationState.WALK : AnimationState.IDLE; } - let spriteData = SpriteLibrary[sprite.shape as string]; + let spriteData = SpriteLibrary[effectiveShape as string]; if (!spriteData) { spriteData = SpriteLibrary[EntityType.SLIME]; } @@ -245,7 +307,6 @@ export class RenderSystem extends System { this.ctx.restore(); } - const stealth = entity.getComponent(ComponentType.STEALTH); if (stealth && stealth.isStealthed) { this.drawStealthIndicator(stealth, sprite); } @@ -284,8 +345,9 @@ export class RenderSystem extends System { ctx.fillStyle = p.color; ctx.globalAlpha = p.type === VFXType.IMPACT ? Math.min(1, p.lifetime / 0.3) : 0.8; - const x = Math.floor(p.x); - const y = Math.floor(p.y); + const screen = this.worldToScreen(p.x, p.y); + const x = Math.floor(screen.x); + const y = Math.floor(screen.y); const size = Math.floor(p.size); ctx.fillRect(x - size / 2, y - size / 2, size, size); @@ -337,7 +399,13 @@ export class RenderSystem extends System { ctx.save(); - if (sprite.shape === EntityType.SLIME) { + const stealth = entity.getComponent(ComponentType.STEALTH); + let effectiveShape = sprite.shape; + if (stealth && stealth.isStealthed && stealth.formAppearance) { + effectiveShape = stealth.formAppearance; + } + + if (effectiveShape === EntityType.SLIME) { ctx.strokeStyle = Palette.CYAN; ctx.lineWidth = 3; ctx.lineCap = 'round'; @@ -353,7 +421,7 @@ export class RenderSystem extends System { ctx.beginPath(); ctx.arc(length, 0, 2, 0, Math.PI * 2); ctx.fill(); - } else if (sprite.shape === EntityType.BEAST) { + } else if (effectiveShape === EntityType.BEAST) { ctx.strokeStyle = Palette.WHITE; ctx.lineWidth = 2; ctx.globalAlpha = alpha; @@ -365,7 +433,7 @@ export class RenderSystem extends System { ctx.beginPath(); ctx.arc(0, 0, radius, start - 0.5, start + 0.5); ctx.stroke(); - } else if (sprite.shape === EntityType.HUMANOID) { + } else if (effectiveShape === EntityType.HUMANOID) { ctx.strokeStyle = `rgba(255, 255, 255, ${alpha})`; ctx.lineWidth = 4; @@ -481,7 +549,8 @@ export class RenderSystem extends System { const progress = Math.min(1.0, effect.time / effect.lifetime); const alpha = Math.max(0, 1.0 - progress); - ctx.translate(effect.x, effect.y); + const screen = this.worldToScreen(effect.x, effect.y); + ctx.translate(screen.x, screen.y); ctx.rotate(effect.angle); const gradient = ctx.createRadialGradient(0, 0, 0, 0, 0, effect.range); @@ -531,10 +600,13 @@ export class RenderSystem extends System { currentY = effect.startY + Math.sin(effect.angle) * (effect.speed || 400) * effect.time; } + const startScreen = this.worldToScreen(effect.startX, effect.startY); + const currentScreen = this.worldToScreen(currentX, currentY); + ctx.globalAlpha = Math.max(0, 0.3 * (1 - progress)); ctx.fillStyle = Palette.VOID; ctx.beginPath(); - ctx.ellipse(effect.startX, effect.startY, 10, 5, 0, 0, Math.PI * 2); + ctx.ellipse(startScreen.x, startScreen.y, 10, 5, 0, 0, Math.PI * 2); ctx.fill(); const alpha = Math.max(0, 0.8 * (1.0 - progress)); @@ -542,15 +614,15 @@ export class RenderSystem extends System { ctx.lineWidth = 2; ctx.lineCap = 'round'; ctx.beginPath(); - ctx.moveTo(effect.startX, effect.startY); - ctx.lineTo(currentX, currentY); + ctx.moveTo(startScreen.x, startScreen.y); + ctx.lineTo(currentScreen.x, currentScreen.y); ctx.stroke(); const ringSize = progress * 40; ctx.strokeStyle = `rgba(255, 255, 255, ${0.4 * (1 - progress)})`; ctx.lineWidth = 1; ctx.beginPath(); - ctx.arc(effect.startX, effect.startY, ringSize, 0, Math.PI * 2); + ctx.arc(startScreen.x, startScreen.y, ringSize, 0, Math.PI * 2); ctx.stroke(); } @@ -563,18 +635,20 @@ export class RenderSystem extends System { const alpha = Math.max(0, 1.0 - progress); const size = Math.max(0, 30 * (1 - progress)); + const screen = this.worldToScreen(effect.x, effect.y); + if (size > 0 && alpha > 0) { ctx.strokeStyle = `rgba(255, 200, 0, ${alpha})`; ctx.lineWidth = 3; ctx.beginPath(); - ctx.arc(effect.x, effect.y, size, 0, Math.PI * 2); + ctx.arc(screen.x, screen.y, size, 0, Math.PI * 2); ctx.stroke(); for (let i = 0; i < 8; i++) { const angle = (i / 8) * Math.PI * 2; const dist = size * 0.7; - const x = effect.x + Math.cos(angle) * dist; - const y = effect.y + Math.sin(angle) * dist; + const x = screen.x + Math.cos(angle) * dist; + const y = screen.y + Math.sin(angle) * dist; ctx.fillStyle = `rgba(255, 150, 0, ${alpha})`; ctx.beginPath(); diff --git a/src/systems/StealthSystem.ts b/src/systems/StealthSystem.ts index 8d91067..51a5e0b 100644 --- a/src/systems/StealthSystem.ts +++ b/src/systems/StealthSystem.ts @@ -1,10 +1,13 @@ import { System } from '../core/System.ts'; -import { SystemName, ComponentType } from '../core/Constants.ts'; +import { SystemName, ComponentType, EntityType } from '../core/Constants.ts'; +import { ColorSampler } from '../core/ColorSampler.ts'; import type { Entity } from '../core/Entity.ts'; import type { Stealth } from '../components/Stealth.ts'; import type { Velocity } from '../components/Velocity.ts'; import type { Combat } from '../components/Combat.ts'; import type { Evolution } from '../components/Evolution.ts'; +import type { Sprite } from '../components/Sprite.ts'; +import type { Position } from '../components/Position.ts'; import type { InputSystem } from './InputSystem.ts'; import type { PlayerControllerSystem } from './PlayerControllerSystem.ts'; @@ -45,13 +48,23 @@ export class StealthSystem extends System { stealth.stealthType = form; } + const sprite = entity.getComponent(ComponentType.SPRITE); + const position = entity.getComponent(ComponentType.POSITION); + if (entity === player && inputSystem) { const shiftPress = inputSystem.isKeyJustPressed('shift'); if (shiftPress) { if (stealth.isStealthed) { stealth.exitStealth(); + if (sprite && stealth.baseColor) { + sprite.color = stealth.baseColor; + } } else { - stealth.enterStealth(stealth.stealthType); + if (sprite) { + stealth.enterStealth(stealth.stealthType, sprite.color); + } else { + stealth.enterStealth(stealth.stealthType); + } } } } @@ -61,24 +74,51 @@ export class StealthSystem extends System { stealth.updateStealth(isMoving || false, isInCombat || false); - if (stealth.isStealthed) { + if (stealth.isStealthed && sprite && position) { switch (stealth.stealthType) { - case 'slime': + case 'slime': { if (!isMoving) { stealth.visibility = Math.max(0.05, stealth.visibility - deltaTime * 0.2); } + + const sampledColor = ColorSampler.sampleDominantColor( + this.engine.tileMap, + position.x, + position.y, + 30 + ); + + if (stealth.camouflageColor !== sampledColor) { + stealth.camouflageColor = sampledColor; + sprite.color = sampledColor; + } + + sprite.scale = stealth.sizeMultiplier; break; - case 'beast': + } + case 'beast': { if (isMoving && velocity) { const speed = Math.sqrt(velocity.vx * velocity.vx + velocity.vy * velocity.vy); if (speed < 50) { stealth.visibility = Math.max(0.1, stealth.visibility - deltaTime * 0.1); } } + + stealth.formAppearance = EntityType.BEAST; + sprite.scale = 1.0; break; - case 'human': + } + case 'human': { stealth.visibility = Math.max(0.2, stealth.visibility - deltaTime * 0.05); + stealth.formAppearance = EntityType.HUMANOID; + sprite.scale = 1.0; break; + } + } + } else if (!stealth.isStealthed && sprite) { + sprite.scale = 1.0; + if (stealth.baseColor) { + sprite.color = stealth.baseColor; } } }); From 109cee005286fa3523dea9dcbc7cbd5e899daf41 Mon Sep 17 00:00:00 2001 From: forgebot Date: Wed, 7 Jan 2026 06:29:14 +0000 Subject: [PATCH 8/8] chore: update version to 0.5.0 [skip ci] --- VERSION | 2 +- portainer.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/VERSION b/VERSION index 1d0ba9e..8f0916f 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.4.0 +0.5.0 diff --git a/portainer.yml b/portainer.yml index d2a3e11..ace98e9 100644 --- a/portainer.yml +++ b/portainer.yml @@ -2,7 +2,7 @@ name: slime services: app: - image: git.jusemon.com/jusemon/slime:0.4.0 + image: git.jusemon.com/jusemon/slime:0.5.0 restart: unless-stopped networks: