Compare commits
10 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
109cee0052 | ||
| 2858898ec2 | |||
| c859e20ffc | |||
| 62e58f77ae | |||
|
|
b32ac22be8 | ||
| 71c8129f37 | |||
| 66719912ba | |||
| 5a24d6a2af | |||
| 2213f64e60 | |||
| 143072f0a0 |
33 changed files with 1715 additions and 162 deletions
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
||||||
0.3.0
|
0.5.0
|
||||||
|
|
|
||||||
|
|
@ -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
19
package-lock.json
generated
|
|
@ -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"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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
58
src/components/Camera.ts
Normal 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
234
src/components/Music.ts
Normal file
|
|
@ -0,0 +1,234 @@
|
||||||
|
import { Component } from '../core/Component.ts';
|
||||||
|
import { ComponentType } from '../core/Constants.ts';
|
||||||
|
import type { Sequence } from '../core/Music.ts';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Component for managing background music and sound effects.
|
||||||
|
*/
|
||||||
|
export class Music extends Component {
|
||||||
|
sequences: Map<string, Sequence>;
|
||||||
|
currentSequence: Sequence | null;
|
||||||
|
activeSequences: Set<Sequence>;
|
||||||
|
volume: number;
|
||||||
|
enabled: boolean;
|
||||||
|
private sequenceChain: string[];
|
||||||
|
private currentChainIndex: number;
|
||||||
|
private sequenceVolumes: Map<Sequence, number>;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super(ComponentType.MUSIC);
|
||||||
|
this.sequences = new Map();
|
||||||
|
this.currentSequence = null;
|
||||||
|
this.activeSequences = new Set();
|
||||||
|
this.volume = 0.5;
|
||||||
|
this.enabled = true;
|
||||||
|
this.sequenceChain = [];
|
||||||
|
this.currentChainIndex = 0;
|
||||||
|
this.sequenceVolumes = new Map();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a music sequence.
|
||||||
|
* @param name - Unique identifier for the sequence
|
||||||
|
* @param sequence - The sequence instance
|
||||||
|
*/
|
||||||
|
addSequence(name: string, sequence: Sequence): void {
|
||||||
|
this.sequences.set(name, sequence);
|
||||||
|
if (sequence.gain) {
|
||||||
|
sequence.gain.gain.value = this.volume;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Play a sequence by name.
|
||||||
|
* @param name - The sequence identifier
|
||||||
|
*/
|
||||||
|
playSequence(name: string): void {
|
||||||
|
if (!this.enabled) return;
|
||||||
|
|
||||||
|
const sequence = this.sequences.get(name);
|
||||||
|
if (sequence) {
|
||||||
|
this.stop();
|
||||||
|
this.currentSequence = sequence;
|
||||||
|
if (sequence.gain) {
|
||||||
|
sequence.gain.gain.value = this.volume;
|
||||||
|
}
|
||||||
|
sequence.play();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Play multiple sequences simultaneously (polyphony).
|
||||||
|
* @param sequenceConfigs - Array of configs with name, optional delay in beats, and optional loop
|
||||||
|
*/
|
||||||
|
playSequences(sequenceConfigs: Array<{ name: string; delay?: number; loop?: boolean }>): void {
|
||||||
|
if (!this.enabled || sequenceConfigs.length === 0) return;
|
||||||
|
|
||||||
|
const firstSeq = this.sequences.get(sequenceConfigs[0].name);
|
||||||
|
if (!firstSeq || !firstSeq.ac) return;
|
||||||
|
|
||||||
|
const ac = firstSeq.ac;
|
||||||
|
const when = ac.currentTime;
|
||||||
|
const tempo = firstSeq.tempo || 120;
|
||||||
|
|
||||||
|
sequenceConfigs.forEach((config) => {
|
||||||
|
const sequence = this.sequences.get(config.name);
|
||||||
|
if (!sequence) return;
|
||||||
|
|
||||||
|
if (config.loop !== undefined) {
|
||||||
|
sequence.loop = config.loop;
|
||||||
|
}
|
||||||
|
if (sequence.gain) {
|
||||||
|
sequence.gain.gain.value = this.volume;
|
||||||
|
}
|
||||||
|
|
||||||
|
const delaySeconds = config.delay ? (60 / tempo) * config.delay : 0;
|
||||||
|
sequence.play(when + delaySeconds);
|
||||||
|
this.activeSequences.add(sequence);
|
||||||
|
|
||||||
|
if (!this.currentSequence) {
|
||||||
|
this.currentSequence = sequence;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Chain multiple sequences together in order (sequential playback).
|
||||||
|
* @param sequenceNames - Array of sequence names to play in order
|
||||||
|
*/
|
||||||
|
chainSequences(sequenceNames: string[]): void {
|
||||||
|
if (!this.enabled || sequenceNames.length === 0) return;
|
||||||
|
|
||||||
|
this.stop();
|
||||||
|
this.sequenceChain = sequenceNames;
|
||||||
|
this.currentChainIndex = 0;
|
||||||
|
|
||||||
|
this.playNextInChain();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Play the next sequence in the chain.
|
||||||
|
*/
|
||||||
|
private playNextInChain(): void {
|
||||||
|
if (!this.enabled || this.sequenceChain.length === 0) return;
|
||||||
|
|
||||||
|
const seqName = this.sequenceChain[this.currentChainIndex];
|
||||||
|
const sequence = this.sequences.get(seqName);
|
||||||
|
if (!sequence) return;
|
||||||
|
|
||||||
|
this.currentSequence = sequence;
|
||||||
|
sequence.loop = false;
|
||||||
|
if (sequence.gain) {
|
||||||
|
sequence.gain.gain.value = this.volume;
|
||||||
|
}
|
||||||
|
|
||||||
|
sequence.play();
|
||||||
|
if (sequence.osc) {
|
||||||
|
const nextIndex = (this.currentChainIndex + 1) % this.sequenceChain.length;
|
||||||
|
sequence.osc.onended = () => {
|
||||||
|
if (this.enabled) {
|
||||||
|
this.currentChainIndex = nextIndex;
|
||||||
|
this.playNextInChain();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop current playback.
|
||||||
|
*/
|
||||||
|
stop(): void {
|
||||||
|
this.activeSequences.forEach((seq) => {
|
||||||
|
seq.stop();
|
||||||
|
});
|
||||||
|
this.activeSequences.clear();
|
||||||
|
if (this.currentSequence) {
|
||||||
|
this.currentSequence.stop();
|
||||||
|
this.currentSequence = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the volume (0.0 to 1.0).
|
||||||
|
* @param volume - Volume level
|
||||||
|
*/
|
||||||
|
setVolume(volume: number): void {
|
||||||
|
this.volume = Math.max(0, Math.min(1, volume));
|
||||||
|
if (this.currentSequence && this.currentSequence.gain) {
|
||||||
|
this.currentSequence.gain.gain.value = this.volume;
|
||||||
|
}
|
||||||
|
this.sequences.forEach((seq) => {
|
||||||
|
if (seq.gain) {
|
||||||
|
seq.gain.gain.value = this.volume;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enable or disable music playback.
|
||||||
|
* @param enabled - Whether music should be enabled
|
||||||
|
*/
|
||||||
|
setEnabled(enabled: boolean): void {
|
||||||
|
this.enabled = enabled;
|
||||||
|
if (!enabled) {
|
||||||
|
this.stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pause all active sequences by setting their gain to 0.
|
||||||
|
*/
|
||||||
|
pause(): void {
|
||||||
|
this.activeSequences.forEach((seq) => {
|
||||||
|
if (seq.gain) {
|
||||||
|
const currentVolume = seq.gain.gain.value;
|
||||||
|
this.sequenceVolumes.set(seq, currentVolume);
|
||||||
|
seq.gain.gain.value = 0;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (this.currentSequence && this.currentSequence.gain) {
|
||||||
|
const currentVolume = this.currentSequence.gain.gain.value;
|
||||||
|
this.sequenceVolumes.set(this.currentSequence, currentVolume);
|
||||||
|
this.currentSequence.gain.gain.value = 0;
|
||||||
|
}
|
||||||
|
this.sequences.forEach((seq) => {
|
||||||
|
if (seq.gain && seq.gain.gain.value > 0) {
|
||||||
|
const currentVolume = seq.gain.gain.value;
|
||||||
|
this.sequenceVolumes.set(seq, currentVolume);
|
||||||
|
seq.gain.gain.value = 0;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resume all active sequences by restoring their volume.
|
||||||
|
*/
|
||||||
|
resume(): void {
|
||||||
|
this.activeSequences.forEach((seq) => {
|
||||||
|
if (seq.gain) {
|
||||||
|
const savedVolume = this.sequenceVolumes.get(seq);
|
||||||
|
if (savedVolume !== undefined) {
|
||||||
|
seq.gain.gain.value = savedVolume;
|
||||||
|
} else {
|
||||||
|
seq.gain.gain.value = this.volume;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (this.currentSequence && this.currentSequence.gain) {
|
||||||
|
const savedVolume = this.sequenceVolumes.get(this.currentSequence);
|
||||||
|
if (savedVolume !== undefined) {
|
||||||
|
this.currentSequence.gain.gain.value = savedVolume;
|
||||||
|
} else {
|
||||||
|
this.currentSequence.gain.gain.value = this.volume;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.sequences.forEach((seq) => {
|
||||||
|
if (seq.gain) {
|
||||||
|
const savedVolume = this.sequenceVolumes.get(seq);
|
||||||
|
if (savedVolume !== undefined) {
|
||||||
|
seq.gain.gain.value = savedVolume;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
73
src/components/SoundEffects.ts
Normal file
73
src/components/SoundEffects.ts
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
import { Component } from '../core/Component.ts';
|
||||||
|
import { ComponentType } from '../core/Constants.ts';
|
||||||
|
import type { Sequence } from '../core/Music.ts';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Component for managing sound effects.
|
||||||
|
* Sound effects are short, one-shot audio sequences.
|
||||||
|
*/
|
||||||
|
export class SoundEffects extends Component {
|
||||||
|
sounds: Map<string, Sequence>;
|
||||||
|
volume: number;
|
||||||
|
enabled: boolean;
|
||||||
|
audioContext: AudioContext | null;
|
||||||
|
|
||||||
|
constructor(audioContext?: AudioContext) {
|
||||||
|
super(ComponentType.SOUND_EFFECTS);
|
||||||
|
this.sounds = new Map();
|
||||||
|
this.volume = 0.15; // Reduced default volume
|
||||||
|
this.enabled = true;
|
||||||
|
this.audioContext = audioContext || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a sound effect sequence.
|
||||||
|
* @param name - Unique identifier for the sound
|
||||||
|
* @param sequence - The sequence instance (should be short, non-looping)
|
||||||
|
*/
|
||||||
|
addSound(name: string, sequence: Sequence): void {
|
||||||
|
sequence.loop = false; // SFX should never loop
|
||||||
|
this.sounds.set(name, sequence);
|
||||||
|
if (sequence.gain) {
|
||||||
|
sequence.gain.gain.value = this.volume;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Play a sound effect by name.
|
||||||
|
* @param name - The sound identifier
|
||||||
|
*/
|
||||||
|
play(name: string): void {
|
||||||
|
if (!this.enabled) return;
|
||||||
|
|
||||||
|
const sound = this.sounds.get(name);
|
||||||
|
if (sound) {
|
||||||
|
sound.stop();
|
||||||
|
if (sound.gain) {
|
||||||
|
sound.gain.gain.value = this.volume;
|
||||||
|
}
|
||||||
|
sound.play();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the volume (0.0 to 1.0).
|
||||||
|
* @param volume - Volume level
|
||||||
|
*/
|
||||||
|
setVolume(volume: number): void {
|
||||||
|
this.volume = Math.max(0, Math.min(1, volume));
|
||||||
|
this.sounds.forEach((seq) => {
|
||||||
|
if (seq.gain) {
|
||||||
|
seq.gain.gain.value = this.volume;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enable or disable sound effects.
|
||||||
|
* @param enabled - Whether sound effects should be enabled
|
||||||
|
*/
|
||||||
|
setEnabled(enabled: boolean): void {
|
||||||
|
this.enabled = enabled;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
156
src/config/MusicConfig.ts
Normal file
|
|
@ -0,0 +1,156 @@
|
||||||
|
import { Sequence } from '../core/Music.ts';
|
||||||
|
import type { Music } from '../components/Music.ts';
|
||||||
|
import type { MusicSystem } from '../systems/MusicSystem.ts';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configure and setup background music.
|
||||||
|
* @param music - Music component instance
|
||||||
|
* @param audioCtx - AudioContext instance
|
||||||
|
*/
|
||||||
|
export function setupMusic(music: Music, audioCtx: AudioContext): void {
|
||||||
|
const tempo = 132;
|
||||||
|
|
||||||
|
const lead = new Sequence(audioCtx, tempo, [
|
||||||
|
'F4 e',
|
||||||
|
'Ab4 e',
|
||||||
|
'C5 e',
|
||||||
|
'F5 e',
|
||||||
|
'C5 e',
|
||||||
|
'Ab4 e',
|
||||||
|
'F4 e',
|
||||||
|
'C4 e',
|
||||||
|
'F4 e',
|
||||||
|
'Ab4 e',
|
||||||
|
'C5 e',
|
||||||
|
'F5 e',
|
||||||
|
'C5 e',
|
||||||
|
'Ab4 e',
|
||||||
|
'F4 e',
|
||||||
|
'C4 e',
|
||||||
|
'G4 e',
|
||||||
|
'Bb4 e',
|
||||||
|
'D5 e',
|
||||||
|
'G5 e',
|
||||||
|
'D5 e',
|
||||||
|
'Bb4 e',
|
||||||
|
'G4 e',
|
||||||
|
'D4 e',
|
||||||
|
'F4 e',
|
||||||
|
'Ab4 e',
|
||||||
|
'C5 e',
|
||||||
|
'F5 e',
|
||||||
|
'C5 e',
|
||||||
|
'Ab4 e',
|
||||||
|
'F4 e',
|
||||||
|
'C4 e',
|
||||||
|
]);
|
||||||
|
lead.staccato = 0.1;
|
||||||
|
lead.smoothing = 0.3;
|
||||||
|
lead.waveType = 'triangle';
|
||||||
|
lead.loop = true;
|
||||||
|
if (lead.gain) {
|
||||||
|
lead.gain.gain.value = 0.8;
|
||||||
|
}
|
||||||
|
music.addSequence('lead', lead);
|
||||||
|
|
||||||
|
const harmony = new Sequence(audioCtx, tempo, [
|
||||||
|
'C4 e',
|
||||||
|
'Eb4 e',
|
||||||
|
'F4 e',
|
||||||
|
'Ab4 e',
|
||||||
|
'F4 e',
|
||||||
|
'Eb4 e',
|
||||||
|
'C4 e',
|
||||||
|
'Ab3 e',
|
||||||
|
'C4 e',
|
||||||
|
'Eb4 e',
|
||||||
|
'F4 e',
|
||||||
|
'Ab4 e',
|
||||||
|
'F4 e',
|
||||||
|
'Eb4 e',
|
||||||
|
'C4 e',
|
||||||
|
'Ab3 e',
|
||||||
|
'D4 e',
|
||||||
|
'F4 e',
|
||||||
|
'G4 e',
|
||||||
|
'Bb4 e',
|
||||||
|
'G4 e',
|
||||||
|
'F4 e',
|
||||||
|
'D4 e',
|
||||||
|
'Bb3 e',
|
||||||
|
'C4 e',
|
||||||
|
'Eb4 e',
|
||||||
|
'F4 e',
|
||||||
|
'Ab4 e',
|
||||||
|
'F4 e',
|
||||||
|
'Eb4 e',
|
||||||
|
'C4 e',
|
||||||
|
'Ab3 e',
|
||||||
|
]);
|
||||||
|
harmony.staccato = 0.15;
|
||||||
|
harmony.smoothing = 0.4;
|
||||||
|
harmony.waveType = 'triangle';
|
||||||
|
harmony.loop = true;
|
||||||
|
if (harmony.gain) {
|
||||||
|
harmony.gain.gain.value = 0.6;
|
||||||
|
}
|
||||||
|
music.addSequence('harmony', harmony);
|
||||||
|
|
||||||
|
const bass = new Sequence(audioCtx, tempo, [
|
||||||
|
'F2 q',
|
||||||
|
'C3 q',
|
||||||
|
'F2 q',
|
||||||
|
'C3 q',
|
||||||
|
'G2 q',
|
||||||
|
'D3 q',
|
||||||
|
'G2 q',
|
||||||
|
'D3 q',
|
||||||
|
'F2 q',
|
||||||
|
'C3 q',
|
||||||
|
'F2 q',
|
||||||
|
'C3 q',
|
||||||
|
]);
|
||||||
|
bass.staccato = 0.05;
|
||||||
|
bass.smoothing = 0.5;
|
||||||
|
bass.waveType = 'triangle';
|
||||||
|
bass.loop = true;
|
||||||
|
if (bass.gain) {
|
||||||
|
bass.gain.gain.value = 0.7;
|
||||||
|
}
|
||||||
|
if (bass.bass) {
|
||||||
|
bass.bass.gain.value = 4;
|
||||||
|
bass.bass.frequency.value = 80;
|
||||||
|
}
|
||||||
|
music.addSequence('bass', bass);
|
||||||
|
|
||||||
|
music.playSequences([
|
||||||
|
{ name: 'lead', loop: true },
|
||||||
|
{ name: 'harmony', loop: true },
|
||||||
|
{ name: 'bass', loop: true },
|
||||||
|
]);
|
||||||
|
music.setVolume(0.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setup music event handlers for canvas interaction.
|
||||||
|
* @param music - Music component instance
|
||||||
|
* @param musicSystem - MusicSystem instance
|
||||||
|
* @param canvas - Canvas element
|
||||||
|
*/
|
||||||
|
export function setupMusicHandlers(
|
||||||
|
music: Music,
|
||||||
|
musicSystem: MusicSystem,
|
||||||
|
canvas: HTMLCanvasElement
|
||||||
|
): void {
|
||||||
|
canvas.addEventListener('click', () => {
|
||||||
|
musicSystem.resumeAudioContext();
|
||||||
|
if (music.enabled && music.activeSequences.size === 0) {
|
||||||
|
music.playSequences([
|
||||||
|
{ name: 'lead', loop: true },
|
||||||
|
{ name: 'harmony', loop: true },
|
||||||
|
{ name: 'bass', loop: true },
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
canvas.focus();
|
||||||
|
});
|
||||||
|
}
|
||||||
35
src/config/SFXConfig.ts
Normal file
35
src/config/SFXConfig.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
import { Sequence } from '../core/Music.ts';
|
||||||
|
import type { SoundEffects } from '../components/SoundEffects.ts';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configure and setup sound effects.
|
||||||
|
* @param sfx - SoundEffects component instance
|
||||||
|
* @param ac - AudioContext instance
|
||||||
|
*/
|
||||||
|
export function setupSFX(sfx: SoundEffects, ac: AudioContext): void {
|
||||||
|
const attackSound = new Sequence(ac, 120, ['C5 s']);
|
||||||
|
attackSound.staccato = 0.8;
|
||||||
|
sfx.addSound('attack', attackSound);
|
||||||
|
|
||||||
|
const absorbSound = new Sequence(ac, 120, ['G4 e']);
|
||||||
|
absorbSound.staccato = 0.5;
|
||||||
|
sfx.addSound('absorb', absorbSound);
|
||||||
|
|
||||||
|
const skillSound = new Sequence(ac, 120, ['A4 e']);
|
||||||
|
skillSound.staccato = 0.6;
|
||||||
|
sfx.addSound('skill', skillSound);
|
||||||
|
|
||||||
|
const damageSound = new Sequence(ac, 120, ['F4 s']);
|
||||||
|
damageSound.staccato = 0.8;
|
||||||
|
sfx.addSound('damage', damageSound);
|
||||||
|
|
||||||
|
const shootSound = new Sequence(ac, 120, ['C5 s']);
|
||||||
|
shootSound.staccato = 0.9;
|
||||||
|
sfx.addSound('shoot', shootSound);
|
||||||
|
|
||||||
|
const impactSound = new Sequence(ac, 120, ['G4 s']);
|
||||||
|
impactSound.staccato = 0.7;
|
||||||
|
sfx.addSound('impact', impactSound);
|
||||||
|
|
||||||
|
sfx.setVolume(0.02);
|
||||||
|
}
|
||||||
102
src/core/ColorSampler.ts
Normal file
102
src/core/ColorSampler.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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',
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)) {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -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
269
src/core/Music.ts
Normal file
|
|
@ -0,0 +1,269 @@
|
||||||
|
/**
|
||||||
|
* Note class - represents a single musical note
|
||||||
|
*/
|
||||||
|
export class Note {
|
||||||
|
frequency: number;
|
||||||
|
duration: number;
|
||||||
|
|
||||||
|
constructor(str: string) {
|
||||||
|
const couple = str.split(/\s+/);
|
||||||
|
this.frequency = Note.getFrequency(couple[0]) || 0;
|
||||||
|
this.duration = Note.getDuration(couple[1]) || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a note name (e.g. 'A4') to a frequency (e.g. 440.00)
|
||||||
|
*/
|
||||||
|
static getFrequency(name: string): number {
|
||||||
|
const enharmonics = 'B#-C|C#-Db|D|D#-Eb|E-Fb|E#-F|F#-Gb|G|G#-Ab|A|A#-Bb|B-Cb';
|
||||||
|
const middleC = 440 * Math.pow(Math.pow(2, 1 / 12), -9);
|
||||||
|
const octaveOffset = 4;
|
||||||
|
const num = /(\d+)/;
|
||||||
|
const offsets: Record<string, number> = {};
|
||||||
|
|
||||||
|
enharmonics.split('|').forEach((val, i) => {
|
||||||
|
val.split('-').forEach((note) => {
|
||||||
|
offsets[note] = i;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const couple = name.split(num);
|
||||||
|
const distance = offsets[couple[0]] ?? 0;
|
||||||
|
const octaveDiff = parseInt(couple[1] || String(octaveOffset), 10) - octaveOffset;
|
||||||
|
const freq = middleC * Math.pow(Math.pow(2, 1 / 12), distance);
|
||||||
|
return freq * Math.pow(2, octaveDiff);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a duration string (e.g. 'q') to a number (e.g. 1)
|
||||||
|
*/
|
||||||
|
static getDuration(symbol: string): number {
|
||||||
|
const numeric = /^[0-9.]+$/;
|
||||||
|
if (numeric.test(symbol)) {
|
||||||
|
return parseFloat(symbol);
|
||||||
|
}
|
||||||
|
return symbol
|
||||||
|
.toLowerCase()
|
||||||
|
.split('')
|
||||||
|
.reduce((prev, curr) => {
|
||||||
|
return (
|
||||||
|
prev +
|
||||||
|
(curr === 'w'
|
||||||
|
? 4
|
||||||
|
: curr === 'h'
|
||||||
|
? 2
|
||||||
|
: curr === 'q'
|
||||||
|
? 1
|
||||||
|
: curr === 'e'
|
||||||
|
? 0.5
|
||||||
|
: curr === 's'
|
||||||
|
? 0.25
|
||||||
|
: 0)
|
||||||
|
);
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sequence class - manages playback of musical sequences
|
||||||
|
*/
|
||||||
|
export class Sequence {
|
||||||
|
ac: AudioContext;
|
||||||
|
tempo: number;
|
||||||
|
loop: boolean;
|
||||||
|
smoothing: number;
|
||||||
|
staccato: number;
|
||||||
|
notes: Note[];
|
||||||
|
gain: GainNode;
|
||||||
|
bass: BiquadFilterNode | null;
|
||||||
|
mid: BiquadFilterNode | null;
|
||||||
|
treble: BiquadFilterNode | null;
|
||||||
|
waveType: OscillatorType | 'custom';
|
||||||
|
customWave?: [Float32Array, Float32Array];
|
||||||
|
osc: OscillatorNode | null;
|
||||||
|
|
||||||
|
constructor(ac?: AudioContext, tempo = 120, arr?: (Note | string)[]) {
|
||||||
|
this.ac = ac || new AudioContext();
|
||||||
|
this.tempo = tempo;
|
||||||
|
this.loop = true;
|
||||||
|
this.smoothing = 0;
|
||||||
|
this.staccato = 0;
|
||||||
|
this.notes = [];
|
||||||
|
this.bass = null;
|
||||||
|
this.mid = null;
|
||||||
|
this.treble = null;
|
||||||
|
this.osc = null;
|
||||||
|
this.waveType = 'square';
|
||||||
|
this.gain = this.ac.createGain();
|
||||||
|
this.createFxNodes();
|
||||||
|
if (arr) {
|
||||||
|
this.push(...arr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create gain and EQ nodes, then connect them
|
||||||
|
*/
|
||||||
|
createFxNodes(): void {
|
||||||
|
const eq: Array<[string, number]> = [
|
||||||
|
['bass', 100],
|
||||||
|
['mid', 1000],
|
||||||
|
['treble', 2500],
|
||||||
|
];
|
||||||
|
let prev: AudioNode = this.gain;
|
||||||
|
|
||||||
|
eq.forEach((config) => {
|
||||||
|
const filter = this.ac.createBiquadFilter();
|
||||||
|
filter.type = 'peaking';
|
||||||
|
filter.frequency.value = config[1];
|
||||||
|
prev.connect(filter);
|
||||||
|
prev = filter;
|
||||||
|
|
||||||
|
if (config[0] === 'bass') {
|
||||||
|
this.bass = filter;
|
||||||
|
} else if (config[0] === 'mid') {
|
||||||
|
this.mid = filter;
|
||||||
|
} else if (config[0] === 'treble') {
|
||||||
|
this.treble = filter;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
prev.connect(this.ac.destination);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Accepts Note instances or strings (e.g. 'A4 e')
|
||||||
|
*/
|
||||||
|
push(...notes: (Note | string)[]): this {
|
||||||
|
notes.forEach((note) => {
|
||||||
|
this.notes.push(note instanceof Note ? note : new Note(note));
|
||||||
|
});
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a custom waveform
|
||||||
|
*/
|
||||||
|
createCustomWave(real: number[], imag?: number[]): void {
|
||||||
|
if (!imag) {
|
||||||
|
imag = real;
|
||||||
|
}
|
||||||
|
this.waveType = 'custom';
|
||||||
|
this.customWave = [new Float32Array(real), new Float32Array(imag)];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recreate the oscillator node (happens on every play)
|
||||||
|
*/
|
||||||
|
createOscillator(): this {
|
||||||
|
this.stop();
|
||||||
|
this.osc = this.ac.createOscillator();
|
||||||
|
|
||||||
|
if (this.customWave) {
|
||||||
|
this.osc.setPeriodicWave(this.ac.createPeriodicWave(this.customWave[0], this.customWave[1]));
|
||||||
|
} else {
|
||||||
|
this.osc.type = this.waveType === 'custom' ? 'square' : this.waveType;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.gain) {
|
||||||
|
this.osc.connect(this.gain);
|
||||||
|
}
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schedule a note to play at the given time
|
||||||
|
*/
|
||||||
|
scheduleNote(index: number, when: number): number {
|
||||||
|
const duration = (60 / this.tempo) * this.notes[index].duration;
|
||||||
|
const cutoff = duration * (1 - (this.staccato || 0));
|
||||||
|
|
||||||
|
this.setFrequency(this.notes[index].frequency, when);
|
||||||
|
|
||||||
|
if (this.smoothing && this.notes[index].frequency) {
|
||||||
|
this.slide(index, when, cutoff);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.setFrequency(0, when + cutoff);
|
||||||
|
return when + duration;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the next note
|
||||||
|
*/
|
||||||
|
getNextNote(index: number): Note {
|
||||||
|
return this.notes[index < this.notes.length - 1 ? index + 1 : 0];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How long do we wait before beginning the slide?
|
||||||
|
*/
|
||||||
|
getSlideStartDelay(duration: number): number {
|
||||||
|
return duration - Math.min(duration, (60 / this.tempo) * this.smoothing);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Slide the note at index into the next note
|
||||||
|
*/
|
||||||
|
slide(index: number, when: number, cutoff: number): this {
|
||||||
|
const next = this.getNextNote(index);
|
||||||
|
const start = this.getSlideStartDelay(cutoff);
|
||||||
|
this.setFrequency(this.notes[index].frequency, when + start);
|
||||||
|
this.rampFrequency(next.frequency, when + cutoff);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set frequency at time
|
||||||
|
*/
|
||||||
|
setFrequency(freq: number, when: number): this {
|
||||||
|
if (this.osc) {
|
||||||
|
this.osc.frequency.setValueAtTime(freq, when);
|
||||||
|
}
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ramp to frequency at time
|
||||||
|
*/
|
||||||
|
rampFrequency(freq: number, when: number): this {
|
||||||
|
if (this.osc) {
|
||||||
|
this.osc.frequency.linearRampToValueAtTime(freq, when);
|
||||||
|
}
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run through all notes in the sequence and schedule them
|
||||||
|
*/
|
||||||
|
play(when?: number): this {
|
||||||
|
const startTime = typeof when === 'number' ? when : this.ac.currentTime;
|
||||||
|
|
||||||
|
this.createOscillator();
|
||||||
|
if (this.osc) {
|
||||||
|
this.osc.start(startTime);
|
||||||
|
|
||||||
|
let currentTime = startTime;
|
||||||
|
this.notes.forEach((_note, i) => {
|
||||||
|
currentTime = this.scheduleNote(i, currentTime);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.osc.stop(currentTime);
|
||||||
|
this.osc.onended = this.loop ? () => this.play(currentTime) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop playback
|
||||||
|
*/
|
||||||
|
stop(): this {
|
||||||
|
if (this.osc) {
|
||||||
|
this.osc.onended = null;
|
||||||
|
this.osc.disconnect();
|
||||||
|
this.osc = null;
|
||||||
|
}
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,57 +1,58 @@
|
||||||
/**
|
/**
|
||||||
* Simple 5x7 Matrix Pixel Font data.
|
* 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) {
|
||||||
|
|
|
||||||
111
src/main.ts
111
src/main.ts
|
|
@ -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;
|
|
||||||
const types = [EntityType.HUMANOID, EntityType.BEAST, EntityType.ELEMENTAL];
|
function spawnEnemyNearPlayer(): void {
|
||||||
const type = types[Math.floor(Math.random() * types.length)];
|
const playerPos = player.getComponent<Position>(ComponentType.POSITION);
|
||||||
createCreature(engine, x, y, type);
|
if (!playerPos) return;
|
||||||
|
|
||||||
|
const spawnRadius = 150;
|
||||||
|
const minDistance = 80;
|
||||||
|
const maxAttempts = 10;
|
||||||
|
|
||||||
|
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
||||||
|
const angle = Math.random() * Math.PI * 2;
|
||||||
|
const distance = minDistance + Math.random() * (spawnRadius - minDistance);
|
||||||
|
const x = playerPos.x + Math.cos(angle) * distance;
|
||||||
|
const y = playerPos.y + Math.sin(angle) * distance;
|
||||||
|
|
||||||
|
if (x >= 50 && x <= mapWidth - 50 && y >= 50 && y <= mapHeight - 50) {
|
||||||
|
const types = [EntityType.HUMANOID, EntityType.BEAST, EntityType.ELEMENTAL];
|
||||||
|
const type = types[Math.floor(Math.random() * types.length)];
|
||||||
|
createCreature(engine, x, y, type);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const numberOfEnemies = 20;
|
||||||
|
|
||||||
|
for (let i = 0; i < numberOfEnemies / 2; i++) {
|
||||||
|
spawnEnemyNearPlayer();
|
||||||
}
|
}
|
||||||
|
|
||||||
setInterval(() => {
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
50
src/systems/CameraSystem.ts
Normal file
50
src/systems/CameraSystem.ts
Normal 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();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,10 +156,26 @@ 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 } {
|
||||||
return { x: this.mouse.x, y: this.mouse.y };
|
if (!this.engine) {
|
||||||
|
return { x: this.mouse.x, y: this.mouse.y };
|
||||||
|
}
|
||||||
|
|
||||||
|
const cameraEntity = this.engine.entities.find((e) => e.hasComponent(ComponentType.CAMERA));
|
||||||
|
if (!cameraEntity) {
|
||||||
|
return { x: this.mouse.x, y: this.mouse.y };
|
||||||
|
}
|
||||||
|
|
||||||
|
const camera = cameraEntity.getComponent<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 };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -77,21 +77,42 @@ export class MovementSystem extends System {
|
||||||
velocity.vy *= Math.pow(friction, deltaTime * 60);
|
velocity.vy *= Math.pow(friction, deltaTime * 60);
|
||||||
}
|
}
|
||||||
|
|
||||||
const canvas = this.engine.canvas;
|
if (tileMap) {
|
||||||
if (position.x < 0) {
|
const mapWidth = tileMap.cols * tileMap.tileSize;
|
||||||
position.x = 0;
|
const mapHeight = tileMap.rows * tileMap.tileSize;
|
||||||
velocity.vx = 0;
|
|
||||||
} else if (position.x > canvas.width) {
|
|
||||||
position.x = canvas.width;
|
|
||||||
velocity.vx = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (position.y < 0) {
|
if (position.x < 0) {
|
||||||
position.y = 0;
|
position.x = 0;
|
||||||
velocity.vy = 0;
|
velocity.vx = 0;
|
||||||
} else if (position.y > canvas.height) {
|
} else if (position.x > mapWidth) {
|
||||||
position.y = canvas.height;
|
position.x = mapWidth;
|
||||||
velocity.vy = 0;
|
velocity.vx = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (position.y < 0) {
|
||||||
|
position.y = 0;
|
||||||
|
velocity.vy = 0;
|
||||||
|
} else if (position.y > mapHeight) {
|
||||||
|
position.y = mapHeight;
|
||||||
|
velocity.vy = 0;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const canvas = this.engine.canvas;
|
||||||
|
if (position.x < 0) {
|
||||||
|
position.x = 0;
|
||||||
|
velocity.vx = 0;
|
||||||
|
} else if (position.x > canvas.width) {
|
||||||
|
position.x = canvas.width;
|
||||||
|
velocity.vx = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (position.y < 0) {
|
||||||
|
position.y = 0;
|
||||||
|
velocity.vy = 0;
|
||||||
|
} else if (position.y > canvas.height) {
|
||||||
|
position.y = canvas.height;
|
||||||
|
velocity.vy = 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
76
src/systems/MusicSystem.ts
Normal file
76
src/systems/MusicSystem.ts
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
import { System } from '../core/System.ts';
|
||||||
|
import { SystemName, ComponentType, GameState } from '../core/Constants.ts';
|
||||||
|
import type { Entity } from '../core/Entity.ts';
|
||||||
|
import type { Engine } from '../core/Engine.ts';
|
||||||
|
import type { Music } from '../components/Music.ts';
|
||||||
|
import type { MenuSystem } from './MenuSystem.ts';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* System responsible for managing background music playback.
|
||||||
|
*/
|
||||||
|
export class MusicSystem extends System {
|
||||||
|
private audioContext: AudioContext | null;
|
||||||
|
private wasPaused: boolean;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super(SystemName.MUSIC);
|
||||||
|
this.requiredComponents = [ComponentType.MUSIC];
|
||||||
|
this.priority = 5;
|
||||||
|
this.audioContext = null;
|
||||||
|
this.wasPaused = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the audio context when system is added to engine.
|
||||||
|
*/
|
||||||
|
init(engine: Engine): void {
|
||||||
|
super.init(engine);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process music entities - ensures audio context exists and handles pause/resume.
|
||||||
|
*/
|
||||||
|
process(_deltaTime: number, entities: Entity[]): void {
|
||||||
|
const menuSystem = this.engine.systems.find((s) => s.name === SystemName.MENU) as
|
||||||
|
| MenuSystem
|
||||||
|
| undefined;
|
||||||
|
const gameState = menuSystem ? menuSystem.getGameState() : GameState.PLAYING;
|
||||||
|
const isPaused = gameState === GameState.PAUSED;
|
||||||
|
|
||||||
|
entities.forEach((entity) => {
|
||||||
|
const music = entity.getComponent<Music>(ComponentType.MUSIC);
|
||||||
|
if (!music) return;
|
||||||
|
|
||||||
|
if (!this.audioContext) {
|
||||||
|
this.audioContext = new AudioContext();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isPaused && !this.wasPaused) {
|
||||||
|
music.pause();
|
||||||
|
this.wasPaused = true;
|
||||||
|
} else if (!isPaused && this.wasPaused) {
|
||||||
|
music.resume();
|
||||||
|
this.wasPaused = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get or create the shared audio context.
|
||||||
|
*/
|
||||||
|
getAudioContext(): AudioContext {
|
||||||
|
if (!this.audioContext) {
|
||||||
|
this.audioContext = new AudioContext();
|
||||||
|
}
|
||||||
|
return this.audioContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resume audio context (required after user interaction).
|
||||||
|
*/
|
||||||
|
resumeAudioContext(): void {
|
||||||
|
if (this.audioContext && this.audioContext.state === 'suspended') {
|
||||||
|
this.audioContext.resume();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -75,6 +75,12 @@ export class ProjectileSystem extends System {
|
||||||
if (targetHealthComp) {
|
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,14 +99,23 @@ export class ProjectileSystem extends System {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const canvas = this.engine.canvas;
|
const tileMap = this.engine.tileMap;
|
||||||
if (
|
if (tileMap) {
|
||||||
position.x < 0 ||
|
const mapWidth = tileMap.cols * tileMap.tileSize;
|
||||||
position.x > canvas.width ||
|
const mapHeight = tileMap.rows * tileMap.tileSize;
|
||||||
position.y < 0 ||
|
if (position.x < 0 || position.x > mapWidth || position.y < 0 || position.y > mapHeight) {
|
||||||
position.y > canvas.height
|
this.engine.removeEntity(entity);
|
||||||
) {
|
}
|
||||||
this.engine.removeEntity(entity);
|
} else {
|
||||||
|
const canvas = this.engine.canvas;
|
||||||
|
if (
|
||||||
|
position.x < 0 ||
|
||||||
|
position.x > canvas.width ||
|
||||||
|
position.y < 0 ||
|
||||||
|
position.y > canvas.height
|
||||||
|
) {
|
||||||
|
this.engine.removeEntity(entity);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
105
src/systems/SoundEffectsSystem.ts
Normal file
105
src/systems/SoundEffectsSystem.ts
Normal file
|
|
@ -0,0 +1,105 @@
|
||||||
|
import { System } from '../core/System.ts';
|
||||||
|
import { SystemName, ComponentType } from '../core/Constants.ts';
|
||||||
|
import { Events } from '../core/EventBus.ts';
|
||||||
|
import type { Entity } from '../core/Entity.ts';
|
||||||
|
import type { Engine } from '../core/Engine.ts';
|
||||||
|
import type { SoundEffects } from '../components/SoundEffects.ts';
|
||||||
|
import type { MusicSystem } from './MusicSystem.ts';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* System responsible for managing sound effects playback.
|
||||||
|
* Follows ECS pattern: system processes entities with SoundEffects component.
|
||||||
|
* Listens to game events and plays appropriate sound effects.
|
||||||
|
*/
|
||||||
|
export class SoundEffectsSystem extends System {
|
||||||
|
private sfxEntity: Entity | null;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super(SystemName.SOUND_EFFECTS);
|
||||||
|
this.requiredComponents = [ComponentType.SOUND_EFFECTS];
|
||||||
|
this.priority = 5;
|
||||||
|
this.sfxEntity = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize event listeners when system is added to engine.
|
||||||
|
*/
|
||||||
|
init(engine: Engine): void {
|
||||||
|
super.init(engine);
|
||||||
|
|
||||||
|
this.engine.on(Events.ATTACK_PERFORMED, () => {
|
||||||
|
this.playSound('attack');
|
||||||
|
});
|
||||||
|
|
||||||
|
this.engine.on(Events.DAMAGE_DEALT, () => {
|
||||||
|
this.playSound('damage');
|
||||||
|
});
|
||||||
|
|
||||||
|
this.engine.on(Events.ABSORPTION, () => {
|
||||||
|
this.playSound('absorb');
|
||||||
|
});
|
||||||
|
|
||||||
|
this.engine.on(Events.SKILL_LEARNED, () => {
|
||||||
|
this.playSound('skill');
|
||||||
|
});
|
||||||
|
|
||||||
|
this.engine.on(Events.SKILL_COOLDOWN_STARTED, () => {
|
||||||
|
this.playSound('skill');
|
||||||
|
});
|
||||||
|
|
||||||
|
this.engine.on(Events.PROJECTILE_CREATED, () => {
|
||||||
|
this.playSound('shoot');
|
||||||
|
});
|
||||||
|
|
||||||
|
this.engine.on(Events.PROJECTILE_IMPACT, () => {
|
||||||
|
this.playSound('impact');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process sound effect entities - ensures audio context is available.
|
||||||
|
*/
|
||||||
|
process(_deltaTime: number, entities: Entity[]): void {
|
||||||
|
entities.forEach((entity) => {
|
||||||
|
const sfx = entity.getComponent<SoundEffects>(ComponentType.SOUND_EFFECTS);
|
||||||
|
if (!sfx) return;
|
||||||
|
|
||||||
|
if (!this.sfxEntity) {
|
||||||
|
this.sfxEntity = entity;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!sfx.audioContext) {
|
||||||
|
const musicSystem = this.engine.systems.find((s) => s.name === SystemName.MUSIC) as
|
||||||
|
| MusicSystem
|
||||||
|
| undefined;
|
||||||
|
if (musicSystem) {
|
||||||
|
sfx.audioContext = musicSystem.getAudioContext();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Play a sound effect from any entity with SoundEffects component.
|
||||||
|
* @param soundName - The name of the sound to play
|
||||||
|
*/
|
||||||
|
playSound(soundName: string): void {
|
||||||
|
if (this.sfxEntity) {
|
||||||
|
const sfx = this.sfxEntity.getComponent<SoundEffects>(ComponentType.SOUND_EFFECTS);
|
||||||
|
if (sfx) {
|
||||||
|
sfx.play(soundName);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const entities = this.engine.getEntities();
|
||||||
|
for (const entity of entities) {
|
||||||
|
const sfx = entity.getComponent<SoundEffects>(ComponentType.SOUND_EFFECTS);
|
||||||
|
if (sfx) {
|
||||||
|
this.sfxEntity = entity;
|
||||||
|
sfx.play(soundName);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,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,13 +48,23 @@ 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 {
|
} else {
|
||||||
stealth.enterStealth(stealth.stealthType);
|
if (sprite) {
|
||||||
|
stealth.enterStealth(stealth.stealthType, sprite.color);
|
||||||
|
} else {
|
||||||
|
stealth.enterStealth(stealth.stealthType);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -61,24 +74,51 @@ export class StealthSystem extends System {
|
||||||
|
|
||||||
stealth.updateStealth(isMoving || false, isInCombat || false);
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,25 +1,27 @@
|
||||||
import { defineConfig } from 'vite';
|
import { defineConfig } from 'vite';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
build: {
|
build: {
|
||||||
minify: 'terser',
|
minify: 'terser',
|
||||||
terserOptions: {
|
terserOptions: {
|
||||||
compress: {
|
ecma: 2020,
|
||||||
drop_console: true,
|
compress: {
|
||||||
drop_debugger: true,
|
drop_console: true,
|
||||||
},
|
drop_debugger: true,
|
||||||
mangle: {
|
},
|
||||||
toplevel: true,
|
mangle: {
|
||||||
},
|
toplevel: true,
|
||||||
format: {
|
properties: true,
|
||||||
comments: false,
|
},
|
||||||
},
|
format: {
|
||||||
},
|
comments: false,
|
||||||
rollupOptions: {
|
},
|
||||||
output: {
|
|
||||||
manualChunks: undefined,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
sourcemap: false,
|
|
||||||
},
|
},
|
||||||
|
rollupOptions: {
|
||||||
|
output: {
|
||||||
|
manualChunks: undefined,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
sourcemap: false,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue