Compare commits

..

10 commits
v0.3.0 ... main

Author SHA1 Message Date
forgebot
109cee0052 chore: update version to 0.5.0 [skip ci] 2026-01-07 06:29:14 +00:00
2858898ec2 Merge pull request 'Feature/Camera and Large Map' (#7) from Feature/Camera-and-Large-Map into main
All checks were successful
Build and Publish Docker Image / Publish to Registry (push) Successful in 9s
Build and Publish Docker Image / Deploy to Portainer (push) Successful in 1s
Reviewed-on: #7
2026-01-07 01:29:09 -05:00
c859e20ffc feat: implement Camera system and component for improved viewport management and player tracking
All checks were successful
Build and Publish Docker Image / Build and Validate (pull_request) Successful in 14s
2026-01-07 01:25:56 -05:00
62e58f77ae chore: add eslint-config-prettier to ESLint configuration for improved formatting compatibility 2026-01-07 01:25:53 -05:00
forgebot
b32ac22be8 chore: update version to 0.4.0 [skip ci] 2026-01-07 05:04:22 +00:00
71c8129f37 Merge pull request 'Feature/Sound, mangling and minification' (#6) from Feature/Sound-mangling-and-minification into main
All checks were successful
Build and Publish Docker Image / Publish to Registry (push) Successful in 8s
Build and Publish Docker Image / Deploy to Portainer (push) Successful in 1s
Reviewed-on: #6
2026-01-07 00:04:17 -05:00
66719912ba refactor: remove unused audio playback logic from setupMusicHandlers, streamlining music configuration
All checks were successful
Build and Publish Docker Image / Build and Validate (pull_request) Successful in 10s
2026-01-07 00:03:50 -05:00
5a24d6a2af feat: refactor audio management by introducing setup functions for music and sound effects, enhancing modularity and maintainability
All checks were successful
Build and Publish Docker Image / Build and Validate (pull_request) Successful in 10s
2026-01-06 23:58:26 -05:00
2213f64e60 feat: implement Music and SoundEffects systems for enhanced audio management, including background music and sound effects playback
All checks were successful
Build and Publish Docker Image / Build and Validate (pull_request) Successful in 10s
2026-01-06 23:25:33 -05:00
143072f0a0 feat: enhance minification and mangling support by switching FONT_DATA to a Map and updating Vite configuration for ECMAScript 2020 compatibility 2026-01-06 22:40:19 -05:00
33 changed files with 1715 additions and 162 deletions

View file

@ -1 +1 @@
0.3.0 0.5.0

View file

@ -1,10 +1,12 @@
import js from '@eslint/js'; import js from '@eslint/js';
import globals from 'globals'; import globals from 'globals';
import tseslint from 'typescript-eslint'; import tseslint from 'typescript-eslint';
import prettier from 'eslint-config-prettier';
export default [ export default [
js.configs.recommended, js.configs.recommended,
...tseslint.configs.recommended, ...tseslint.configs.recommended,
prettier,
{ {
languageOptions: { languageOptions: {
ecmaVersion: 2022, ecmaVersion: 2022,
@ -27,7 +29,6 @@ export default [
], ],
'@typescript-eslint/no-explicit-any': 'warn', '@typescript-eslint/no-explicit-any': 'warn',
'no-console': 'off', 'no-console': 'off',
indent: ['error', 2],
'@typescript-eslint/explicit-module-boundary-types': 'off', '@typescript-eslint/explicit-module-boundary-types': 'off',
'@typescript-eslint/no-non-null-assertion': 'warn', '@typescript-eslint/no-non-null-assertion': 'warn',
}, },

19
package-lock.json generated
View file

@ -7,12 +7,12 @@
"": { "": {
"name": "slime-genesis-poc", "name": "slime-genesis-poc",
"version": "0.1.0", "version": "0.1.0",
"hasInstallScript": true,
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.2", "@eslint/js": "^9.39.2",
"@typescript-eslint/eslint-plugin": "^8.52.0", "@typescript-eslint/eslint-plugin": "^8.52.0",
"@typescript-eslint/parser": "^8.52.0", "@typescript-eslint/parser": "^8.52.0",
"eslint": "^9.39.2", "eslint": "^9.39.2",
"eslint-config-prettier": "^10.1.8",
"globals": "^17.0.0", "globals": "^17.0.0",
"husky": "^9.1.7", "husky": "^9.1.7",
"lint-staged": "^16.2.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": { "node_modules/eslint-scope": {
"version": "8.4.0", "version": "8.4.0",
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz",
@ -3120,7 +3136,6 @@
"integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==",
"dev": true, "dev": true,
"license": "ISC", "license": "ISC",
"peer": true,
"bin": { "bin": {
"yaml": "bin.mjs" "yaml": "bin.mjs"
}, },

View file

@ -18,6 +18,7 @@
"@typescript-eslint/eslint-plugin": "^8.52.0", "@typescript-eslint/eslint-plugin": "^8.52.0",
"@typescript-eslint/parser": "^8.52.0", "@typescript-eslint/parser": "^8.52.0",
"eslint": "^9.39.2", "eslint": "^9.39.2",
"eslint-config-prettier": "^10.1.8",
"globals": "^17.0.0", "globals": "^17.0.0",
"husky": "^9.1.7", "husky": "^9.1.7",
"lint-staged": "^16.2.7", "lint-staged": "^16.2.7",

View file

@ -2,7 +2,7 @@ name: slime
services: services:
app: app:
image: git.jusemon.com/jusemon/slime:0.3.0 image: git.jusemon.com/jusemon/slime:0.5.0
restart: unless-stopped restart: unless-stopped
networks: networks:

58
src/components/Camera.ts Normal file
View file

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

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

@ -0,0 +1,234 @@
import { Component } from '../core/Component.ts';
import { ComponentType } from '../core/Constants.ts';
import type { Sequence } from '../core/Music.ts';
/**
* Component for managing background music and sound effects.
*/
export class Music extends Component {
sequences: Map<string, Sequence>;
currentSequence: Sequence | null;
activeSequences: Set<Sequence>;
volume: number;
enabled: boolean;
private sequenceChain: string[];
private currentChainIndex: number;
private sequenceVolumes: Map<Sequence, number>;
constructor() {
super(ComponentType.MUSIC);
this.sequences = new Map();
this.currentSequence = null;
this.activeSequences = new Set();
this.volume = 0.5;
this.enabled = true;
this.sequenceChain = [];
this.currentChainIndex = 0;
this.sequenceVolumes = new Map();
}
/**
* Add a music sequence.
* @param name - Unique identifier for the sequence
* @param sequence - The sequence instance
*/
addSequence(name: string, sequence: Sequence): void {
this.sequences.set(name, sequence);
if (sequence.gain) {
sequence.gain.gain.value = this.volume;
}
}
/**
* Play a sequence by name.
* @param name - The sequence identifier
*/
playSequence(name: string): void {
if (!this.enabled) return;
const sequence = this.sequences.get(name);
if (sequence) {
this.stop();
this.currentSequence = sequence;
if (sequence.gain) {
sequence.gain.gain.value = this.volume;
}
sequence.play();
}
}
/**
* Play multiple sequences simultaneously (polyphony).
* @param sequenceConfigs - Array of configs with name, optional delay in beats, and optional loop
*/
playSequences(sequenceConfigs: Array<{ name: string; delay?: number; loop?: boolean }>): void {
if (!this.enabled || sequenceConfigs.length === 0) return;
const firstSeq = this.sequences.get(sequenceConfigs[0].name);
if (!firstSeq || !firstSeq.ac) return;
const ac = firstSeq.ac;
const when = ac.currentTime;
const tempo = firstSeq.tempo || 120;
sequenceConfigs.forEach((config) => {
const sequence = this.sequences.get(config.name);
if (!sequence) return;
if (config.loop !== undefined) {
sequence.loop = config.loop;
}
if (sequence.gain) {
sequence.gain.gain.value = this.volume;
}
const delaySeconds = config.delay ? (60 / tempo) * config.delay : 0;
sequence.play(when + delaySeconds);
this.activeSequences.add(sequence);
if (!this.currentSequence) {
this.currentSequence = sequence;
}
});
}
/**
* Chain multiple sequences together in order (sequential playback).
* @param sequenceNames - Array of sequence names to play in order
*/
chainSequences(sequenceNames: string[]): void {
if (!this.enabled || sequenceNames.length === 0) return;
this.stop();
this.sequenceChain = sequenceNames;
this.currentChainIndex = 0;
this.playNextInChain();
}
/**
* Play the next sequence in the chain.
*/
private playNextInChain(): void {
if (!this.enabled || this.sequenceChain.length === 0) return;
const seqName = this.sequenceChain[this.currentChainIndex];
const sequence = this.sequences.get(seqName);
if (!sequence) return;
this.currentSequence = sequence;
sequence.loop = false;
if (sequence.gain) {
sequence.gain.gain.value = this.volume;
}
sequence.play();
if (sequence.osc) {
const nextIndex = (this.currentChainIndex + 1) % this.sequenceChain.length;
sequence.osc.onended = () => {
if (this.enabled) {
this.currentChainIndex = nextIndex;
this.playNextInChain();
}
};
}
}
/**
* Stop current playback.
*/
stop(): void {
this.activeSequences.forEach((seq) => {
seq.stop();
});
this.activeSequences.clear();
if (this.currentSequence) {
this.currentSequence.stop();
this.currentSequence = null;
}
}
/**
* Set the volume (0.0 to 1.0).
* @param volume - Volume level
*/
setVolume(volume: number): void {
this.volume = Math.max(0, Math.min(1, volume));
if (this.currentSequence && this.currentSequence.gain) {
this.currentSequence.gain.gain.value = this.volume;
}
this.sequences.forEach((seq) => {
if (seq.gain) {
seq.gain.gain.value = this.volume;
}
});
}
/**
* Enable or disable music playback.
* @param enabled - Whether music should be enabled
*/
setEnabled(enabled: boolean): void {
this.enabled = enabled;
if (!enabled) {
this.stop();
}
}
/**
* Pause all active sequences by setting their gain to 0.
*/
pause(): void {
this.activeSequences.forEach((seq) => {
if (seq.gain) {
const currentVolume = seq.gain.gain.value;
this.sequenceVolumes.set(seq, currentVolume);
seq.gain.gain.value = 0;
}
});
if (this.currentSequence && this.currentSequence.gain) {
const currentVolume = this.currentSequence.gain.gain.value;
this.sequenceVolumes.set(this.currentSequence, currentVolume);
this.currentSequence.gain.gain.value = 0;
}
this.sequences.forEach((seq) => {
if (seq.gain && seq.gain.gain.value > 0) {
const currentVolume = seq.gain.gain.value;
this.sequenceVolumes.set(seq, currentVolume);
seq.gain.gain.value = 0;
}
});
}
/**
* Resume all active sequences by restoring their volume.
*/
resume(): void {
this.activeSequences.forEach((seq) => {
if (seq.gain) {
const savedVolume = this.sequenceVolumes.get(seq);
if (savedVolume !== undefined) {
seq.gain.gain.value = savedVolume;
} else {
seq.gain.gain.value = this.volume;
}
}
});
if (this.currentSequence && this.currentSequence.gain) {
const savedVolume = this.sequenceVolumes.get(this.currentSequence);
if (savedVolume !== undefined) {
this.currentSequence.gain.gain.value = savedVolume;
} else {
this.currentSequence.gain.gain.value = this.volume;
}
}
this.sequences.forEach((seq) => {
if (seq.gain) {
const savedVolume = this.sequenceVolumes.get(seq);
if (savedVolume !== undefined) {
seq.gain.gain.value = savedVolume;
}
}
});
}
}

View file

@ -0,0 +1,73 @@
import { Component } from '../core/Component.ts';
import { ComponentType } from '../core/Constants.ts';
import type { Sequence } from '../core/Music.ts';
/**
* Component for managing sound effects.
* Sound effects are short, one-shot audio sequences.
*/
export class SoundEffects extends Component {
sounds: Map<string, Sequence>;
volume: number;
enabled: boolean;
audioContext: AudioContext | null;
constructor(audioContext?: AudioContext) {
super(ComponentType.SOUND_EFFECTS);
this.sounds = new Map();
this.volume = 0.15; // Reduced default volume
this.enabled = true;
this.audioContext = audioContext || null;
}
/**
* Add a sound effect sequence.
* @param name - Unique identifier for the sound
* @param sequence - The sequence instance (should be short, non-looping)
*/
addSound(name: string, sequence: Sequence): void {
sequence.loop = false; // SFX should never loop
this.sounds.set(name, sequence);
if (sequence.gain) {
sequence.gain.gain.value = this.volume;
}
}
/**
* Play a sound effect by name.
* @param name - The sound identifier
*/
play(name: string): void {
if (!this.enabled) return;
const sound = this.sounds.get(name);
if (sound) {
sound.stop();
if (sound.gain) {
sound.gain.gain.value = this.volume;
}
sound.play();
}
}
/**
* Set the volume (0.0 to 1.0).
* @param volume - Volume level
*/
setVolume(volume: number): void {
this.volume = Math.max(0, Math.min(1, volume));
this.sounds.forEach((seq) => {
if (seq.gain) {
seq.gain.gain.value = this.volume;
}
});
}
/**
* Enable or disable sound effects.
* @param enabled - Whether sound effects should be enabled
*/
setEnabled(enabled: boolean): void {
this.enabled = enabled;
}
}

View file

@ -10,6 +10,10 @@ export class Stealth extends Component {
isStealthed: boolean; isStealthed: boolean;
stealthLevel: number; stealthLevel: number;
detectionRadius: number; detectionRadius: number;
camouflageColor: string | null;
baseColor: string | null;
sizeMultiplier: number;
formAppearance: string | null;
constructor() { constructor() {
super(ComponentType.STEALTH); super(ComponentType.STEALTH);
@ -18,16 +22,29 @@ export class Stealth extends Component {
this.isStealthed = false; this.isStealthed = false;
this.stealthLevel = 0; this.stealthLevel = 0;
this.detectionRadius = 100; this.detectionRadius = 100;
this.camouflageColor = null;
this.baseColor = null;
this.sizeMultiplier = 1.0;
this.formAppearance = null;
} }
/** /**
* Enter stealth mode. * Enter stealth mode.
* @param type - The type of stealth (e.g., 'slime', 'human') * @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.stealthType = type;
this.isStealthed = true; this.isStealthed = true;
this.visibility = 0.3; 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 { exitStealth(): void {
this.isStealthed = false; this.isStealthed = false;
this.visibility = 1.0; this.visibility = 1.0;
this.camouflageColor = null;
this.sizeMultiplier = 1.0;
this.formAppearance = null;
} }
/** /**

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

@ -0,0 +1,156 @@
import { Sequence } from '../core/Music.ts';
import type { Music } from '../components/Music.ts';
import type { MusicSystem } from '../systems/MusicSystem.ts';
/**
* Configure and setup background music.
* @param music - Music component instance
* @param audioCtx - AudioContext instance
*/
export function setupMusic(music: Music, audioCtx: AudioContext): void {
const tempo = 132;
const lead = new Sequence(audioCtx, tempo, [
'F4 e',
'Ab4 e',
'C5 e',
'F5 e',
'C5 e',
'Ab4 e',
'F4 e',
'C4 e',
'F4 e',
'Ab4 e',
'C5 e',
'F5 e',
'C5 e',
'Ab4 e',
'F4 e',
'C4 e',
'G4 e',
'Bb4 e',
'D5 e',
'G5 e',
'D5 e',
'Bb4 e',
'G4 e',
'D4 e',
'F4 e',
'Ab4 e',
'C5 e',
'F5 e',
'C5 e',
'Ab4 e',
'F4 e',
'C4 e',
]);
lead.staccato = 0.1;
lead.smoothing = 0.3;
lead.waveType = 'triangle';
lead.loop = true;
if (lead.gain) {
lead.gain.gain.value = 0.8;
}
music.addSequence('lead', lead);
const harmony = new Sequence(audioCtx, tempo, [
'C4 e',
'Eb4 e',
'F4 e',
'Ab4 e',
'F4 e',
'Eb4 e',
'C4 e',
'Ab3 e',
'C4 e',
'Eb4 e',
'F4 e',
'Ab4 e',
'F4 e',
'Eb4 e',
'C4 e',
'Ab3 e',
'D4 e',
'F4 e',
'G4 e',
'Bb4 e',
'G4 e',
'F4 e',
'D4 e',
'Bb3 e',
'C4 e',
'Eb4 e',
'F4 e',
'Ab4 e',
'F4 e',
'Eb4 e',
'C4 e',
'Ab3 e',
]);
harmony.staccato = 0.15;
harmony.smoothing = 0.4;
harmony.waveType = 'triangle';
harmony.loop = true;
if (harmony.gain) {
harmony.gain.gain.value = 0.6;
}
music.addSequence('harmony', harmony);
const bass = new Sequence(audioCtx, tempo, [
'F2 q',
'C3 q',
'F2 q',
'C3 q',
'G2 q',
'D3 q',
'G2 q',
'D3 q',
'F2 q',
'C3 q',
'F2 q',
'C3 q',
]);
bass.staccato = 0.05;
bass.smoothing = 0.5;
bass.waveType = 'triangle';
bass.loop = true;
if (bass.gain) {
bass.gain.gain.value = 0.7;
}
if (bass.bass) {
bass.bass.gain.value = 4;
bass.bass.frequency.value = 80;
}
music.addSequence('bass', bass);
music.playSequences([
{ name: 'lead', loop: true },
{ name: 'harmony', loop: true },
{ name: 'bass', loop: true },
]);
music.setVolume(0.02);
}
/**
* Setup music event handlers for canvas interaction.
* @param music - Music component instance
* @param musicSystem - MusicSystem instance
* @param canvas - Canvas element
*/
export function setupMusicHandlers(
music: Music,
musicSystem: MusicSystem,
canvas: HTMLCanvasElement
): void {
canvas.addEventListener('click', () => {
musicSystem.resumeAudioContext();
if (music.enabled && music.activeSequences.size === 0) {
music.playSequences([
{ name: 'lead', loop: true },
{ name: 'harmony', loop: true },
{ name: 'bass', loop: true },
]);
}
canvas.focus();
});
}

35
src/config/SFXConfig.ts Normal file
View file

@ -0,0 +1,35 @@
import { Sequence } from '../core/Music.ts';
import type { SoundEffects } from '../components/SoundEffects.ts';
/**
* Configure and setup sound effects.
* @param sfx - SoundEffects component instance
* @param ac - AudioContext instance
*/
export function setupSFX(sfx: SoundEffects, ac: AudioContext): void {
const attackSound = new Sequence(ac, 120, ['C5 s']);
attackSound.staccato = 0.8;
sfx.addSound('attack', attackSound);
const absorbSound = new Sequence(ac, 120, ['G4 e']);
absorbSound.staccato = 0.5;
sfx.addSound('absorb', absorbSound);
const skillSound = new Sequence(ac, 120, ['A4 e']);
skillSound.staccato = 0.6;
sfx.addSound('skill', skillSound);
const damageSound = new Sequence(ac, 120, ['F4 s']);
damageSound.staccato = 0.8;
sfx.addSound('damage', damageSound);
const shootSound = new Sequence(ac, 120, ['C5 s']);
shootSound.staccato = 0.9;
sfx.addSound('shoot', shootSound);
const impactSound = new Sequence(ac, 120, ['G4 s']);
impactSound.staccato = 0.7;
sfx.addSound('impact', impactSound);
sfx.setVolume(0.02);
}

102
src/core/ColorSampler.ts Normal file
View file

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

View file

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

View file

@ -44,7 +44,7 @@ export class Engine {
this.ctx.imageSmoothingEnabled = false; this.ctx.imageSmoothingEnabled = false;
this.deltaTime = 0; this.deltaTime = 0;
this.tileMap = LevelLoader.loadSimpleLevel(20, 15, 16); this.tileMap = LevelLoader.loadDesignedLevel(200, 150, 16);
} }
/** /**
@ -143,7 +143,7 @@ export class Engine {
| undefined; | undefined;
const gameState = menuSystem ? menuSystem.getGameState() : GameState.PLAYING; const gameState = menuSystem ? menuSystem.getGameState() : GameState.PLAYING;
const isPaused = [GameState.PAUSED, GameState.START, GameState.GAME_OVER].includes(gameState); const isPaused = [GameState.PAUSED, GameState.START, GameState.GAME_OVER].includes(gameState);
const unskippedSystems = [SystemName.MENU, SystemName.UI, SystemName.RENDER]; const unskippedSystems = [SystemName.MENU, SystemName.UI, SystemName.RENDER, SystemName.MUSIC];
this.systems.forEach((system) => { this.systems.forEach((system) => {
if (isPaused && !unskippedSystems.includes(system.name as SystemName)) { if (isPaused && !unskippedSystems.includes(system.name as SystemName)) {

View file

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

View file

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

View file

@ -27,4 +27,99 @@ export class LevelLoader {
} }
return map; 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;
}
} }

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

@ -0,0 +1,269 @@
/**
* Note class - represents a single musical note
*/
export class Note {
frequency: number;
duration: number;
constructor(str: string) {
const couple = str.split(/\s+/);
this.frequency = Note.getFrequency(couple[0]) || 0;
this.duration = Note.getDuration(couple[1]) || 0;
}
/**
* Convert a note name (e.g. 'A4') to a frequency (e.g. 440.00)
*/
static getFrequency(name: string): number {
const enharmonics = 'B#-C|C#-Db|D|D#-Eb|E-Fb|E#-F|F#-Gb|G|G#-Ab|A|A#-Bb|B-Cb';
const middleC = 440 * Math.pow(Math.pow(2, 1 / 12), -9);
const octaveOffset = 4;
const num = /(\d+)/;
const offsets: Record<string, number> = {};
enharmonics.split('|').forEach((val, i) => {
val.split('-').forEach((note) => {
offsets[note] = i;
});
});
const couple = name.split(num);
const distance = offsets[couple[0]] ?? 0;
const octaveDiff = parseInt(couple[1] || String(octaveOffset), 10) - octaveOffset;
const freq = middleC * Math.pow(Math.pow(2, 1 / 12), distance);
return freq * Math.pow(2, octaveDiff);
}
/**
* Convert a duration string (e.g. 'q') to a number (e.g. 1)
*/
static getDuration(symbol: string): number {
const numeric = /^[0-9.]+$/;
if (numeric.test(symbol)) {
return parseFloat(symbol);
}
return symbol
.toLowerCase()
.split('')
.reduce((prev, curr) => {
return (
prev +
(curr === 'w'
? 4
: curr === 'h'
? 2
: curr === 'q'
? 1
: curr === 'e'
? 0.5
: curr === 's'
? 0.25
: 0)
);
}, 0);
}
}
/**
* Sequence class - manages playback of musical sequences
*/
export class Sequence {
ac: AudioContext;
tempo: number;
loop: boolean;
smoothing: number;
staccato: number;
notes: Note[];
gain: GainNode;
bass: BiquadFilterNode | null;
mid: BiquadFilterNode | null;
treble: BiquadFilterNode | null;
waveType: OscillatorType | 'custom';
customWave?: [Float32Array, Float32Array];
osc: OscillatorNode | null;
constructor(ac?: AudioContext, tempo = 120, arr?: (Note | string)[]) {
this.ac = ac || new AudioContext();
this.tempo = tempo;
this.loop = true;
this.smoothing = 0;
this.staccato = 0;
this.notes = [];
this.bass = null;
this.mid = null;
this.treble = null;
this.osc = null;
this.waveType = 'square';
this.gain = this.ac.createGain();
this.createFxNodes();
if (arr) {
this.push(...arr);
}
}
/**
* Create gain and EQ nodes, then connect them
*/
createFxNodes(): void {
const eq: Array<[string, number]> = [
['bass', 100],
['mid', 1000],
['treble', 2500],
];
let prev: AudioNode = this.gain;
eq.forEach((config) => {
const filter = this.ac.createBiquadFilter();
filter.type = 'peaking';
filter.frequency.value = config[1];
prev.connect(filter);
prev = filter;
if (config[0] === 'bass') {
this.bass = filter;
} else if (config[0] === 'mid') {
this.mid = filter;
} else if (config[0] === 'treble') {
this.treble = filter;
}
});
prev.connect(this.ac.destination);
}
/**
* Accepts Note instances or strings (e.g. 'A4 e')
*/
push(...notes: (Note | string)[]): this {
notes.forEach((note) => {
this.notes.push(note instanceof Note ? note : new Note(note));
});
return this;
}
/**
* Create a custom waveform
*/
createCustomWave(real: number[], imag?: number[]): void {
if (!imag) {
imag = real;
}
this.waveType = 'custom';
this.customWave = [new Float32Array(real), new Float32Array(imag)];
}
/**
* Recreate the oscillator node (happens on every play)
*/
createOscillator(): this {
this.stop();
this.osc = this.ac.createOscillator();
if (this.customWave) {
this.osc.setPeriodicWave(this.ac.createPeriodicWave(this.customWave[0], this.customWave[1]));
} else {
this.osc.type = this.waveType === 'custom' ? 'square' : this.waveType;
}
if (this.gain) {
this.osc.connect(this.gain);
}
return this;
}
/**
* Schedule a note to play at the given time
*/
scheduleNote(index: number, when: number): number {
const duration = (60 / this.tempo) * this.notes[index].duration;
const cutoff = duration * (1 - (this.staccato || 0));
this.setFrequency(this.notes[index].frequency, when);
if (this.smoothing && this.notes[index].frequency) {
this.slide(index, when, cutoff);
}
this.setFrequency(0, when + cutoff);
return when + duration;
}
/**
* Get the next note
*/
getNextNote(index: number): Note {
return this.notes[index < this.notes.length - 1 ? index + 1 : 0];
}
/**
* How long do we wait before beginning the slide?
*/
getSlideStartDelay(duration: number): number {
return duration - Math.min(duration, (60 / this.tempo) * this.smoothing);
}
/**
* Slide the note at index into the next note
*/
slide(index: number, when: number, cutoff: number): this {
const next = this.getNextNote(index);
const start = this.getSlideStartDelay(cutoff);
this.setFrequency(this.notes[index].frequency, when + start);
this.rampFrequency(next.frequency, when + cutoff);
return this;
}
/**
* Set frequency at time
*/
setFrequency(freq: number, when: number): this {
if (this.osc) {
this.osc.frequency.setValueAtTime(freq, when);
}
return this;
}
/**
* Ramp to frequency at time
*/
rampFrequency(freq: number, when: number): this {
if (this.osc) {
this.osc.frequency.linearRampToValueAtTime(freq, when);
}
return this;
}
/**
* Run through all notes in the sequence and schedule them
*/
play(when?: number): this {
const startTime = typeof when === 'number' ? when : this.ac.currentTime;
this.createOscillator();
if (this.osc) {
this.osc.start(startTime);
let currentTime = startTime;
this.notes.forEach((_note, i) => {
currentTime = this.scheduleNote(i, currentTime);
});
this.osc.stop(currentTime);
this.osc.onended = this.loop ? () => this.play(currentTime) : null;
}
return this;
}
/**
* Stop playback
*/
stop(): this {
if (this.osc) {
this.osc.onended = null;
this.osc.disconnect();
this.osc = null;
}
return this;
}
}

View file

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

View file

@ -15,6 +15,9 @@ import { MenuSystem } from './systems/MenuSystem.ts';
import { RenderSystem } from './systems/RenderSystem.ts'; import { RenderSystem } from './systems/RenderSystem.ts';
import { UISystem } from './systems/UISystem.ts'; import { UISystem } from './systems/UISystem.ts';
import { VFXSystem } from './systems/VFXSystem.ts'; import { VFXSystem } from './systems/VFXSystem.ts';
import { MusicSystem } from './systems/MusicSystem.ts';
import { SoundEffectsSystem } from './systems/SoundEffectsSystem.ts';
import { CameraSystem } from './systems/CameraSystem.ts';
import { Position } from './components/Position.ts'; import { Position } from './components/Position.ts';
import { Velocity } from './components/Velocity.ts'; import { Velocity } from './components/Velocity.ts';
@ -30,9 +33,14 @@ import { AI } from './components/AI.ts';
import { Absorbable } from './components/Absorbable.ts'; import { Absorbable } from './components/Absorbable.ts';
import { SkillProgress } from './components/SkillProgress.ts'; import { SkillProgress } from './components/SkillProgress.ts';
import { Intent } from './components/Intent.ts'; import { Intent } from './components/Intent.ts';
import { Music } from './components/Music.ts';
import { SoundEffects } from './components/SoundEffects.ts';
import { Camera } from './components/Camera.ts';
import { EntityType, ComponentType } from './core/Constants.ts'; import { EntityType, ComponentType } from './core/Constants.ts';
import type { Entity } from './core/Entity.ts'; import type { Entity } from './core/Entity.ts';
import { setupMusic, setupMusicHandlers } from './config/MusicConfig.ts';
import { setupSFX } from './config/SFXConfig.ts';
const canvas = document.getElementById('game-canvas') as HTMLCanvasElement; const canvas = document.getElementById('game-canvas') as HTMLCanvasElement;
if (!canvas) { if (!canvas) {
@ -42,6 +50,9 @@ if (!canvas) {
engine.addSystem(new MenuSystem(engine)); engine.addSystem(new MenuSystem(engine));
engine.addSystem(new InputSystem()); engine.addSystem(new InputSystem());
engine.addSystem(new MusicSystem());
engine.addSystem(new SoundEffectsSystem());
engine.addSystem(new CameraSystem());
engine.addSystem(new PlayerControllerSystem()); engine.addSystem(new PlayerControllerSystem());
engine.addSystem(new StealthSystem()); engine.addSystem(new StealthSystem());
engine.addSystem(new AISystem()); engine.addSystem(new AISystem());
@ -58,7 +69,9 @@ if (!canvas) {
engine.addSystem(new UISystem(engine)); engine.addSystem(new UISystem(engine));
const player = engine.createEntity(); 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 Velocity(0, 0));
player.addComponent(new Sprite('#00ff96', 14, 14, EntityType.SLIME)); player.addComponent(new Sprite('#00ff96', 14, 14, EntityType.SLIME));
player.addComponent(new Health(100)); player.addComponent(new Health(100));
@ -75,6 +88,17 @@ if (!canvas) {
player.addComponent(new SkillProgress()); player.addComponent(new SkillProgress());
player.addComponent(new Intent()); 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 { function createCreature(engine: Engine, x: number, y: number, type: EntityType): Entity {
const creature = engine.createEntity(); const creature = engine.createEntity();
creature.addComponent(new Position(x, y)); creature.addComponent(new Position(x, y));
@ -121,12 +145,36 @@ if (!canvas) {
return creature; return creature;
} }
for (let i = 0; i < 8; i++) { const mapWidth = engine.tileMap ? engine.tileMap.cols * engine.tileMap.tileSize : 320;
const x = 20 + Math.random() * 280; const mapHeight = engine.tileMap ? engine.tileMap.rows * engine.tileMap.tileSize : 240;
const y = 20 + Math.random() * 200;
function spawnEnemyNearPlayer(): void {
const playerPos = player.getComponent<Position>(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 types = [EntityType.HUMANOID, EntityType.BEAST, EntityType.ELEMENTAL];
const type = types[Math.floor(Math.random() * types.length)]; const type = types[Math.floor(Math.random() * types.length)];
createCreature(engine, x, y, type); createCreature(engine, x, y, type);
return;
}
}
}
const numberOfEnemies = 20;
for (let i = 0; i < numberOfEnemies / 2; i++) {
spawnEnemyNearPlayer();
} }
setInterval(() => { setInterval(() => {
@ -134,26 +182,55 @@ if (!canvas) {
.getEntities() .getEntities()
.filter((e) => e.hasComponent(ComponentType.AI) && e !== player); .filter((e) => e.hasComponent(ComponentType.AI) && e !== player);
if (existingCreatures.length < 10) { if (existingCreatures.length < numberOfEnemies) {
const x = 20 + Math.random() * 280; spawnEnemyNearPlayer();
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);
} }
}, 5000); }, 5000);
const musicSystem = engine.systems.find((s) => s.name === 'MusicSystem') as
| MusicSystem
| undefined;
if (musicSystem) {
const musicEntity = engine.createEntity();
const music = new Music();
const audioCtx = musicSystem.getAudioContext();
setupMusic(music, audioCtx);
musicEntity.addComponent(music);
setupMusicHandlers(music, musicSystem, canvas);
const sfxSystem = engine.systems.find((s) => s.name === 'SoundEffectsSystem') as
| SoundEffectsSystem
| undefined;
if (sfxSystem) {
const sfxEntity = engine.createEntity();
const sfx = new SoundEffects(audioCtx);
setupSFX(sfx, audioCtx);
sfxEntity.addComponent(sfx);
}
} else {
canvas.addEventListener('click', () => {
canvas.focus();
});
}
canvas.focus(); canvas.focus();
engine.start(); engine.start();
interface WindowWithGame { interface WindowWithGame {
gameEngine?: Engine; gameEngine?: Engine;
player?: Entity; player?: Entity;
music?: Music;
} }
(window as WindowWithGame).gameEngine = engine; (window as WindowWithGame).gameEngine = engine;
(window as WindowWithGame).player = player; (window as WindowWithGame).player = player;
if (musicSystem) {
canvas.addEventListener('click', () => { const musicEntity = engine.getEntities().find((e) => e.hasComponent(ComponentType.MUSIC));
canvas.focus(); if (musicEntity) {
}); const music = musicEntity.getComponent<Music>(ComponentType.MUSIC);
if (music) {
(window as WindowWithGame).music = music;
}
}
}
} }

View file

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

View file

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

View file

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

View file

@ -0,0 +1,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<Position>(ComponentType.POSITION);
if (!playerPos) return;
entities.forEach((entity) => {
const camera = entity.getComponent<Camera>(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();
});
}
}

View file

@ -1,7 +1,8 @@
import { System } from '../core/System.ts'; 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 { Engine } from '../core/Engine.ts';
import type { Entity } from '../core/Entity.ts'; import type { Entity } from '../core/Entity.ts';
import type { Camera } from '../components/Camera.ts';
interface MouseState { interface MouseState {
x: number; x: number;
@ -155,12 +156,28 @@ export class InputSystem extends System {
/** /**
* Get the current mouse position in world coordinates. * Get the current mouse position in world coordinates.
* @returns The mouse coordinates * @returns The mouse coordinates in world space
*/ */
getMousePosition(): { x: number; y: number } { getMousePosition(): { x: number; y: number } {
if (!this.engine) {
return { x: this.mouse.x, y: this.mouse.y }; 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<Camera>(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 };
}
/** /**
* Check if a mouse button is currently being held down. * Check if a mouse button is currently being held down.
* @param button - The button index (0=left, 1=middle, 2=right) * @param button - The button index (0=left, 1=middle, 2=right)

View file

@ -77,6 +77,26 @@ export class MovementSystem extends System {
velocity.vy *= Math.pow(friction, deltaTime * 60); velocity.vy *= Math.pow(friction, deltaTime * 60);
} }
if (tileMap) {
const mapWidth = tileMap.cols * tileMap.tileSize;
const mapHeight = tileMap.rows * tileMap.tileSize;
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; const canvas = this.engine.canvas;
if (position.x < 0) { if (position.x < 0) {
position.x = 0; position.x = 0;
@ -93,6 +113,7 @@ export class MovementSystem extends System {
position.y = canvas.height; position.y = canvas.height;
velocity.vy = 0; velocity.vy = 0;
} }
}
}); });
} }
} }

View file

@ -0,0 +1,76 @@
import { System } from '../core/System.ts';
import { SystemName, ComponentType, GameState } from '../core/Constants.ts';
import type { Entity } from '../core/Entity.ts';
import type { Engine } from '../core/Engine.ts';
import type { Music } from '../components/Music.ts';
import type { MenuSystem } from './MenuSystem.ts';
/**
* System responsible for managing background music playback.
*/
export class MusicSystem extends System {
private audioContext: AudioContext | null;
private wasPaused: boolean;
constructor() {
super(SystemName.MUSIC);
this.requiredComponents = [ComponentType.MUSIC];
this.priority = 5;
this.audioContext = null;
this.wasPaused = false;
}
/**
* Initialize the audio context when system is added to engine.
*/
init(engine: Engine): void {
super.init(engine);
}
/**
* Process music entities - ensures audio context exists and handles pause/resume.
*/
process(_deltaTime: number, entities: Entity[]): void {
const menuSystem = this.engine.systems.find((s) => s.name === SystemName.MENU) as
| MenuSystem
| undefined;
const gameState = menuSystem ? menuSystem.getGameState() : GameState.PLAYING;
const isPaused = gameState === GameState.PAUSED;
entities.forEach((entity) => {
const music = entity.getComponent<Music>(ComponentType.MUSIC);
if (!music) return;
if (!this.audioContext) {
this.audioContext = new AudioContext();
}
if (isPaused && !this.wasPaused) {
music.pause();
this.wasPaused = true;
} else if (!isPaused && this.wasPaused) {
music.resume();
this.wasPaused = false;
}
});
}
/**
* Get or create the shared audio context.
*/
getAudioContext(): AudioContext {
if (!this.audioContext) {
this.audioContext = new AudioContext();
}
return this.audioContext;
}
/**
* Resume audio context (required after user interaction).
*/
resumeAudioContext(): void {
if (this.audioContext && this.audioContext.state === 'suspended') {
this.audioContext.resume();
}
}
}

View file

@ -75,6 +75,12 @@ export class ProjectileSystem extends System {
if (targetHealthComp) { if (targetHealthComp) {
targetHealthComp.takeDamage(damage); targetHealthComp.takeDamage(damage);
this.engine.emit(Events.PROJECTILE_IMPACT, {
x: position.x,
y: position.y,
damage,
});
const vfxSystem = this.engine.systems.find((s) => s.name === SystemName.VFX) as const vfxSystem = this.engine.systems.find((s) => s.name === SystemName.VFX) as
| VFXSystem | VFXSystem
| undefined; | undefined;
@ -93,6 +99,14 @@ export class ProjectileSystem extends System {
} }
}); });
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; const canvas = this.engine.canvas;
if ( if (
position.x < 0 || position.x < 0 ||
@ -102,6 +116,7 @@ export class ProjectileSystem extends System {
) { ) {
this.engine.removeEntity(entity); this.engine.removeEntity(entity);
} }
}
}); });
} }
} }

View file

@ -18,6 +18,7 @@ import type { Combat } from '../components/Combat.ts';
import type { Stealth } from '../components/Stealth.ts'; import type { Stealth } from '../components/Stealth.ts';
import type { Evolution } from '../components/Evolution.ts'; import type { Evolution } from '../components/Evolution.ts';
import type { Absorbable } from '../components/Absorbable.ts'; import type { Absorbable } from '../components/Absorbable.ts';
import type { Camera } from '../components/Camera.ts';
import type { VFXSystem } from './VFXSystem.ts'; import type { VFXSystem } from './VFXSystem.ts';
import type { SkillEffectSystem, SkillEffect } from './SkillEffectSystem.ts'; import type { SkillEffectSystem, SkillEffect } from './SkillEffectSystem.ts';
@ -26,6 +27,7 @@ import type { SkillEffectSystem, SkillEffect } from './SkillEffectSystem.ts';
*/ */
export class RenderSystem extends System { export class RenderSystem extends System {
ctx: CanvasRenderingContext2D; ctx: CanvasRenderingContext2D;
private camera: Camera | null;
/** /**
* @param engine - The game engine instance * @param engine - The game engine instance
@ -36,6 +38,37 @@ export class RenderSystem extends System {
this.priority = 100; this.priority = 100;
this.engine = engine; this.engine = engine;
this.ctx = engine.ctx; 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<Camera>(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; ctx.fillStyle = Palette.DARKER_BLUE;
for (let i = 0; i < 20; i++) { for (let i = 0; i < 20; i++) {
const x = Math.floor((i * 70 + Math.sin(i) * 30) % width); const worldX = (i * 70 + Math.sin(i) * 30) % 2000;
const y = Math.floor((i * 50 + Math.cos(i) * 40) % height); const worldY = (i * 50 + Math.cos(i) * 40) % 1500;
const screen = this.worldToScreen(worldX, worldY);
const size = Math.floor(25 + (i % 4) * 15); 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; const tileMap = this.engine.tileMap;
if (!tileMap) return; if (!tileMap) return;
const camera = this.getCamera();
const ctx = this.ctx; const ctx = this.ctx;
const tileSize = tileMap.tileSize; 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; ctx.fillStyle = Palette.DARK_BLUE;
for (let r = 0; r < tileMap.rows; r++) { for (let r = startRow; r < endRow; r++) {
for (let c = 0; c < tileMap.cols; c++) { for (let c = startCol; c < endCol; c++) {
if (tileMap.getTile(c, r) === 1) { 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.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; ctx.fillStyle = Palette.DARK_BLUE;
} }
} }
@ -138,8 +191,9 @@ export class RenderSystem extends System {
this.ctx.save(); this.ctx.save();
const drawX = Math.floor(position.x); const screen = this.worldToScreen(position.x, position.y);
const drawY = Math.floor(position.y); const drawX = Math.floor(screen.x);
const drawY = Math.floor(screen.y);
let alpha = sprite.alpha; let alpha = sprite.alpha;
if (isDeadFade && health && health.isDead()) { if (isDeadFade && health && health.isDead()) {
@ -155,13 +209,21 @@ export class RenderSystem extends System {
this.ctx.translate(drawX, drawY + (sprite.yOffset || 0)); this.ctx.translate(drawX, drawY + (sprite.yOffset || 0));
this.ctx.scale(sprite.scale, sprite.scale); this.ctx.scale(sprite.scale, sprite.scale);
if (sprite.shape === EntityType.SLIME) { const stealth = entity.getComponent<Stealth>(ComponentType.STEALTH);
let effectiveShape = sprite.shape;
if (stealth && stealth.isStealthed && stealth.formAppearance) {
effectiveShape = stealth.formAppearance;
}
if (effectiveShape === EntityType.SLIME) {
sprite.animationTime += deltaTime; sprite.animationTime += deltaTime;
sprite.morphAmount = Math.sin(sprite.animationTime * 3) * 0.2 + 0.8; sprite.morphAmount = Math.sin(sprite.animationTime * 3) * 0.2 + 0.8;
} }
let drawColor = sprite.color; 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; this.ctx.fillStyle = drawColor;
@ -171,7 +233,7 @@ export class RenderSystem extends System {
sprite.animationState = isMoving ? AnimationState.WALK : AnimationState.IDLE; sprite.animationState = isMoving ? AnimationState.WALK : AnimationState.IDLE;
} }
let spriteData = SpriteLibrary[sprite.shape as string]; let spriteData = SpriteLibrary[effectiveShape as string];
if (!spriteData) { if (!spriteData) {
spriteData = SpriteLibrary[EntityType.SLIME]; spriteData = SpriteLibrary[EntityType.SLIME];
} }
@ -179,7 +241,6 @@ export class RenderSystem extends System {
let frames = spriteData[sprite.animationState as string] || spriteData[AnimationState.IDLE]; let frames = spriteData[sprite.animationState as string] || spriteData[AnimationState.IDLE];
if (!frames || !Array.isArray(frames)) { if (!frames || !Array.isArray(frames)) {
// Fallback to default slime animation if data structure is unexpected
frames = SpriteLibrary[EntityType.SLIME][AnimationState.IDLE]; frames = SpriteLibrary[EntityType.SLIME][AnimationState.IDLE];
} }
@ -246,7 +307,6 @@ export class RenderSystem extends System {
this.ctx.restore(); this.ctx.restore();
} }
const stealth = entity.getComponent<Stealth>(ComponentType.STEALTH);
if (stealth && stealth.isStealthed) { if (stealth && stealth.isStealthed) {
this.drawStealthIndicator(stealth, sprite); this.drawStealthIndicator(stealth, sprite);
} }
@ -285,8 +345,9 @@ export class RenderSystem extends System {
ctx.fillStyle = p.color; ctx.fillStyle = p.color;
ctx.globalAlpha = p.type === VFXType.IMPACT ? Math.min(1, p.lifetime / 0.3) : 0.8; ctx.globalAlpha = p.type === VFXType.IMPACT ? Math.min(1, p.lifetime / 0.3) : 0.8;
const x = Math.floor(p.x); const screen = this.worldToScreen(p.x, p.y);
const y = Math.floor(p.y); const x = Math.floor(screen.x);
const y = Math.floor(screen.y);
const size = Math.floor(p.size); const size = Math.floor(p.size);
ctx.fillRect(x - size / 2, y - size / 2, size, size); ctx.fillRect(x - size / 2, y - size / 2, size, size);
@ -338,7 +399,13 @@ export class RenderSystem extends System {
ctx.save(); ctx.save();
if (sprite.shape === EntityType.SLIME) { const stealth = entity.getComponent<Stealth>(ComponentType.STEALTH);
let effectiveShape = sprite.shape;
if (stealth && stealth.isStealthed && stealth.formAppearance) {
effectiveShape = stealth.formAppearance;
}
if (effectiveShape === EntityType.SLIME) {
ctx.strokeStyle = Palette.CYAN; ctx.strokeStyle = Palette.CYAN;
ctx.lineWidth = 3; ctx.lineWidth = 3;
ctx.lineCap = 'round'; ctx.lineCap = 'round';
@ -354,7 +421,7 @@ export class RenderSystem extends System {
ctx.beginPath(); ctx.beginPath();
ctx.arc(length, 0, 2, 0, Math.PI * 2); ctx.arc(length, 0, 2, 0, Math.PI * 2);
ctx.fill(); ctx.fill();
} else if (sprite.shape === EntityType.BEAST) { } else if (effectiveShape === EntityType.BEAST) {
ctx.strokeStyle = Palette.WHITE; ctx.strokeStyle = Palette.WHITE;
ctx.lineWidth = 2; ctx.lineWidth = 2;
ctx.globalAlpha = alpha; ctx.globalAlpha = alpha;
@ -366,7 +433,7 @@ export class RenderSystem extends System {
ctx.beginPath(); ctx.beginPath();
ctx.arc(0, 0, radius, start - 0.5, start + 0.5); ctx.arc(0, 0, radius, start - 0.5, start + 0.5);
ctx.stroke(); ctx.stroke();
} else if (sprite.shape === EntityType.HUMANOID) { } else if (effectiveShape === EntityType.HUMANOID) {
ctx.strokeStyle = `rgba(255, 255, 255, ${alpha})`; ctx.strokeStyle = `rgba(255, 255, 255, ${alpha})`;
ctx.lineWidth = 4; ctx.lineWidth = 4;
@ -414,28 +481,24 @@ export class RenderSystem extends System {
*/ */
drawGlowEffect(sprite: Sprite): void { drawGlowEffect(sprite: Sprite): void {
const ctx = this.ctx; const ctx = this.ctx;
const time = Date.now() * 0.001; // Time in seconds for pulsing const time = Date.now() * 0.001;
const pulse = 0.5 + Math.sin(time * 3) * 0.3; // Pulsing between 0.2 and 0.8 const pulse = 0.5 + Math.sin(time * 3) * 0.3;
const baseRadius = Math.max(sprite.width, sprite.height) / 2; const baseRadius = Math.max(sprite.width, sprite.height) / 2;
const glowRadius = baseRadius + 4 + pulse * 2; const glowRadius = baseRadius + 4 + pulse * 2;
// Create radial gradient for soft glow
const gradient = ctx.createRadialGradient(0, 0, baseRadius, 0, 0, glowRadius); const gradient = ctx.createRadialGradient(0, 0, baseRadius, 0, 0, glowRadius);
gradient.addColorStop(0, `rgba(255, 255, 255, ${0.4 * pulse})`); gradient.addColorStop(0, `rgba(255, 255, 255, ${0.4 * pulse})`);
gradient.addColorStop(0.5, `rgba(0, 230, 255, ${0.3 * pulse})`); gradient.addColorStop(0.5, `rgba(0, 230, 255, ${0.3 * pulse})`);
gradient.addColorStop(1, 'rgba(0, 230, 255, 0)'); gradient.addColorStop(1, 'rgba(0, 230, 255, 0)');
// Draw multiple layers for a softer glow effect
ctx.save(); ctx.save();
ctx.globalCompositeOperation = 'screen'; ctx.globalCompositeOperation = 'screen';
// Outer glow layer
ctx.fillStyle = gradient; ctx.fillStyle = gradient;
ctx.beginPath(); ctx.beginPath();
ctx.arc(0, 0, glowRadius, 0, Math.PI * 2); ctx.arc(0, 0, glowRadius, 0, Math.PI * 2);
ctx.fill(); ctx.fill();
// Inner bright core
const innerGradient = ctx.createRadialGradient(0, 0, 0, 0, 0, baseRadius * 0.6); const innerGradient = ctx.createRadialGradient(0, 0, 0, 0, 0, baseRadius * 0.6);
innerGradient.addColorStop(0, `rgba(255, 255, 255, ${0.6 * pulse})`); innerGradient.addColorStop(0, `rgba(255, 255, 255, ${0.6 * pulse})`);
innerGradient.addColorStop(1, 'rgba(0, 230, 255, 0)'); innerGradient.addColorStop(1, 'rgba(0, 230, 255, 0)');
@ -486,7 +549,8 @@ export class RenderSystem extends System {
const progress = Math.min(1.0, effect.time / effect.lifetime); const progress = Math.min(1.0, effect.time / effect.lifetime);
const alpha = Math.max(0, 1.0 - progress); 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); ctx.rotate(effect.angle);
const gradient = ctx.createRadialGradient(0, 0, 0, 0, 0, effect.range); const gradient = ctx.createRadialGradient(0, 0, 0, 0, 0, effect.range);
@ -536,10 +600,13 @@ export class RenderSystem extends System {
currentY = effect.startY + Math.sin(effect.angle) * (effect.speed || 400) * effect.time; 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.globalAlpha = Math.max(0, 0.3 * (1 - progress));
ctx.fillStyle = Palette.VOID; ctx.fillStyle = Palette.VOID;
ctx.beginPath(); 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(); ctx.fill();
const alpha = Math.max(0, 0.8 * (1.0 - progress)); const alpha = Math.max(0, 0.8 * (1.0 - progress));
@ -547,15 +614,15 @@ export class RenderSystem extends System {
ctx.lineWidth = 2; ctx.lineWidth = 2;
ctx.lineCap = 'round'; ctx.lineCap = 'round';
ctx.beginPath(); ctx.beginPath();
ctx.moveTo(effect.startX, effect.startY); ctx.moveTo(startScreen.x, startScreen.y);
ctx.lineTo(currentX, currentY); ctx.lineTo(currentScreen.x, currentScreen.y);
ctx.stroke(); ctx.stroke();
const ringSize = progress * 40; const ringSize = progress * 40;
ctx.strokeStyle = `rgba(255, 255, 255, ${0.4 * (1 - progress)})`; ctx.strokeStyle = `rgba(255, 255, 255, ${0.4 * (1 - progress)})`;
ctx.lineWidth = 1; ctx.lineWidth = 1;
ctx.beginPath(); 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(); ctx.stroke();
} }
@ -568,18 +635,20 @@ export class RenderSystem extends System {
const alpha = Math.max(0, 1.0 - progress); const alpha = Math.max(0, 1.0 - progress);
const size = Math.max(0, 30 * (1 - progress)); const size = Math.max(0, 30 * (1 - progress));
const screen = this.worldToScreen(effect.x, effect.y);
if (size > 0 && alpha > 0) { if (size > 0 && alpha > 0) {
ctx.strokeStyle = `rgba(255, 200, 0, ${alpha})`; ctx.strokeStyle = `rgba(255, 200, 0, ${alpha})`;
ctx.lineWidth = 3; ctx.lineWidth = 3;
ctx.beginPath(); 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(); ctx.stroke();
for (let i = 0; i < 8; i++) { for (let i = 0; i < 8; i++) {
const angle = (i / 8) * Math.PI * 2; const angle = (i / 8) * Math.PI * 2;
const dist = size * 0.7; const dist = size * 0.7;
const x = effect.x + Math.cos(angle) * dist; const x = screen.x + Math.cos(angle) * dist;
const y = effect.y + Math.sin(angle) * dist; const y = screen.y + Math.sin(angle) * dist;
ctx.fillStyle = `rgba(255, 150, 0, ${alpha})`; ctx.fillStyle = `rgba(255, 150, 0, ${alpha})`;
ctx.beginPath(); ctx.beginPath();

View file

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

View file

@ -0,0 +1,105 @@
import { System } from '../core/System.ts';
import { SystemName, ComponentType } from '../core/Constants.ts';
import { Events } from '../core/EventBus.ts';
import type { Entity } from '../core/Entity.ts';
import type { Engine } from '../core/Engine.ts';
import type { SoundEffects } from '../components/SoundEffects.ts';
import type { MusicSystem } from './MusicSystem.ts';
/**
* System responsible for managing sound effects playback.
* Follows ECS pattern: system processes entities with SoundEffects component.
* Listens to game events and plays appropriate sound effects.
*/
export class SoundEffectsSystem extends System {
private sfxEntity: Entity | null;
constructor() {
super(SystemName.SOUND_EFFECTS);
this.requiredComponents = [ComponentType.SOUND_EFFECTS];
this.priority = 5;
this.sfxEntity = null;
}
/**
* Initialize event listeners when system is added to engine.
*/
init(engine: Engine): void {
super.init(engine);
this.engine.on(Events.ATTACK_PERFORMED, () => {
this.playSound('attack');
});
this.engine.on(Events.DAMAGE_DEALT, () => {
this.playSound('damage');
});
this.engine.on(Events.ABSORPTION, () => {
this.playSound('absorb');
});
this.engine.on(Events.SKILL_LEARNED, () => {
this.playSound('skill');
});
this.engine.on(Events.SKILL_COOLDOWN_STARTED, () => {
this.playSound('skill');
});
this.engine.on(Events.PROJECTILE_CREATED, () => {
this.playSound('shoot');
});
this.engine.on(Events.PROJECTILE_IMPACT, () => {
this.playSound('impact');
});
}
/**
* Process sound effect entities - ensures audio context is available.
*/
process(_deltaTime: number, entities: Entity[]): void {
entities.forEach((entity) => {
const sfx = entity.getComponent<SoundEffects>(ComponentType.SOUND_EFFECTS);
if (!sfx) return;
if (!this.sfxEntity) {
this.sfxEntity = entity;
}
if (!sfx.audioContext) {
const musicSystem = this.engine.systems.find((s) => s.name === SystemName.MUSIC) as
| MusicSystem
| undefined;
if (musicSystem) {
sfx.audioContext = musicSystem.getAudioContext();
}
}
});
}
/**
* Play a sound effect from any entity with SoundEffects component.
* @param soundName - The name of the sound to play
*/
playSound(soundName: string): void {
if (this.sfxEntity) {
const sfx = this.sfxEntity.getComponent<SoundEffects>(ComponentType.SOUND_EFFECTS);
if (sfx) {
sfx.play(soundName);
return;
}
}
const entities = this.engine.getEntities();
for (const entity of entities) {
const sfx = entity.getComponent<SoundEffects>(ComponentType.SOUND_EFFECTS);
if (sfx) {
this.sfxEntity = entity;
sfx.play(soundName);
break;
}
}
}
}

View file

@ -1,10 +1,13 @@
import { System } from '../core/System.ts'; 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 { Entity } from '../core/Entity.ts';
import type { Stealth } from '../components/Stealth.ts'; import type { Stealth } from '../components/Stealth.ts';
import type { Velocity } from '../components/Velocity.ts'; import type { Velocity } from '../components/Velocity.ts';
import type { Combat } from '../components/Combat.ts'; import type { Combat } from '../components/Combat.ts';
import type { Evolution } from '../components/Evolution.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 { InputSystem } from './InputSystem.ts';
import type { PlayerControllerSystem } from './PlayerControllerSystem.ts'; import type { PlayerControllerSystem } from './PlayerControllerSystem.ts';
@ -45,42 +48,79 @@ export class StealthSystem extends System {
stealth.stealthType = form; stealth.stealthType = form;
} }
const sprite = entity.getComponent<Sprite>(ComponentType.SPRITE);
const position = entity.getComponent<Position>(ComponentType.POSITION);
if (entity === player && inputSystem) { if (entity === player && inputSystem) {
const shiftPress = inputSystem.isKeyJustPressed('shift'); const shiftPress = inputSystem.isKeyJustPressed('shift');
if (shiftPress) { if (shiftPress) {
if (stealth.isStealthed) { if (stealth.isStealthed) {
stealth.exitStealth(); stealth.exitStealth();
if (sprite && stealth.baseColor) {
sprite.color = stealth.baseColor;
}
} else {
if (sprite) {
stealth.enterStealth(stealth.stealthType, sprite.color);
} else { } else {
stealth.enterStealth(stealth.stealthType); stealth.enterStealth(stealth.stealthType);
} }
} }
} }
}
const isMoving = velocity && (Math.abs(velocity.vx) > 1 || Math.abs(velocity.vy) > 1); const isMoving = velocity && (Math.abs(velocity.vx) > 1 || Math.abs(velocity.vy) > 1);
const isInCombat = combat && combat.isAttacking; const isInCombat = combat && combat.isAttacking;
stealth.updateStealth(isMoving || false, isInCombat || false); stealth.updateStealth(isMoving || false, isInCombat || false);
if (stealth.isStealthed) { if (stealth.isStealthed && sprite && position) {
switch (stealth.stealthType) { switch (stealth.stealthType) {
case 'slime': case 'slime': {
if (!isMoving) { if (!isMoving) {
stealth.visibility = Math.max(0.05, stealth.visibility - deltaTime * 0.2); 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; break;
case 'beast': }
case 'beast': {
if (isMoving && velocity) { if (isMoving && velocity) {
const speed = Math.sqrt(velocity.vx * velocity.vx + velocity.vy * velocity.vy); const speed = Math.sqrt(velocity.vx * velocity.vx + velocity.vy * velocity.vy);
if (speed < 50) { if (speed < 50) {
stealth.visibility = Math.max(0.1, stealth.visibility - deltaTime * 0.1); stealth.visibility = Math.max(0.1, stealth.visibility - deltaTime * 0.1);
} }
} }
stealth.formAppearance = EntityType.BEAST;
sprite.scale = 1.0;
break; break;
case 'human': }
case 'human': {
stealth.visibility = Math.max(0.2, stealth.visibility - deltaTime * 0.05); stealth.visibility = Math.max(0.2, stealth.visibility - deltaTime * 0.05);
stealth.formAppearance = EntityType.HUMANOID;
sprite.scale = 1.0;
break; break;
} }
} }
} else if (!stealth.isStealthed && sprite) {
sprite.scale = 1.0;
if (stealth.baseColor) {
sprite.color = stealth.baseColor;
}
}
}); });
} }
} }

View file

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