Compare commits

..

No commits in common. "main" and "v0.3.0" have entirely different histories.
main ... v0.3.0

33 changed files with 162 additions and 1715 deletions

View file

@ -1 +1 @@
0.5.0
0.3.0

View file

@ -1,12 +1,10 @@
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,
@ -29,6 +27,7 @@ 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',
},

19
package-lock.json generated
View file

@ -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,22 +1753,6 @@
}
}
},
"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",
@ -3136,6 +3120,7 @@
"integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==",
"dev": true,
"license": "ISC",
"peer": true,
"bin": {
"yaml": "bin.mjs"
},

View file

@ -18,7 +18,6 @@
"@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",

View file

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

View file

@ -1,58 +0,0 @@
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));
}
}

View file

@ -1,234 +0,0 @@
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

@ -1,73 +0,0 @@
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,10 +10,6 @@ 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);
@ -22,29 +18,16 @@ 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, baseColor?: string): void {
enterStealth(type: 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;
}
}
/**
@ -53,9 +36,6 @@ export class Stealth extends Component {
exitStealth(): void {
this.isStealthed = false;
this.visibility = 1.0;
this.camouflageColor = null;
this.sizeMultiplier = 1.0;
this.formAppearance = null;
}
/**

View file

@ -1,156 +0,0 @@
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();
});
}

View file

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

View file

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

View file

@ -44,7 +44,7 @@ export class Engine {
this.ctx.imageSmoothingEnabled = false;
this.deltaTime = 0;
this.tileMap = LevelLoader.loadDesignedLevel(200, 150, 16);
this.tileMap = LevelLoader.loadSimpleLevel(20, 15, 16);
}
/**
@ -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, SystemName.MUSIC];
const unskippedSystems = [SystemName.MENU, SystemName.UI, SystemName.RENDER];
this.systems.forEach((system) => {
if (isPaused && !unskippedSystems.includes(system.name as SystemName)) {

View file

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

View file

@ -11,9 +11,6 @@ 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',
}
/**

View file

@ -27,99 +27,4 @@ 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;
}
}

View file

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

View file

@ -15,9 +15,6 @@ 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 { CameraSystem } from './systems/CameraSystem.ts';
import { Position } from './components/Position.ts';
import { Velocity } from './components/Velocity.ts';
@ -33,14 +30,9 @@ 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 { Camera } from './components/Camera.ts';
import { EntityType, ComponentType } from './core/Constants.ts';
import type { Entity } from './core/Entity.ts';
import { setupMusic, setupMusicHandlers } from './config/MusicConfig.ts';
import { setupSFX } from './config/SFXConfig.ts';
const canvas = document.getElementById('game-canvas') as HTMLCanvasElement;
if (!canvas) {
@ -50,9 +42,6 @@ if (!canvas) {
engine.addSystem(new MenuSystem(engine));
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());
@ -69,9 +58,7 @@ if (!canvas) {
engine.addSystem(new UISystem(engine));
const player = engine.createEntity();
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 Position(160, 120));
player.addComponent(new Velocity(0, 0));
player.addComponent(new Sprite('#00ff96', 14, 14, EntityType.SLIME));
player.addComponent(new Health(100));
@ -88,17 +75,6 @@ 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));
@ -145,36 +121,12 @@ if (!canvas) {
return creature;
}
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<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) {
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);
return;
}
}
}
const numberOfEnemies = 20;
for (let i = 0; i < numberOfEnemies / 2; i++) {
spawnEnemyNearPlayer();
}
setInterval(() => {
@ -182,55 +134,26 @@ if (!canvas) {
.getEntities()
.filter((e) => e.hasComponent(ComponentType.AI) && e !== player);
if (existingCreatures.length < numberOfEnemies) {
spawnEnemyNearPlayer();
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);
}
}, 5000);
const musicSystem = engine.systems.find((s) => s.name === 'MusicSystem') as
| MusicSystem
| undefined;
if (musicSystem) {
const musicEntity = engine.createEntity();
const music = new Music();
const audioCtx = musicSystem.getAudioContext();
setupMusic(music, audioCtx);
musicEntity.addComponent(music);
setupMusicHandlers(music, musicSystem, canvas);
const sfxSystem = engine.systems.find((s) => s.name === 'SoundEffectsSystem') as
| SoundEffectsSystem
| undefined;
if (sfxSystem) {
const sfxEntity = engine.createEntity();
const sfx = new SoundEffects(audioCtx);
setupSFX(sfx, audioCtx);
sfxEntity.addComponent(sfx);
}
} else {
canvas.addEventListener('click', () => {
canvas.focus();
});
}
canvas.focus();
engine.start();
interface WindowWithGame {
gameEngine?: Engine;
player?: Entity;
music?: Music;
}
(window as WindowWithGame).gameEngine = engine;
(window as WindowWithGame).player = player;
if (musicSystem) {
const musicEntity = engine.getEntities().find((e) => e.hasComponent(ComponentType.MUSIC));
if (musicEntity) {
const music = musicEntity.getComponent<Music>(ComponentType.MUSIC);
if (music) {
(window as WindowWithGame).music = music;
}
}
}
canvas.addEventListener('click', () => {
canvas.focus();
});
}

View file

@ -1,6 +1,5 @@
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';
@ -90,12 +89,6 @@ 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;
}
}

View file

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

View file

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

View file

@ -1,50 +0,0 @@
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,8 +1,7 @@
import { System } from '../core/System.ts';
import { SystemName, ComponentType } from '../core/Constants.ts';
import { SystemName } 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;
@ -156,28 +155,12 @@ export class InputSystem extends System {
/**
* Get the current mouse position in world coordinates.
* @returns The mouse coordinates in world space
* @returns The mouse coordinates
*/
getMousePosition(): { x: number; y: number } {
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<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.
* @param button - The button index (0=left, 1=middle, 2=right)

View file

@ -77,26 +77,6 @@ export class MovementSystem extends System {
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;
if (position.x < 0) {
position.x = 0;
@ -113,7 +93,6 @@ export class MovementSystem extends System {
position.y = canvas.height;
velocity.vy = 0;
}
}
});
}
}

View file

@ -1,76 +0,0 @@
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,12 +75,6 @@ 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;
@ -99,14 +93,6 @@ 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;
if (
position.x < 0 ||
@ -116,7 +102,6 @@ export class ProjectileSystem extends System {
) {
this.engine.removeEntity(entity);
}
}
});
}
}

View file

@ -18,7 +18,6 @@ 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';
@ -27,7 +26,6 @@ import type { SkillEffectSystem, SkillEffect } from './SkillEffectSystem.ts';
*/
export class RenderSystem extends System {
ctx: CanvasRenderingContext2D;
private camera: Camera | null;
/**
* @param engine - The game engine instance
@ -38,37 +36,6 @@ 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<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 };
}
/**
@ -123,14 +90,11 @@ export class RenderSystem extends System {
ctx.fillStyle = Palette.DARKER_BLUE;
for (let i = 0; i < 20; i++) {
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 x = Math.floor((i * 70 + Math.sin(i) * 30) % width);
const y = Math.floor((i * 50 + Math.cos(i) * 40) % height);
const size = Math.floor(25 + (i % 4) * 15);
if (screen.x + size > 0 && screen.x < width && screen.y + size > 0 && screen.y < height) {
ctx.fillRect(screen.x, screen.y, size, size);
}
ctx.fillRect(x, y, size, size);
}
}
@ -141,35 +105,18 @@ 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 = startRow; r < endRow; r++) {
for (let c = startCol; c < endCol; c++) {
for (let r = 0; r < tileMap.rows; r++) {
for (let c = 0; c < tileMap.cols; c++) {
if (tileMap.getTile(c, r) === 1) {
const worldX = c * tileSize;
const worldY = r * tileSize;
const screen = this.worldToScreen(worldX, worldY);
ctx.fillRect(screen.x, screen.y, tileSize, tileSize);
ctx.fillRect(c * tileSize, r * tileSize, tileSize, tileSize);
ctx.fillStyle = Palette.ROYAL_BLUE;
ctx.fillRect(screen.x, screen.y, tileSize, 2);
ctx.fillRect(c * tileSize, r * tileSize, tileSize, 2);
ctx.fillStyle = Palette.DARK_BLUE;
}
}
@ -191,9 +138,8 @@ export class RenderSystem extends System {
this.ctx.save();
const screen = this.worldToScreen(position.x, position.y);
const drawX = Math.floor(screen.x);
const drawY = Math.floor(screen.y);
const drawX = Math.floor(position.x);
const drawY = Math.floor(position.y);
let alpha = sprite.alpha;
if (isDeadFade && health && health.isDead()) {
@ -209,21 +155,13 @@ export class RenderSystem extends System {
this.ctx.translate(drawX, drawY + (sprite.yOffset || 0));
this.ctx.scale(sprite.scale, sprite.scale);
const stealth = entity.getComponent<Stealth>(ComponentType.STEALTH);
let effectiveShape = sprite.shape;
if (stealth && stealth.isStealthed && stealth.formAppearance) {
effectiveShape = stealth.formAppearance;
}
if (effectiveShape === EntityType.SLIME) {
if (sprite.shape === EntityType.SLIME) {
sprite.animationTime += deltaTime;
sprite.morphAmount = Math.sin(sprite.animationTime * 3) * 0.2 + 0.8;
}
let drawColor = sprite.color;
if (effectiveShape === EntityType.SLIME && (!stealth || !stealth.isStealthed)) {
drawColor = Palette.CYAN;
}
if (sprite.shape === EntityType.SLIME) drawColor = Palette.CYAN;
this.ctx.fillStyle = drawColor;
@ -233,7 +171,7 @@ export class RenderSystem extends System {
sprite.animationState = isMoving ? AnimationState.WALK : AnimationState.IDLE;
}
let spriteData = SpriteLibrary[effectiveShape as string];
let spriteData = SpriteLibrary[sprite.shape as string];
if (!spriteData) {
spriteData = SpriteLibrary[EntityType.SLIME];
}
@ -241,6 +179,7 @@ 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];
}
@ -307,6 +246,7 @@ export class RenderSystem extends System {
this.ctx.restore();
}
const stealth = entity.getComponent<Stealth>(ComponentType.STEALTH);
if (stealth && stealth.isStealthed) {
this.drawStealthIndicator(stealth, sprite);
}
@ -345,9 +285,8 @@ 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 screen = this.worldToScreen(p.x, p.y);
const x = Math.floor(screen.x);
const y = Math.floor(screen.y);
const x = Math.floor(p.x);
const y = Math.floor(p.y);
const size = Math.floor(p.size);
ctx.fillRect(x - size / 2, y - size / 2, size, size);
@ -399,13 +338,7 @@ export class RenderSystem extends System {
ctx.save();
const stealth = entity.getComponent<Stealth>(ComponentType.STEALTH);
let effectiveShape = sprite.shape;
if (stealth && stealth.isStealthed && stealth.formAppearance) {
effectiveShape = stealth.formAppearance;
}
if (effectiveShape === EntityType.SLIME) {
if (sprite.shape === EntityType.SLIME) {
ctx.strokeStyle = Palette.CYAN;
ctx.lineWidth = 3;
ctx.lineCap = 'round';
@ -421,7 +354,7 @@ export class RenderSystem extends System {
ctx.beginPath();
ctx.arc(length, 0, 2, 0, Math.PI * 2);
ctx.fill();
} else if (effectiveShape === EntityType.BEAST) {
} else if (sprite.shape === EntityType.BEAST) {
ctx.strokeStyle = Palette.WHITE;
ctx.lineWidth = 2;
ctx.globalAlpha = alpha;
@ -433,7 +366,7 @@ export class RenderSystem extends System {
ctx.beginPath();
ctx.arc(0, 0, radius, start - 0.5, start + 0.5);
ctx.stroke();
} else if (effectiveShape === EntityType.HUMANOID) {
} else if (sprite.shape === EntityType.HUMANOID) {
ctx.strokeStyle = `rgba(255, 255, 255, ${alpha})`;
ctx.lineWidth = 4;
@ -481,24 +414,28 @@ export class RenderSystem extends System {
*/
drawGlowEffect(sprite: Sprite): void {
const ctx = this.ctx;
const time = Date.now() * 0.001;
const pulse = 0.5 + Math.sin(time * 3) * 0.3;
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 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)');
@ -549,8 +486,7 @@ export class RenderSystem extends System {
const progress = Math.min(1.0, effect.time / effect.lifetime);
const alpha = Math.max(0, 1.0 - progress);
const screen = this.worldToScreen(effect.x, effect.y);
ctx.translate(screen.x, screen.y);
ctx.translate(effect.x, effect.y);
ctx.rotate(effect.angle);
const gradient = ctx.createRadialGradient(0, 0, 0, 0, 0, effect.range);
@ -600,13 +536,10 @@ 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(startScreen.x, startScreen.y, 10, 5, 0, 0, Math.PI * 2);
ctx.ellipse(effect.startX, effect.startY, 10, 5, 0, 0, Math.PI * 2);
ctx.fill();
const alpha = Math.max(0, 0.8 * (1.0 - progress));
@ -614,15 +547,15 @@ export class RenderSystem extends System {
ctx.lineWidth = 2;
ctx.lineCap = 'round';
ctx.beginPath();
ctx.moveTo(startScreen.x, startScreen.y);
ctx.lineTo(currentScreen.x, currentScreen.y);
ctx.moveTo(effect.startX, effect.startY);
ctx.lineTo(currentX, currentY);
ctx.stroke();
const ringSize = progress * 40;
ctx.strokeStyle = `rgba(255, 255, 255, ${0.4 * (1 - progress)})`;
ctx.lineWidth = 1;
ctx.beginPath();
ctx.arc(startScreen.x, startScreen.y, ringSize, 0, Math.PI * 2);
ctx.arc(effect.startX, effect.startY, ringSize, 0, Math.PI * 2);
ctx.stroke();
}
@ -635,20 +568,18 @@ 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(screen.x, screen.y, size, 0, Math.PI * 2);
ctx.arc(effect.x, effect.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 = screen.x + Math.cos(angle) * dist;
const y = screen.y + Math.sin(angle) * dist;
const x = effect.x + Math.cos(angle) * dist;
const y = effect.y + Math.sin(angle) * dist;
ctx.fillStyle = `rgba(255, 150, 0, ${alpha})`;
ctx.beginPath();

View file

@ -1,7 +1,6 @@
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';
@ -56,7 +55,6 @@ export class SkillSystem extends System {
if (skills) {
skills.setCooldown(skillId, skill.cooldown);
}
this.engine.emit(Events.SKILL_COOLDOWN_STARTED, { skillId });
}
}
}

View file

@ -1,105 +0,0 @@
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,13 +1,10 @@
import { System } from '../core/System.ts';
import { SystemName, ComponentType, EntityType } from '../core/Constants.ts';
import { ColorSampler } from '../core/ColorSampler.ts';
import { SystemName, ComponentType } from '../core/Constants.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';
@ -48,79 +45,42 @@ export class StealthSystem extends System {
stealth.stealthType = form;
}
const sprite = entity.getComponent<Sprite>(ComponentType.SPRITE);
const position = entity.getComponent<Position>(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 {
if (sprite) {
stealth.enterStealth(stealth.stealthType, sprite.color);
} else {
stealth.enterStealth(stealth.stealthType);
}
}
}
}
const isMoving = velocity && (Math.abs(velocity.vx) > 1 || Math.abs(velocity.vy) > 1);
const isInCombat = combat && combat.isAttacking;
stealth.updateStealth(isMoving || false, isInCombat || false);
if (stealth.isStealthed && sprite && position) {
if (stealth.isStealthed) {
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;
}
}
});
}
}

View file

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