Compare commits

...

24 commits
v0.1.0 ... main

Author SHA1 Message Date
forgebot
109cee0052 chore: update version to 0.5.0 [skip ci] 2026-01-07 06:29:14 +00:00
2858898ec2 Merge pull request 'Feature/Camera and Large Map' (#7) from Feature/Camera-and-Large-Map into main
All checks were successful
Build and Publish Docker Image / Publish to Registry (push) Successful in 9s
Build and Publish Docker Image / Deploy to Portainer (push) Successful in 1s
Reviewed-on: #7
2026-01-07 01:29:09 -05:00
c859e20ffc feat: implement Camera system and component for improved viewport management and player tracking
All checks were successful
Build and Publish Docker Image / Build and Validate (pull_request) Successful in 14s
2026-01-07 01:25:56 -05:00
62e58f77ae chore: add eslint-config-prettier to ESLint configuration for improved formatting compatibility 2026-01-07 01:25:53 -05:00
forgebot
b32ac22be8 chore: update version to 0.4.0 [skip ci] 2026-01-07 05:04:22 +00:00
71c8129f37 Merge pull request 'Feature/Sound, mangling and minification' (#6) from Feature/Sound-mangling-and-minification into main
All checks were successful
Build and Publish Docker Image / Publish to Registry (push) Successful in 8s
Build and Publish Docker Image / Deploy to Portainer (push) Successful in 1s
Reviewed-on: #6
2026-01-07 00:04:17 -05:00
66719912ba refactor: remove unused audio playback logic from setupMusicHandlers, streamlining music configuration
All checks were successful
Build and Publish Docker Image / Build and Validate (pull_request) Successful in 10s
2026-01-07 00:03:50 -05:00
5a24d6a2af feat: refactor audio management by introducing setup functions for music and sound effects, enhancing modularity and maintainability
All checks were successful
Build and Publish Docker Image / Build and Validate (pull_request) Successful in 10s
2026-01-06 23:58:26 -05:00
2213f64e60 feat: implement Music and SoundEffects systems for enhanced audio management, including background music and sound effects playback
All checks were successful
Build and Publish Docker Image / Build and Validate (pull_request) Successful in 10s
2026-01-06 23:25:33 -05:00
143072f0a0 feat: enhance minification and mangling support by switching FONT_DATA to a Map and updating Vite configuration for ECMAScript 2020 compatibility 2026-01-06 22:40:19 -05:00
forgebot
9e640aa7be chore: update version to 0.3.0 [skip ci] 2026-01-07 03:00:21 +00:00
0d3bce4d4f Merge pull request 'Feature/VFX and animations' (#5) from Feature/VFX-and-animations into main
All checks were successful
Build and Publish Docker Image / Publish to Registry (push) Successful in 10s
Build and Publish Docker Image / Deploy to Portainer (push) Successful in 2s
Reviewed-on: #5
2026-01-06 22:00:16 -05:00
f01e6af519 chore: clean up .gitignore by removing unnecessary empty lines
All checks were successful
Build and Publish Docker Image / Build and Validate (pull_request) Successful in 14s
2026-01-06 21:54:53 -05:00
c582f2004e feat: migrate JavaScript files to TypeScript, enhancing type safety and maintainability across the codebase 2026-01-06 21:51:00 -05:00
3db2bb9160 refactor: centralize system names, component types, entity types, and animation states into a new Constants module. 2026-01-06 18:58:12 -05:00
e9db84abd1 feat: introduce VFXSystem to centralize visual effect management and rendering, migrating absorption effects from UISystem and AbsorptionSystem. 2026-01-06 18:56:20 -05:00
forgebot
691bc3b8da chore: update version to 0.2.0 [skip ci] 2026-01-06 22:28:30 +00:00
294e2dcf1f Merge pull request 'Feature/Pixel-Rework' (#4) from Feature/Pixel-Rework into main
All checks were successful
Build and Publish Docker Image / Publish to Registry (push) Successful in 8s
Build and Publish Docker Image / Deploy to Portainer (push) Successful in 2s
Reviewed-on: #4
2026-01-06 17:28:25 -05:00
4e51a430e8 fix: Add minimum width to a layout element.
All checks were successful
Build and Publish Docker Image / Build and Validate (pull_request) Successful in 10s
2026-01-06 17:25:59 -05:00
86c1c3bc59 feat: Move player stats and skill progress display from active HUD to the paused menu, removing minor HUD elements and consolidating evolution details into the stats display. 2026-01-06 17:25:34 -05:00
cf04677511 feat: Implement pixel-art rendering with new level loading, tile maps, palettes, and pixel fonts, alongside a game over screen. 2026-01-06 17:21:15 -05:00
forgebot
5b15e63ac3 chore: update version to 0.1.1 [skip ci] 2026-01-06 20:09:05 +00:00
49d20532da Merge pull request 'Hotfix/Use var instead of secret for username' (#3) from Hotfix/Use-var-instead-of-secret-for-username into main
All checks were successful
Build and Publish Docker Image / Publish to Registry (push) Successful in 10s
Build and Publish Docker Image / Deploy to Portainer (push) Successful in 1s
Reviewed-on: #3
2026-01-06 15:09:00 -05:00
4c0af096f9 fix: replace secret with var
All checks were successful
Build and Publish Docker Image / Build and Validate (pull_request) Successful in 16s
2026-01-06 15:03:39 -05:00
120 changed files with 8149 additions and 3687 deletions

View file

@ -22,7 +22,7 @@ jobs:
- name: Login to Registry - name: Login to Registry
run: | run: |
echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login ${{ env.REGISTRY }} -u "${{ secrets.REGISTRY_USERNAME }}" --password-stdin echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login ${{ env.REGISTRY }} -u "${{ vars.REGISTRY_USERNAME }}" --password-stdin
- name: Determine Version - name: Determine Version
id: version id: version

1
.gitignore vendored
View file

@ -3,4 +3,3 @@ dist/
.DS_Store .DS_Store
*.log *.log
.vite/ .vite/

7
.husky/pre-commit Executable file
View file

@ -0,0 +1,7 @@
# Skip in CI environments
if [ -n "$CI" ] || [ -n "$FORGEJO_ACTIONS" ] || [ -n "$GITHUB_ACTIONS" ]; then
echo "Skipping pre-commit hooks in CI environment"
exit 0
fi
npx lint-staged

4
.lintstagedrc.json Normal file
View file

@ -0,0 +1,4 @@
{
"*.{ts,tsx,js,jsx}": ["eslint --fix", "prettier --write"],
"*.{json,css,md}": ["prettier --write"]
}

5
.prettierignore Normal file
View file

@ -0,0 +1,5 @@
node_modules
dist
*.min.js
package-lock.json

9
.prettierrc.json Normal file
View file

@ -0,0 +1,9 @@
{
"semi": true,
"trailingComma": "es5",
"singleQuote": true,
"printWidth": 100,
"tabWidth": 2,
"useTabs": false,
"arrowParens": "always"
}

134
README.md
View file

@ -1,21 +1,54 @@
# Slime Genesis - ECS RPG PoC # Slime Genesis - ECS RPG PoC
A complete proof of concept for **Slime Genesis: The Awakening of the Entity** built with vanilla JavaScript using an Entity Component System (ECS) architecture. A complete proof of concept for **Slime Genesis: The Awakening of the Entity** built with TypeScript using an Entity Component System (ECS) architecture.
## Quick Start ## Quick Start
1. **Install dependencies:** 1. **Install dependencies:**
```bash ```bash
npm install npm install
``` ```
2. **Run the development server:** 2. **Run the development server:**
```bash ```bash
npm run dev npm run dev
``` ```
3. **Open your browser** to the URL shown (usually `http://localhost:5173`) 3. **Open your browser** to the URL shown (usually `http://localhost:5173`)
## Development
- **Format code:**
```bash
npm run format
```
- **Check formatting:**
```bash
npm run format:check
```
- **Lint code:**
```bash
npm run lint
```
- **Fix linting issues:**
```bash
npm run lint:fix
```
- **Build for production:**
```bash
npm run build
```
## Controls ## Controls
- **WASD** or **Arrow Keys**: Move your slime - **WASD** or **Arrow Keys**: Move your slime
@ -27,6 +60,7 @@ A complete proof of concept for **Slime Genesis: The Awakening of the Entity** b
## Features ## Features
### Core Systems ### Core Systems
- ✅ **ECS Architecture**: Entity Component System for flexible game design - ✅ **ECS Architecture**: Entity Component System for flexible game design
- ✅ **Real-time Combat**: Fast-paced action combat with form-specific styles - ✅ **Real-time Combat**: Fast-paced action combat with form-specific styles
- ✅ **Evolution System**: Three paths (Human, Beast, Slime) that change based on absorption - ✅ **Evolution System**: Three paths (Human, Beast, Slime) that change based on absorption
@ -35,15 +69,19 @@ A complete proof of concept for **Slime Genesis: The Awakening of the Entity** b
- ✅ **RPG Systems**: Stats, leveling, XP, inventory, equipment - ✅ **RPG Systems**: Stats, leveling, XP, inventory, equipment
- ✅ **AI System**: Intelligent creature behaviors (wander, chase, combat, flee) - ✅ **AI System**: Intelligent creature behaviors (wander, chase, combat, flee)
- ✅ **Projectile System**: Skills can create projectiles (Water Gun, etc.) - ✅ **Projectile System**: Skills can create projectiles (Water Gun, etc.)
- ✅ **Mutation System**: Gain mutations like Hardened Shell, Electric Skin, Bioluminescence
### Graphics & Polish ### Graphics & Polish
- ✅ **Animated Slime**: Smooth morphing blob with jiggle physics - ✅ **Animated Slime**: Smooth morphing blob with jiggle physics
- ✅ **Combat Effects**: Damage numbers, attack indicators, particle effects - ✅ **Combat Effects**: Damage numbers, attack indicators, particle effects
- ✅ **Absorption Visuals**: Swirling particles and color transitions - ✅ **Absorption Visuals**: Swirling particles and color transitions
- ✅ **Stealth Indicators**: Visibility meters and detection warnings - ✅ **Stealth Indicators**: Visibility meters and detection warnings
- ✅ **Glow Effects**: Bioluminescence mutation creates a pulsing glow effect
- ✅ **Polished UI**: Health bars, XP bars, skill hotbar, stat displays - ✅ **Polished UI**: Health bars, XP bars, skill hotbar, stat displays
### Skills ### Skills
- **Water Gun**: Shoot a jet of water at enemies - **Water Gun**: Shoot a jet of water at enemies
- **Fire Breath**: Breathe fire in a cone - **Fire Breath**: Breathe fire in a cone
- **Pounce**: Leap forward and damage enemies - **Pounce**: Leap forward and damage enemies
@ -58,6 +96,7 @@ This game uses a pure ECS (Entity Component System) architecture:
- **Systems**: Logic processors (RenderSystem, CombatSystem, AbsorptionSystem, etc.) - **Systems**: Logic processors (RenderSystem, CombatSystem, AbsorptionSystem, etc.)
This architecture makes it easy to: This architecture makes it easy to:
- Add new skills and abilities - Add new skills and abilities
- Create mutations and combinations - Create mutations and combinations
- Extend creature behaviors - Extend creature behaviors
@ -67,32 +106,65 @@ This architecture makes it easy to:
``` ```
src/ src/
├── core/ # ECS framework ├── core/ # ECS framework and utilities
│ ├── Engine.js # Main game loop │ ├── Engine.ts # Main game loop
│ ├── Entity.js # Entity manager │ ├── Entity.ts # Entity manager
│ ├── Component.js # Base component │ ├── Component.ts # Base component
│ └── System.js # Base system │ ├── System.ts # Base system
│ ├── Constants.ts # Enums and constants
│ ├── EventBus.ts # Event system
│ ├── LevelLoader.ts # Level loading
│ ├── Palette.ts # Color palette
│ ├── PixelFont.ts # Pixel font rendering
│ ├── SpriteLibrary.ts # Sprite definitions
│ └── TileMap.ts # Tile map system
├── components/ # All game components ├── components/ # All game components
│ ├── Position.js │ ├── Position.ts # Position and rotation
│ ├── Health.js │ ├── Velocity.ts # Movement velocity
│ ├── Stats.js │ ├── Health.ts # Health and regeneration
│ ├── Evolution.js │ ├── Sprite.ts # Visual representation
│ └── ... │ ├── Stats.ts # Attributes and leveling
│ ├── Combat.ts # Combat stats and attacks
│ ├── Evolution.ts # Evolution paths and mutations
│ ├── Skills.ts # Skill management
│ ├── SkillProgress.ts # Skill learning progress
│ ├── Absorbable.ts # Absorption mechanics
│ ├── Stealth.ts # Stealth state
│ ├── Intent.ts # Action intent
│ ├── Inventory.ts # Items and equipment
│ └── AI.ts # AI behavior data
├── systems/ # All game systems ├── systems/ # All game systems
│ ├── RenderSystem.js │ ├── RenderSystem.ts # Rendering
│ ├── CombatSystem.js │ ├── InputSystem.ts # Input handling
│ ├── AbsorptionSystem.js │ ├── PlayerControllerSystem.ts # Player control
│ └── ... │ ├── MovementSystem.ts # Movement physics
│ ├── CombatSystem.ts # Combat logic
│ ├── AISystem.ts # AI behavior
│ ├── AbsorptionSystem.ts # Absorption mechanics
│ ├── StealthSystem.ts # Stealth mechanics
│ ├── SkillSystem.ts # Skill activation
│ ├── SkillEffectSystem.ts # Skill visual effects
│ ├── ProjectileSystem.ts # Projectile physics
│ ├── DeathSystem.ts # Death handling
│ ├── HealthRegenerationSystem.ts # Health regen
│ ├── VFXSystem.ts # Visual effects
│ ├── UISystem.ts # UI rendering
│ └── MenuSystem.ts # Menu management
├── skills/ # Skill system ├── skills/ # Skill system
│ ├── Skill.js │ ├── Skill.ts # Base skill class
│ ├── SkillRegistry.js │ ├── SkillRegistry.ts # Skill registry
│ └── skills/ # Individual skills │ └── skills/ # Individual skills
│ ├── FireBreath.ts
│ ├── Pounce.ts
│ ├── StealthMode.ts
│ └── WaterGun.ts
├── items/ # Item system ├── items/ # Item system
│ ├── Item.js │ ├── Item.ts # Base item class
│ └── ItemRegistry.js │ └── ItemRegistry.ts # Item registry
├── world/ # World management ├── world/ # World management
│ └── World.js │ └── World.ts # World setup
└── main.js # Entry point ├── GameConfig.ts # Game configuration
└── main.ts # Entry point
``` ```
## Gameplay Loop ## Gameplay Loop
@ -103,6 +175,7 @@ src/
4. **Evolve**: Your form changes based on what you absorb 4. **Evolve**: Your form changes based on what you absorb
5. **Level Up**: Gain XP, increase stats, unlock new possibilities 5. **Level Up**: Gain XP, increase stats, unlock new possibilities
6. **Stealth**: Use form-specific stealth to avoid or ambush enemies 6. **Stealth**: Use form-specific stealth to avoid or ambush enemies
7. **Mutate**: Gain powerful mutations like Hardened Shell, Electric Skin, or Bioluminescence
## Evolution Paths ## Evolution Paths
@ -110,13 +183,30 @@ src/
- **Beast Path**: Absorb beasts to become a predator, gain physical power - **Beast Path**: Absorb beasts to become a predator, gain physical power
- **Slime Path**: Maintain your original form, gain unique abilities - **Slime Path**: Maintain your original form, gain unique abilities
## Mutations
- **Hardened Shell**: Increased defense (requires high Constitution)
- **Electric Skin**: Damage reflection (requires high Intelligence)
- **Bioluminescence**: Glowing light source (requires high Human evolution)
## Technical Details ## Technical Details
- **No External Dependencies**: Pure vanilla JavaScript (except Vite for dev server) - **TypeScript**: Full type safety and modern JavaScript features
- **Vite**: Fast development server and build tool
- **Canvas 2D**: High-performance rendering with Canvas API - **Canvas 2D**: High-performance rendering with Canvas API
- **ESLint**: Code linting with TypeScript support
- **Prettier**: Code formatting
- **Husky**: Pre-commit hooks (skips in CI environments)
- **Modular Design**: Easy to extend and modify - **Modular Design**: Easy to extend and modify
- **ECS Pattern**: Scalable architecture for complex game mechanics - **ECS Pattern**: Scalable architecture for complex game mechanics
## Code Quality
- **TypeScript**: Full type coverage, no `any` types
- **ESLint**: Zero warnings policy
- **Prettier**: Consistent code formatting
- **Pre-commit Hooks**: Automatic formatting and linting before commits
## Future Enhancements ## Future Enhancements
- More skills and mutations - More skills and mutations

View file

@ -1 +1 @@
0.1.0 0.5.0

View file

@ -1,26 +1,39 @@
import js from "@eslint/js"; import js from '@eslint/js';
import globals from "globals"; import globals from 'globals';
import tseslint from 'typescript-eslint';
import prettier from 'eslint-config-prettier';
export default [ export default [
js.configs.recommended, js.configs.recommended,
...tseslint.configs.recommended,
prettier,
{ {
languageOptions: { languageOptions: {
ecmaVersion: 2022, ecmaVersion: 2022,
sourceType: "module", sourceType: 'module',
globals: { globals: {
...globals.browser, ...globals.browser,
...globals.node, ...globals.node,
performance: "readonly", performance: 'readonly',
requestAnimationFrame: "readonly", requestAnimationFrame: 'readonly',
}, },
}, },
files: ['**/*.ts', '**/*.tsx'],
rules: { rules: {
"no-unused-vars": ["error", { '@typescript-eslint/no-unused-vars': [
"argsIgnorePattern": "^_", 'error',
"varsIgnorePattern": "^_" {
}], argsIgnorePattern: '^_',
"no-console": "off", varsIgnorePattern: '^_',
"indent": ["error", 2],
}, },
],
'@typescript-eslint/no-explicit-any': 'warn',
'no-console': 'off',
'@typescript-eslint/explicit-module-boundary-types': 'off',
'@typescript-eslint/no-non-null-assertion': 'warn',
},
},
{
ignores: ['dist/**', 'node_modules/**', '*.config.js'],
}, },
]; ];

View file

@ -1,31 +1,57 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Slime Genesis - PoC</title> <title>Slime Genesis - PoC</title>
<style> <style>
* {
box-sizing: border-box;
}
body { body {
margin: 0; margin: 0;
padding: 0; padding: 0;
width: 100vw;
height: 100vh;
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
min-height: 100vh;
background: #1a1a1a; background: #1a1a1a;
font-family: 'Courier New', monospace; font-family: 'Courier New', monospace;
overflow: hidden;
} }
#game-container { #game-container {
border: 2px solid #4a4a4a; width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
background: #000;
}
#game-canvas {
display: block;
image-rendering: pixelated;
box-shadow: 0 0 20px rgba(0, 255, 150, 0.3); box-shadow: 0 0 20px rgba(0, 255, 150, 0.3);
border: 2px solid #4a4a4a;
/* Simplified scaling: Aspect ratio determines size, strictly contained */
width: calc(100% - 500px);
min-width: 640px;
height: auto;
aspect-ratio: 4 / 3;
} }
</style> </style>
</head> </head>
<body> <body>
<div id="game-container"> <div id="game-container">
<canvas id="game-canvas" tabindex="0"></canvas> <canvas id="game-canvas" tabindex="0"></canvas>
</div> </div>
<script type="module" src="/src/main.js"></script> <script type="module" src="/src/main.ts"></script>
</body> </body>
</html>
</html>

882
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -7,14 +7,25 @@
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",
"preview": "vite preview", "preview": "vite preview",
"lint": "eslint src", "lint": "eslint src --max-warnings 0",
"lint:fix": "eslint src --fix" "lint:fix": "eslint src --fix",
"format": "prettier --write \"src/**/*.{ts,tsx,js,jsx,json,css,md}\"",
"format:check": "prettier --check \"src/**/*.{ts,tsx,js,jsx,json,css,md}\"",
"prepare": "husky || true"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.2", "@eslint/js": "^9.39.2",
"@typescript-eslint/eslint-plugin": "^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",
"lint-staged": "^16.2.7",
"prettier": "^3.7.4",
"terser": "^5.44.1", "terser": "^5.44.1",
"typescript": "^5.9.3",
"typescript-eslint": "^8.52.0",
"vite": "^7.3.0" "vite": "^7.3.0"
} }
} }

View file

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

View file

@ -1,38 +1,41 @@
/** /**
* Centralized Game Configuration * Centralized Game Configuration containing thresholds, rates, and balancing constants.
* Thresholds, rates, and balancing constants
*/ */
export const GameConfig = { export const GameConfig = {
/** Evolution related constants */
Evolution: { Evolution: {
totalTarget: 150, totalTarget: 150,
thresholds: { thresholds: {
hardenedShell: { constitution: 25 }, hardenedShell: { constitution: 25 },
electricSkin: { intelligence: 25 }, electricSkin: { intelligence: 25 },
glowingBody: { human: 50 } glowingBody: { human: 50 },
} },
}, },
/** Absorption related constants */
Absorption: { Absorption: {
range: 80, range: 30,
healPercentMin: 0.1, healPercentMin: 0.1,
healPercentMax: 0.2, healPercentMax: 0.2,
skillAbsorptionChance: 0.3, skillAbsorptionChance: 0.3,
mutationChance: 0.1, mutationChance: 0.1,
removalDelay: 3.0, // Seconds after death removalDelay: 3.0,
}, },
/** Combat related constants */
Combat: { Combat: {
knockbackPower: 150, knockbackPower: 150,
defaultAttackArc: 0.5, defaultAttackArc: 0.5,
damageReflectionPercent: 0.2, damageReflectionPercent: 0.2,
hardenedShellReduction: 0.7 hardenedShellReduction: 0.7,
}, },
/** AI behavior related constants */
AI: { AI: {
detectionAwarenessThreshold: 0.7, detectionAwarenessThreshold: 0.7,
passiveAwarenessThreshold: 0.95, passiveAwarenessThreshold: 0.95,
fleeAwarenessThreshold: 0.5, fleeAwarenessThreshold: 0.5,
awarenessLossRate: 0.5, awarenessLossRate: 0.5,
awarenessGainMultiplier: 2.0 awarenessGainMultiplier: 2.0,
} },
}; };

View file

@ -1,52 +0,0 @@
import { Component } from '../core/Component.js';
export class AI extends Component {
constructor(behaviorType = 'wander') {
super('AI');
this.behaviorType = behaviorType; // 'wander', 'patrol', 'chase', 'flee', 'combat'
this.state = 'idle'; // 'idle', 'moving', 'attacking', 'fleeing'
this.target = null; // Entity ID to target
this.awareness = 0; // 0-1, how aware of player
this.alertRadius = 150;
this.chaseRadius = 300;
this.fleeRadius = 100;
// Behavior parameters
this.wanderSpeed = 50;
this.wanderDirection = Math.random() * Math.PI * 2;
this.wanderChangeTime = 0;
this.wanderChangeInterval = 2.0; // seconds
}
/**
* Set behavior type
*/
setBehavior(type) {
this.behaviorType = type;
}
/**
* Set target entity
*/
setTarget(entityId) {
this.target = entityId;
}
/**
* Clear target
*/
clearTarget() {
this.target = null;
}
/**
* Update awareness
*/
updateAwareness(delta, maxAwareness = 1.0) {
this.awareness = Math.min(maxAwareness, this.awareness + delta);
if (this.awareness <= 0) {
this.awareness = 0;
}
}
}

73
src/components/AI.ts Normal file
View file

@ -0,0 +1,73 @@
import { Component } from '../core/Component.ts';
import { ComponentType } from '../core/Constants.ts';
/**
* Component for AI behavior and state.
*/
export class AI extends Component {
behaviorType: string;
state: string;
target: number | null;
awareness: number;
alertRadius: number;
chaseRadius: number;
fleeRadius: number;
wanderSpeed: number;
wanderDirection: number;
wanderChangeTime: number;
wanderChangeInterval: number;
/**
* @param behaviorType - The initial behavior type
*/
constructor(behaviorType = 'wander') {
super(ComponentType.AI);
this.behaviorType = behaviorType;
this.state = 'idle';
this.target = null;
this.awareness = 0;
this.alertRadius = 60;
this.chaseRadius = 120;
this.fleeRadius = 40;
this.wanderSpeed = 20;
this.wanderDirection = Math.random() * Math.PI * 2;
this.wanderChangeTime = 0;
this.wanderChangeInterval = 2.0;
}
/**
* Set the behavior type for this AI.
* @param type - The new behavior type (e.g., 'wander', 'patrol', 'chase')
*/
setBehavior(type: string): void {
this.behaviorType = type;
}
/**
* Set a target entity for the AI to interact with.
* @param entityId - The ID of the target entity
*/
setTarget(entityId: number): void {
this.target = entityId;
}
/**
* Clear the current target.
*/
clearTarget(): void {
this.target = null;
}
/**
* Update the AI's awareness level.
* @param delta - The amount to change awareness by
* @param maxAwareness - The maximum awareness level
*/
updateAwareness(delta: number, maxAwareness = 1.0): void {
this.awareness = Math.min(maxAwareness, this.awareness + delta);
if (this.awareness <= 0) {
this.awareness = 0;
}
}
}

View file

@ -1,54 +0,0 @@
import { Component } from '../core/Component.js';
export class Absorbable extends Component {
constructor() {
super('Absorbable');
this.evolutionData = {
human: 0,
beast: 0,
slime: 0
};
this.skillsGranted = []; // Array of skill IDs that can be absorbed
this.skillAbsorptionChance = 0.3; // 30% chance to absorb a skill
this.mutationChance = 0.1; // 10% chance for mutation
this.absorbed = false;
}
/**
* Set evolution data
*/
setEvolutionData(human, beast, slime) {
this.evolutionData = { human, beast, slime };
}
/**
* Add a skill that can be absorbed
*/
addSkill(skillId, chance = null) {
this.skillsGranted.push({
id: skillId,
chance: chance || this.skillAbsorptionChance
});
}
/**
* Get skills that were successfully absorbed (rolls for each)
*/
getAbsorbedSkills() {
const absorbed = [];
for (const skill of this.skillsGranted) {
if (Math.random() < skill.chance) {
absorbed.push(skill.id);
}
}
return absorbed;
}
/**
* Check if should mutate
*/
shouldMutate() {
return Math.random() < this.mutationChance;
}
}

View file

@ -0,0 +1,87 @@
import { Component } from '../core/Component.ts';
import { ComponentType } from '../core/Constants.ts';
/**
* Evolution data structure
*/
export interface EvolutionData {
human: number;
beast: number;
slime: number;
}
/**
* Skill grant structure
*/
export interface SkillGrant {
id: string;
chance: number;
}
/**
* Component for entities that can be absorbed by the player.
*/
export class Absorbable extends Component {
evolutionData: EvolutionData;
skillsGranted: SkillGrant[];
skillAbsorptionChance: number;
mutationChance: number;
absorbed: boolean;
constructor() {
super(ComponentType.ABSORBABLE);
this.evolutionData = {
human: 0,
beast: 0,
slime: 0,
};
this.skillsGranted = [];
this.skillAbsorptionChance = 0.3;
this.mutationChance = 0.1;
this.absorbed = false;
}
/**
* Set evolution data granted upon absorption.
* @param human - Human evolution points
* @param beast - Beast evolution points
* @param slime - Slime evolution points
*/
setEvolutionData(human: number, beast: number, slime: number): void {
this.evolutionData = { human, beast, slime };
}
/**
* Add a skill that can be granted upon absorption.
* @param skillId - The ID of the skill
* @param chance - The probability of absorbing this skill (0.0 to 1.0)
*/
addSkill(skillId: string, chance: number | null = null): void {
this.skillsGranted.push({
id: skillId,
chance: chance || this.skillAbsorptionChance,
});
}
/**
* Calculate which skills are successfully absorbed based on their chances.
* @returns Array of successfully absorbed skill IDs
*/
getAbsorbedSkills(): string[] {
const absorbed: string[] = [];
for (const skill of this.skillsGranted) {
if (Math.random() < skill.chance) {
absorbed.push(skill.id);
}
}
return absorbed;
}
/**
* Determine if a mutation should occur upon absorption.
* @returns True if mutation should occur
*/
shouldMutate(): boolean {
return Math.random() < this.mutationChance;
}
}

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

@ -0,0 +1,58 @@
import { Component } from '../core/Component.ts';
import { ComponentType } from '../core/Constants.ts';
/**
* Component for camera/viewport management.
*/
export class Camera extends Component {
x: number;
y: number;
targetX: number;
targetY: number;
smoothness: number;
bounds: {
minX: number;
maxX: number;
minY: number;
maxY: number;
};
viewportWidth: number;
viewportHeight: number;
constructor(viewportWidth: number, viewportHeight: number, smoothness = 0.15) {
super(ComponentType.CAMERA);
this.x = 0;
this.y = 0;
this.targetX = 0;
this.targetY = 0;
this.smoothness = smoothness;
this.viewportWidth = viewportWidth;
this.viewportHeight = viewportHeight;
this.bounds = {
minX: 0,
maxX: 0,
minY: 0,
maxY: 0,
};
}
/**
* Set camera bounds based on map size.
* @param mapWidth - Total map width in pixels
* @param mapHeight - Total map height in pixels
*/
setBounds(mapWidth: number, mapHeight: number): void {
this.bounds.minX = this.viewportWidth / 2;
this.bounds.maxX = mapWidth - this.viewportWidth / 2;
this.bounds.minY = this.viewportHeight / 2;
this.bounds.maxY = mapHeight - this.viewportHeight / 2;
}
/**
* Clamp camera position to bounds.
*/
clampToBounds(): void {
this.x = Math.max(this.bounds.minX, Math.min(this.bounds.maxX, this.x));
this.y = Math.max(this.bounds.minY, Math.min(this.bounds.maxY, this.y));
}
}

View file

@ -1,52 +0,0 @@
import { Component } from '../core/Component.js';
export class Combat extends Component {
constructor() {
super('Combat');
this.attackDamage = 10;
this.defense = 5;
this.attackSpeed = 1.0; // Attacks per second
this.attackRange = 50;
this.lastAttackTime = 0;
this.attackCooldown = 0;
// Combat state
this.isAttacking = false;
this.attackDirection = 0; // Angle in radians
this.knockbackResistance = 0.5;
}
/**
* Check if can attack
*/
canAttack(currentTime) {
return (currentTime - this.lastAttackTime) >= (1.0 / this.attackSpeed);
}
/**
* Perform attack
*/
attack(currentTime, direction) {
if (!this.canAttack(currentTime)) return false;
this.lastAttackTime = currentTime;
this.isAttacking = true;
this.attackDirection = direction;
this.attackCooldown = 0.3; // Attack animation duration
return true;
}
/**
* Update attack state
*/
update(deltaTime) {
if (this.attackCooldown > 0) {
this.attackCooldown -= deltaTime;
if (this.attackCooldown <= 0) {
this.isAttacking = false;
}
}
}
}

87
src/components/Combat.ts Normal file
View file

@ -0,0 +1,87 @@
import { Component } from '../core/Component.ts';
import { ComponentType } from '../core/Constants.ts';
/**
* Component for combat-related properties and actions.
*/
export class Combat extends Component {
/** The amount of damage dealt by an attack. */
attackDamage: number;
/** The amount of damage reduced from incoming attacks. */
defense: number;
/** The rate of attacks per second. */
attackSpeed: number;
/** The maximum distance an attack can reach. */
attackRange: number;
/** The timestamp of the last successful attack. */
lastAttackTime: number;
/** The remaining duration of the attack animation/cooldown. */
attackCooldown: number;
/** True if the entity is currently in an attack animation/state. */
isAttacking: boolean;
/** The direction of the current attack in radians. */
attackDirection: number;
/** The resistance to knockback effects (0.0 to 1.0). */
knockbackResistance: number;
constructor() {
super(ComponentType.COMBAT);
this.attackDamage = 10;
this.defense = 5;
this.attackSpeed = 1.0;
this.attackRange = 15;
this.lastAttackTime = 0;
this.attackCooldown = 0;
this.isAttacking = false;
this.attackDirection = 0;
this.knockbackResistance = 0.5;
}
/**
* Check if the entity can perform an attack based on its attack speed.
* @param currentTime - The current game time in seconds.
* @returns True if the attack speed cooldown has elapsed, false otherwise.
*/
canAttack(currentTime: number): boolean {
return currentTime - this.lastAttackTime >= 1.0 / this.attackSpeed;
}
/**
* Initiate an attack sequence.
* @param currentTime - The current game time in seconds.
* @param direction - The angle of the attack in radians.
* @returns True if the attack was successfully initiated, false if still on cooldown.
*/
attack(currentTime: number, direction: number): boolean {
if (!this.canAttack(currentTime)) return false;
this.lastAttackTime = currentTime;
this.isAttacking = true;
this.attackDirection = direction;
this.attackCooldown = 0.3;
return true;
}
/**
* Update the internal attack state and timers.
* @param deltaTime - Time elapsed since last frame in seconds.
*/
update(deltaTime: number): void {
if (this.attackCooldown > 0) {
this.attackCooldown -= deltaTime;
if (this.attackCooldown <= 0) {
this.isAttacking = false;
}
}
}
}

View file

@ -1,105 +0,0 @@
import { Component } from '../core/Component.js';
import { GameConfig } from '../GameConfig.js';
import { Events } from '../core/EventBus.js';
export class Evolution extends Component {
constructor() {
super('Evolution');
this.human = 0;
this.beast = 0;
this.slime = 100; // Start as pure slime
// Mutation tracking
this.mutations = new Set(); // Use Set for unique functional mutations
this.mutationEffects = {
electricSkin: false,
glowingBody: false,
hardenedShell: false
};
}
/**
* Check and apply functional mutations
*/
checkMutations(stats, engine = null) {
if (!stats) return;
const config = GameConfig.Evolution.thresholds;
// Threshold-based functional mutations
if (stats.constitution > config.hardenedShell.constitution && !this.mutationEffects.hardenedShell) {
this.mutationEffects.hardenedShell = true;
this.mutations.add('Hardened Shell');
if (engine) engine.emit(Events.MUTATION_GAINED, { name: 'Hardened Shell', description: 'Defense Up' });
console.log("Mutation Gained: Hardened Shell (Defense Up)");
}
if (stats.intelligence > config.electricSkin.intelligence && !this.mutationEffects.electricSkin) {
this.mutationEffects.electricSkin = true;
this.mutations.add('Electric Skin');
if (engine) engine.emit(Events.MUTATION_GAINED, { name: 'Electric Skin', description: 'Damage Reflection' });
console.log("Mutation Gained: Electric Skin (Damage Reflection)");
}
if (this.human > config.glowingBody.human && !this.mutationEffects.glowingBody) {
this.mutationEffects.glowingBody = true;
this.mutations.add('Bioluminescence');
if (engine) engine.emit(Events.MUTATION_GAINED, { name: 'Bioluminescence', description: 'Light Source' });
console.log("Mutation Gained: Bioluminescence (Light Source)");
}
}
/**
* Add evolution points
*/
addEvolution(human = 0, beast = 0, slime = 0) {
this.human += human;
this.beast += beast;
this.slime += slime;
// Normalize to keep total around target
const target = GameConfig.Evolution.totalTarget;
const total = this.human + this.beast + this.slime;
if (total > target) {
const factor = target / total;
this.human *= factor;
this.beast *= factor;
this.slime *= factor;
}
// Ensure no negative values
this.human = Math.max(0, this.human);
this.beast = Math.max(0, this.beast);
this.slime = Math.max(0, this.slime);
}
/**
* Get dominant form
*/
getDominantForm() {
if (this.human > this.beast && this.human > this.slime) {
return 'human';
} else if (this.beast > this.human && this.beast > this.slime) {
return 'beast';
} else {
return 'slime';
}
}
/**
* Get form percentage (0-1)
*/
getFormPercentage(form) {
const total = this.human + this.beast + this.slime;
if (total === 0) return 0;
return this[form] / total;
}
/**
* Add a mutation
*/
addMutation(mutation) {
this.mutations.push(mutation);
}
}

140
src/components/Evolution.ts Normal file
View file

@ -0,0 +1,140 @@
import { Component } from '../core/Component.ts';
import { ComponentType } from '../core/Constants.ts';
import { GameConfig } from '../GameConfig.ts';
import { Events } from '../core/EventBus.ts';
import type { Stats } from './Stats.ts';
import type { Engine } from '../core/Engine.ts';
/**
* Component for tracking evolution and mutations.
*/
export class Evolution extends Component {
human: number;
beast: number;
slime: number;
mutations: Set<string>;
mutationEffects: {
electricSkin: boolean;
glowingBody: boolean;
hardenedShell: boolean;
};
constructor() {
super(ComponentType.EVOLUTION);
this.human = 0;
this.beast = 0;
this.slime = 100;
this.mutations = new Set();
this.mutationEffects = {
electricSkin: false,
glowingBody: false,
hardenedShell: false,
};
}
/**
* Check and apply functional mutations based on stats and evolution levels.
* @param stats - The stats component to check against
* @param engine - The game engine to emit events
*/
checkMutations(stats: Stats, engine: Engine | null = null): void {
if (!stats) return;
const config = GameConfig.Evolution.thresholds;
if (
stats.constitution > config.hardenedShell.constitution &&
!this.mutationEffects.hardenedShell
) {
this.mutationEffects.hardenedShell = true;
this.mutations.add('Hardened Shell');
if (engine)
engine.emit(Events.MUTATION_GAINED, { name: 'Hardened Shell', description: 'Defense Up' });
console.log('Mutation Gained: Hardened Shell (Defense Up)');
}
if (
stats.intelligence > config.electricSkin.intelligence &&
!this.mutationEffects.electricSkin
) {
this.mutationEffects.electricSkin = true;
this.mutations.add('Electric Skin');
if (engine)
engine.emit(Events.MUTATION_GAINED, {
name: 'Electric Skin',
description: 'Damage Reflection',
});
console.log('Mutation Gained: Electric Skin (Damage Reflection)');
}
if (this.human > config.glowingBody.human && !this.mutationEffects.glowingBody) {
this.mutationEffects.glowingBody = true;
this.mutations.add('Bioluminescence');
if (engine)
engine.emit(Events.MUTATION_GAINED, {
name: 'Bioluminescence',
description: 'Light Source',
});
console.log('Mutation Gained: Bioluminescence (Light Source)');
}
}
/**
* Add evolution points for different forms.
* @param human - Human evolution points
* @param beast - Beast evolution points
* @param slime - Slime evolution points
*/
addEvolution(human = 0, beast = 0, slime = 0): void {
this.human += human;
this.beast += beast;
this.slime += slime;
const target = GameConfig.Evolution.totalTarget;
const total = this.human + this.beast + this.slime;
if (total > target) {
const factor = target / total;
this.human *= factor;
this.beast *= factor;
this.slime *= factor;
}
this.human = Math.max(0, this.human);
this.beast = Math.max(0, this.beast);
this.slime = Math.max(0, this.slime);
}
/**
* Determine the current dominant form of the entity.
* @returns The name of the dominant form ('human', 'beast', or 'slime')
*/
getDominantForm(): string {
if (this.human > this.beast && this.human > this.slime) {
return 'human';
} else if (this.beast > this.human && this.beast > this.slime) {
return 'beast';
} else {
return 'slime';
}
}
/**
* Get the percentage of a specific form.
* @param form - The form name ('human', 'beast', or 'slime')
* @returns The percentage (0.0 to 1.0)
*/
getFormPercentage(form: 'human' | 'beast' | 'slime'): number {
const total = this.human + this.beast + this.slime;
if (total === 0) return 0;
return this[form] / total;
}
/**
* Record a new mutation.
* @param mutation - The name of the mutation
*/
addMutation(mutation: string): void {
this.mutations.add(mutation);
}
}

View file

@ -1,29 +0,0 @@
import { Component } from '../core/Component.js';
export class Health extends Component {
constructor(maxHp = 100, hp = null) {
super('Health');
this.maxHp = maxHp;
this.hp = hp !== null ? hp : maxHp;
this.regeneration = 2; // HP per second (slime regenerates)
this.lastDamageTime = 0;
this.invulnerable = false;
this.invulnerabilityDuration = 0;
}
takeDamage(amount) {
if (this.invulnerable) return 0;
this.hp = Math.max(0, this.hp - amount);
this.lastDamageTime = Date.now();
return amount;
}
heal(amount) {
this.hp = Math.min(this.maxHp, this.hp + amount);
}
isDead() {
return this.hp <= 0;
}
}

57
src/components/Health.ts Normal file
View file

@ -0,0 +1,57 @@
import { Component } from '../core/Component.ts';
import { ComponentType } from '../core/Constants.ts';
/**
* Component for tracking entity health and healing.
*/
export class Health extends Component {
maxHp: number;
hp: number;
regeneration: number;
lastDamageTime: number;
invulnerable: boolean;
invulnerabilityDuration: number;
isProjectile?: boolean;
/**
* @param maxHp - The maximum health points
* @param hp - The current health points (defaults to maxHp)
*/
constructor(maxHp = 100, hp: number | null = null) {
super(ComponentType.HEALTH);
this.maxHp = maxHp;
this.hp = hp !== null ? hp : maxHp;
this.regeneration = 2;
this.lastDamageTime = 0;
this.invulnerable = false;
this.invulnerabilityDuration = 0;
}
/**
* Reduce health points.
* @param amount - The amount of damage to take
* @returns The actual damage taken (0 if invulnerable)
*/
takeDamage(amount: number): number {
if (this.invulnerable) return 0;
this.hp = Math.max(0, this.hp - amount);
this.lastDamageTime = Date.now();
return amount;
}
/**
* Increase health points, capped at maxHp.
* @param amount - The amount to heal
*/
heal(amount: number): void {
this.hp = Math.min(this.maxHp, this.hp + amount);
}
/**
* Check if the entity is dead.
* @returns True if hp is 0 or less
*/
isDead(): boolean {
return this.hp <= 0;
}
}

39
src/components/Intent.ts Normal file
View file

@ -0,0 +1,39 @@
import { Component } from '../core/Component.ts';
import { ComponentType } from '../core/Constants.ts';
/**
* Component to track intended actions (Skills, Attacks, Interactions).
* This decouples input from execution.
*/
export class Intent extends Component {
action: string | null;
data: Record<string, unknown>;
/**
* @param action - The initial intended action
* @param data - Additional data for the action
*/
constructor(action: string | null = null, data: Record<string, unknown> = {}) {
super(ComponentType.INTENT);
this.action = action;
this.data = data;
}
/**
* Set a new intended action.
* @param action - The action name (e.g., 'skill_use', 'attack')
* @param data - Additional data for the action
*/
setIntent(action: string, data: Record<string, unknown> = {}): void {
this.action = action;
this.data = data;
}
/**
* Clear the current intent.
*/
clear(): void {
this.action = null;
this.data = {};
}
}

View file

@ -1,71 +0,0 @@
import { Component } from '../core/Component.js';
export class Inventory extends Component {
constructor() {
super('Inventory');
this.items = []; // Array of item objects
this.maxSize = 20;
this.equipped = {
weapon: null,
armor: null,
accessory: null
};
}
/**
* Add an item to inventory
*/
addItem(item) {
if (this.items.length < this.maxSize) {
this.items.push(item);
return true;
}
return false;
}
/**
* Remove an item
*/
removeItem(itemId) {
const index = this.items.findIndex(item => item.id === itemId);
if (index > -1) {
return this.items.splice(index, 1)[0];
}
return null;
}
/**
* Equip an item
*/
equipItem(itemId, slot) {
const item = this.items.find(i => i.id === itemId);
if (!item) return false;
// Unequip current item in slot
if (this.equipped[slot]) {
this.items.push(this.equipped[slot]);
}
// Equip new item
this.equipped[slot] = item;
const index = this.items.indexOf(item);
if (index > -1) {
this.items.splice(index, 1);
}
return true;
}
/**
* Unequip an item
*/
unequipItem(slot) {
if (this.equipped[slot]) {
this.items.push(this.equipped[slot]);
this.equipped[slot] = null;
return true;
}
return false;
}
}

View file

@ -0,0 +1,97 @@
import { Component } from '../core/Component.ts';
import { ComponentType } from '../core/Constants.ts';
import type { Item } from '../items/Item.ts';
/**
* Equipment slots structure
*/
export interface Equipment {
weapon: Item | null;
armor: Item | null;
accessory: Item | null;
}
/**
* Component for managing an entity's items and equipment.
*/
export class Inventory extends Component {
items: Item[];
maxSize: number;
equipped: Equipment;
constructor() {
super(ComponentType.INVENTORY);
this.items = [];
this.maxSize = 20;
this.equipped = {
weapon: null,
armor: null,
accessory: null,
};
}
/**
* Add an item to the inventory.
* @param item - The item to add
* @returns True if the item was added, false if inventory is full
*/
addItem(item: Item): boolean {
if (this.items.length < this.maxSize) {
this.items.push(item);
return true;
}
return false;
}
/**
* Remove an item from the inventory.
* @param itemId - The ID of the item to remove
* @returns The removed item, or null if not found
*/
removeItem(itemId: string): Item | null {
const index = this.items.findIndex((item) => item.id === itemId);
if (index > -1) {
return this.items.splice(index, 1)[0];
}
return null;
}
/**
* Equip an item from the inventory into a specific slot.
* @param itemId - The ID of the item to equip
* @param slot - The equipment slot
* @returns True if the item was successfully equipped
*/
equipItem(itemId: string, slot: keyof Equipment): boolean {
const item = this.items.find((i) => i.id === itemId);
if (!item) return false;
const currentItem = this.equipped[slot];
if (currentItem) {
this.items.push(currentItem);
}
this.equipped[slot] = item;
const index = this.items.indexOf(item);
if (index > -1) {
this.items.splice(index, 1);
}
return true;
}
/**
* Unequip an item from a slot and return it to the inventory.
* @param slot - The equipment slot to clear
* @returns True if an item was unequipped
*/
unequipItem(slot: keyof Equipment): boolean {
const item = this.equipped[slot];
if (item) {
this.items.push(item);
this.equipped[slot] = null;
return true;
}
return false;
}
}

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

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

View file

@ -1,11 +0,0 @@
import { Component } from '../core/Component.js';
export class Position extends Component {
constructor(x = 0, y = 0, rotation = 0) {
super('Position');
this.x = x;
this.y = y;
this.rotation = rotation;
}
}

View file

@ -0,0 +1,23 @@
import { Component } from '../core/Component.ts';
import { ComponentType } from '../core/Constants.ts';
/**
* Component for tracking entity position and rotation in the game world.
*/
export class Position extends Component {
x: number;
y: number;
rotation: number;
/**
* @param x - The X-coordinate
* @param y - The Y-coordinate
* @param rotation - The rotation in radians
*/
constructor(x = 0, y = 0, rotation = 0) {
super(ComponentType.POSITION);
this.x = x;
this.y = y;
this.rotation = rotation;
}
}

View file

@ -1,45 +0,0 @@
import { Component } from '../core/Component.js';
/**
* Tracks progress toward learning skills
* Need to absorb multiple enemies with the same skill to learn it
*/
export class SkillProgress extends Component {
constructor() {
super('SkillProgress');
this.skillProgress = new Map(); // skillId -> count (how many times absorbed)
this.requiredAbsorptions = 5; // Need to absorb 5 enemies with a skill to learn it
}
/**
* Add progress toward learning a skill
*/
addSkillProgress(skillId) {
const current = this.skillProgress.get(skillId) || 0;
this.skillProgress.set(skillId, current + 1);
return this.skillProgress.get(skillId);
}
/**
* Check if skill can be learned
*/
canLearnSkill(skillId) {
const progress = this.skillProgress.get(skillId) || 0;
return progress >= this.requiredAbsorptions;
}
/**
* Get progress for a skill
*/
getSkillProgress(skillId) {
return this.skillProgress.get(skillId) || 0;
}
/**
* Get all skill progress
*/
getAllProgress() {
return this.skillProgress;
}
}

View file

@ -0,0 +1,56 @@
import { Component } from '../core/Component.ts';
import { ComponentType } from '../core/Constants.ts';
/**
* Tracks progress toward learning skills.
* Need to absorb multiple enemies with the same skill to learn it.
*/
export class SkillProgress extends Component {
skillProgress: Map<string, number>;
requiredAbsorptions: number;
constructor() {
super(ComponentType.SKILL_PROGRESS);
this.skillProgress = new Map();
this.requiredAbsorptions = 5;
}
/**
* Add progress toward learning a skill.
* @param skillId - The ID of the skill
* @returns The current absorption count for this skill
*/
addSkillProgress(skillId: string): number {
const current = this.skillProgress.get(skillId) || 0;
const newValue = current + 1;
this.skillProgress.set(skillId, newValue);
return newValue;
}
/**
* Check if a skill has been absorbed enough times to be learned.
* @param skillId - The ID of the skill
* @returns True if progress is greater than or equal to requirements
*/
canLearnSkill(skillId: string): boolean {
const progress = this.skillProgress.get(skillId) || 0;
return progress >= this.requiredAbsorptions;
}
/**
* Get the current absorption count for a specific skill.
* @param skillId - The ID of the skill
* @returns The current progress count
*/
getSkillProgress(skillId: string): number {
return this.skillProgress.get(skillId) || 0;
}
/**
* Get the entire skill progress map.
* @returns The progress map
*/
getAllProgress(): Map<string, number> {
return this.skillProgress;
}
}

View file

@ -1,69 +0,0 @@
import { Component } from '../core/Component.js';
export class Skills extends Component {
constructor() {
super('Skills');
this.activeSkills = []; // Array of skill IDs
this.passiveSkills = []; // Array of passive skill IDs
this.skillCooldowns = new Map(); // skillId -> remaining cooldown time
}
/**
* Add a skill
*/
addSkill(skillId, isPassive = false) {
if (isPassive) {
if (!this.passiveSkills.includes(skillId)) {
this.passiveSkills.push(skillId);
}
} else {
if (!this.activeSkills.includes(skillId)) {
this.activeSkills.push(skillId);
}
}
}
/**
* Check if entity has a skill
*/
hasSkill(skillId) {
return this.activeSkills.includes(skillId) ||
this.passiveSkills.includes(skillId);
}
/**
* Set skill cooldown
*/
setCooldown(skillId, duration) {
this.skillCooldowns.set(skillId, duration);
}
/**
* Update cooldowns
*/
updateCooldowns(deltaTime) {
for (const [skillId, cooldown] of this.skillCooldowns.entries()) {
const newCooldown = cooldown - deltaTime;
if (newCooldown <= 0) {
this.skillCooldowns.delete(skillId);
} else {
this.skillCooldowns.set(skillId, newCooldown);
}
}
}
/**
* Check if skill is on cooldown
*/
isOnCooldown(skillId) {
return this.skillCooldowns.has(skillId);
}
/**
* Get remaining cooldown
*/
getCooldown(skillId) {
return this.skillCooldowns.get(skillId) || 0;
}
}

86
src/components/Skills.ts Normal file
View file

@ -0,0 +1,86 @@
import { Component } from '../core/Component.ts';
import { ComponentType } from '../core/Constants.ts';
/**
* Component for managing an entity's active and passive skills.
*/
export class Skills extends Component {
activeSkills: string[];
passiveSkills: string[];
skillCooldowns: Map<string, number>;
constructor() {
super(ComponentType.SKILLS);
this.activeSkills = [];
this.passiveSkills = [];
this.skillCooldowns = new Map();
}
/**
* Add a skill to the entity.
* @param skillId - The ID of the skill to add
* @param isPassive - Whether the skill is passive
*/
addSkill(skillId: string, isPassive = false): void {
if (isPassive) {
if (!this.passiveSkills.includes(skillId)) {
this.passiveSkills.push(skillId);
}
} else {
if (!this.activeSkills.includes(skillId)) {
this.activeSkills.push(skillId);
}
}
}
/**
* Check if the entity has a specific skill.
* @param skillId - The ID of the skill to check
* @returns True if the entity has the skill (active or passive)
*/
hasSkill(skillId: string): boolean {
return this.activeSkills.includes(skillId) || this.passiveSkills.includes(skillId);
}
/**
* Set the remaining cooldown duration for a skill.
* @param skillId - The ID of the skill
* @param duration - The cooldown duration in seconds
*/
setCooldown(skillId: string, duration: number): void {
this.skillCooldowns.set(skillId, duration);
}
/**
* Update all active cooldowns.
* @param deltaTime - Time elapsed since last frame in seconds
*/
updateCooldowns(deltaTime: number): void {
for (const [skillId, cooldown] of this.skillCooldowns.entries()) {
const newCooldown = cooldown - deltaTime;
if (newCooldown <= 0) {
this.skillCooldowns.delete(skillId);
} else {
this.skillCooldowns.set(skillId, newCooldown);
}
}
}
/**
* Check if a skill is currently on cooldown.
* @param skillId - The ID of the skill
* @returns True if the skill is on cooldown
*/
isOnCooldown(skillId: string): boolean {
return this.skillCooldowns.has(skillId);
}
/**
* Get the remaining cooldown duration for a skill.
* @param skillId - The ID of the skill
* @returns The remaining cooldown in seconds, or 0 if not on cooldown
*/
getCooldown(skillId: string): number {
return this.skillCooldowns.get(skillId) || 0;
}
}

View file

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

View file

@ -1,18 +0,0 @@
import { Component } from '../core/Component.js';
export class Sprite extends Component {
constructor(color = '#00ff96', width = 30, height = 30, shape = 'circle') {
super('Sprite');
this.color = color;
this.width = width;
this.height = height;
this.shape = shape; // 'circle', 'rect', 'slime'
this.alpha = 1.0;
this.scale = 1.0;
// Animation properties
this.animationTime = 0;
this.morphAmount = 0; // For slime morphing
}
}

43
src/components/Sprite.ts Normal file
View file

@ -0,0 +1,43 @@
import { Component } from '../core/Component.ts';
import { ComponentType } from '../core/Constants.ts';
import { EntityType } from '../core/Constants.ts';
import { AnimationState } from '../core/Constants.ts';
/**
* Component for entity visual representation and animation.
*/
export class Sprite extends Component {
color: string;
width: number;
height: number;
shape: string | EntityType;
alpha: number;
scale: number;
animationTime: number;
animationState: string | AnimationState;
animationSpeed: number;
morphAmount: number;
yOffset: number;
/**
* @param color - The CSS color or hex code
* @param width - The width of the sprite
* @param height - The height of the sprite
* @param shape - The shape type ('circle', 'rect', 'slime', or EntityType)
*/
constructor(color = '#00ff96', width = 30, height = 30, shape: string | EntityType = 'circle') {
super(ComponentType.SPRITE);
this.color = color;
this.width = width;
this.height = height;
this.shape = shape;
this.alpha = 1.0;
this.scale = 1.0;
this.animationTime = 0;
this.animationState = AnimationState.IDLE;
this.animationSpeed = 4;
this.morphAmount = 0;
this.yOffset = 0;
}
}

View file

@ -1,55 +0,0 @@
import { Component } from '../core/Component.js';
export class Stats extends Component {
constructor() {
super('Stats');
this.strength = 10; // Physical damage
this.agility = 10; // Movement speed, attack speed
this.intelligence = 10; // Magic damage, skill effectiveness
this.constitution = 10; // Max HP, defense
this.perception = 10; // Detection range, stealth detection
// Derived stats
this.level = 1;
this.experience = 0;
this.experienceToNext = 100;
}
/**
* Add experience and handle level ups
*/
addExperience(amount) {
this.experience += amount;
let leveledUp = false;
while (this.experience >= this.experienceToNext) {
this.experience -= this.experienceToNext;
this.levelUp();
leveledUp = true;
}
return leveledUp;
}
/**
* Level up - increase stats
*/
levelUp() {
this.level++;
this.strength += 2;
this.agility += 2;
this.intelligence += 2;
this.constitution += 2;
this.perception += 2;
this.experienceToNext = Math.floor(this.experienceToNext * 1.5);
}
/**
* Get total stat points
*/
getTotalStats() {
return this.strength + this.agility + this.intelligence +
this.constitution + this.perception;
}
}

68
src/components/Stats.ts Normal file
View file

@ -0,0 +1,68 @@
import { Component } from '../core/Component.ts';
import { ComponentType } from '../core/Constants.ts';
/**
* Component for entity base attributes and leveling.
*/
export class Stats extends Component {
strength: number;
agility: number;
intelligence: number;
constitution: number;
perception: number;
level: number;
experience: number;
experienceToNext: number;
constructor() {
super(ComponentType.STATS);
this.strength = 10;
this.agility = 10;
this.intelligence = 10;
this.constitution = 10;
this.perception = 10;
this.level = 1;
this.experience = 0;
this.experienceToNext = 100;
}
/**
* Add experience points and level up if threshold is reached.
* @param amount - The amount of experience to add
* @returns True if the entity leveled up
*/
addExperience(amount: number): boolean {
this.experience += amount;
let leveledUp = false;
while (this.experience >= this.experienceToNext) {
this.experience -= this.experienceToNext;
this.levelUp();
leveledUp = true;
}
return leveledUp;
}
/**
* Increase level and all base attributes.
*/
levelUp(): void {
this.level++;
this.strength += 2;
this.agility += 2;
this.intelligence += 2;
this.constitution += 2;
this.perception += 2;
this.experienceToNext = Math.floor(this.experienceToNext * 1.5);
}
/**
* Calculate the sum of all base attributes.
* @returns The total attribute points
*/
getTotalStats(): number {
return this.strength + this.agility + this.intelligence + this.constitution + this.perception;
}
}

View file

@ -1,48 +0,0 @@
import { Component } from '../core/Component.js';
export class Stealth extends Component {
constructor() {
super('Stealth');
this.visibility = 1.0; // 0 = fully hidden, 1 = fully visible
this.stealthType = 'slime'; // 'slime', 'beast', 'human'
this.isStealthed = false;
this.stealthLevel = 0; // 0-100
this.detectionRadius = 100; // How far others can detect this entity
}
/**
* Enter stealth mode
*/
enterStealth(type) {
this.stealthType = type;
this.isStealthed = true;
this.visibility = 0.3;
}
/**
* Exit stealth mode
*/
exitStealth() {
this.isStealthed = false;
this.visibility = 1.0;
}
/**
* Update stealth based on movement and actions
*/
updateStealth(isMoving, isInCombat) {
if (isInCombat) {
this.exitStealth();
return;
}
if (this.isStealthed) {
if (isMoving) {
this.visibility = Math.min(1.0, this.visibility + 0.1);
} else {
this.visibility = Math.max(0.1, this.visibility - 0.05);
}
}
}
}

80
src/components/Stealth.ts Normal file
View file

@ -0,0 +1,80 @@
import { Component } from '../core/Component.ts';
import { ComponentType } from '../core/Constants.ts';
/**
* Component for managing entity visibility and stealth mechanics.
*/
export class Stealth extends Component {
visibility: number;
stealthType: string;
isStealthed: boolean;
stealthLevel: number;
detectionRadius: number;
camouflageColor: string | null;
baseColor: string | null;
sizeMultiplier: number;
formAppearance: string | null;
constructor() {
super(ComponentType.STEALTH);
this.visibility = 1.0;
this.stealthType = 'slime';
this.isStealthed = false;
this.stealthLevel = 0;
this.detectionRadius = 100;
this.camouflageColor = null;
this.baseColor = null;
this.sizeMultiplier = 1.0;
this.formAppearance = null;
}
/**
* Enter stealth mode.
* @param type - The type of stealth (e.g., 'slime', 'human')
* @param baseColor - Original entity color to restore later
*/
enterStealth(type: string, baseColor?: string): void {
this.stealthType = type;
this.isStealthed = true;
this.visibility = 0.3;
if (baseColor) {
this.baseColor = baseColor;
}
if (type === 'slime') {
this.sizeMultiplier = 0.6;
} else {
this.sizeMultiplier = 1.0;
}
}
/**
* Exit stealth mode and restore full visibility.
*/
exitStealth(): void {
this.isStealthed = false;
this.visibility = 1.0;
this.camouflageColor = null;
this.sizeMultiplier = 1.0;
this.formAppearance = null;
}
/**
* Update visibility levels based on movement and combat state.
* @param isMoving - Whether the entity is currently moving
* @param isInCombat - Whether the entity is currently in combat
*/
updateStealth(isMoving: boolean, isInCombat: boolean): void {
if (isInCombat) {
this.exitStealth();
return;
}
if (this.isStealthed) {
if (isMoving) {
this.visibility = Math.min(1.0, this.visibility + 0.1);
} else {
this.visibility = Math.max(0.1, this.visibility - 0.05);
}
}
}
}

View file

@ -1,11 +0,0 @@
import { Component } from '../core/Component.js';
export class Velocity extends Component {
constructor(vx = 0, vy = 0) {
super('Velocity');
this.vx = vx;
this.vy = vy;
this.maxSpeed = 200;
}
}

View file

@ -0,0 +1,26 @@
import { Component } from '../core/Component.ts';
import { ComponentType } from '../core/Constants.ts';
/**
* Component for tracking entity velocity and movement speed limits.
*/
export class Velocity extends Component {
vx: number;
vy: number;
maxSpeed: number;
isLocked: boolean;
lockTimer: number;
/**
* @param vx - Initial X velocity
* @param vy - Initial Y velocity
*/
constructor(vx = 0, vy = 0) {
super(ComponentType.VELOCITY);
this.vx = vx;
this.vy = vy;
this.maxSpeed = 200;
this.isLocked = false;
this.lockTimer = 0;
}
}

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

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

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

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

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

@ -0,0 +1,102 @@
import type { TileMap } from './TileMap.ts';
import { Palette } from './Palette.ts';
/**
* Utility for sampling colors from the background and tile map.
*/
export class ColorSampler {
private static cache: Map<string, string> = new Map();
private static cacheFrame: number = 0;
/**
* Sample the dominant color from a region around a position based on tile map and background.
* @param tileMap - The tile map to sample from
* @param x - Center X coordinate in world space
* @param y - Center Y coordinate in world space
* @param radius - Sampling radius in pixels
* @returns Dominant color as hex string (e.g., '#1a1a2e')
*/
static sampleDominantColor(
tileMap: TileMap | null,
x: number,
y: number,
radius: number
): string {
const cacheKey = `${Math.floor(x / 20)}_${Math.floor(y / 20)}`;
const currentFrame = Math.floor(Date.now() / 200);
if (currentFrame !== this.cacheFrame) {
this.cache.clear();
this.cacheFrame = currentFrame;
}
if (this.cache.has(cacheKey)) {
return this.cache.get(cacheKey) || Palette.VOID;
}
if (!tileMap) {
return Palette.VOID;
}
const tileSize = tileMap.tileSize;
const startCol = Math.max(0, Math.floor((x - radius) / tileSize));
const endCol = Math.min(tileMap.cols, Math.ceil((x + radius) / tileSize));
const startRow = Math.max(0, Math.floor((y - radius) / tileSize));
const endRow = Math.min(tileMap.rows, Math.ceil((y + radius) / tileSize));
const colorCounts: Map<string, number> = new Map();
let totalTiles = 0;
for (let r = startRow; r < endRow; r++) {
for (let c = startCol; c < endCol; c++) {
const tileType = tileMap.getTile(c, r);
let color: string;
if (tileType === 1) {
color = Palette.DARK_BLUE;
} else {
const distFromCenter = Math.sqrt(
Math.pow(c * tileSize - x, 2) + Math.pow(r * tileSize - y, 2)
);
if (distFromCenter < radius) {
const noise = Math.sin(c * 0.1 + r * 0.1) * 0.1;
if (Math.random() < 0.3 + noise) {
color = Palette.DARKER_BLUE;
} else {
color = Palette.VOID;
}
} else {
continue;
}
}
colorCounts.set(color, (colorCounts.get(color) || 0) + 1);
totalTiles++;
}
}
if (totalTiles === 0) {
return Palette.VOID;
}
let dominantColor = Palette.VOID;
let maxCount = 0;
colorCounts.forEach((count, color) => {
if (count > maxCount) {
maxCount = count;
dominantColor = color;
}
});
this.cache.set(cacheKey, dominantColor);
return dominantColor;
}
/**
* Clear the color sampling cache.
*/
static clearCache(): void {
this.cache.clear();
}
}

View file

@ -1,14 +0,0 @@
/**
* Base Component class for ECS architecture
* Components are pure data containers
*/
export class Component {
constructor(type) {
this.type = type;
}
static getType() {
return this.name;
}
}

25
src/core/Component.ts Normal file
View file

@ -0,0 +1,25 @@
/**
* Base Component class for ECS architecture.
* Components are pure data containers.
*/
export class Component {
/**
* The unique type identifier for this component
*/
readonly type: string;
/**
* @param type - The unique type identifier for this component
*/
constructor(type: string) {
this.type = type;
}
/**
* Get the type name of the component class.
* @returns The name of the class
*/
static getType(): string {
return this.name;
}
}

88
src/core/Constants.ts Normal file
View file

@ -0,0 +1,88 @@
/**
* Game state enumeration.
*/
export enum GameState {
/** Initial start screen */
START = 'start',
/** Active gameplay */
PLAYING = 'playing',
/** Game paused */
PAUSED = 'paused',
/** Player death screen */
GAME_OVER = 'gameOver',
}
/**
* Component type identifiers.
*/
export enum ComponentType {
POSITION = 'Position',
VELOCITY = 'Velocity',
SPRITE = 'Sprite',
HEALTH = 'Health',
COMBAT = 'Combat',
AI = 'AI',
EVOLUTION = 'Evolution',
STATS = 'Stats',
SKILLS = 'Skills',
SKILL_PROGRESS = 'SkillProgress',
ABSORBABLE = 'Absorbable',
STEALTH = 'Stealth',
INTENT = 'Intent',
INVENTORY = 'Inventory',
MUSIC = 'Music',
SOUND_EFFECTS = 'SoundEffects',
CAMERA = 'Camera',
}
/**
* Entity type identifiers for sprites and behaviors.
*/
export enum EntityType {
SLIME = 'slime',
HUMANOID = 'humanoid',
BEAST = 'beast',
ELEMENTAL = 'elemental',
PROJECTILE = 'projectile',
}
/**
* Animation states for sprites.
*/
export enum AnimationState {
IDLE = 'idle',
WALK = 'walk',
}
/**
* Visual effect types.
*/
export enum VFXType {
IMPACT = 'impact',
ABSORPTION = 'absorption',
}
/**
* System name identifiers.
*/
export enum SystemName {
MENU = 'MenuSystem',
UI = 'UISystem',
PLAYER_CONTROLLER = 'PlayerControllerSystem',
ABSORPTION = 'AbsorptionSystem',
COMBAT = 'CombatSystem',
PROJECTILE = 'ProjectileSystem',
VFX = 'VFXSystem',
MOVEMENT = 'MovementSystem',
AI = 'AISystem',
DEATH = 'DeathSystem',
RENDER = 'RenderSystem',
INPUT = 'InputSystem',
SKILL_EFFECT = 'SkillEffectSystem',
SKILL = 'SkillSystem',
STEALTH = 'StealthSystem',
HEALTH_REGEN = 'HealthRegenerationSystem',
MUSIC = 'MusicSystem',
SOUND_EFFECTS = 'SoundEffectsSystem',
CAMERA = 'CameraSystem',
}

View file

@ -1,139 +0,0 @@
import { System } from './System.js';
import { Entity } from './Entity.js';
import { EventBus } from './EventBus.js';
/**
* Main game engine - manages ECS, game loop, and systems
*/
export class Engine {
constructor(canvas) {
this.canvas = canvas;
this.ctx = canvas.getContext('2d');
this.entities = [];
this.systems = [];
this.events = new EventBus();
this.running = false;
this.lastTime = 0;
// Set canvas size
this.canvas.width = 1024;
this.canvas.height = 768;
// Game state
this.deltaTime = 0;
}
/**
* Add a system to the engine
*/
addSystem(system) {
if (system instanceof System) {
system.init(this);
this.systems.push(system);
// Sort by priority (lower priority runs first)
this.systems.sort((a, b) => a.priority - b.priority);
}
return this;
}
/**
* Emit an event locally
*/
emit(event, data) {
this.events.emit(event, data);
}
/**
* Subscribe to an event
*/
on(event, callback) {
return this.events.on(event, callback);
}
/**
* Create and add an entity
*/
createEntity() {
const entity = new Entity();
this.entities.push(entity);
return entity;
}
/**
* Remove an entity
*/
removeEntity(entity) {
const index = this.entities.indexOf(entity);
if (index > -1) {
this.entities.splice(index, 1);
}
}
/**
* Get all entities
*/
getEntities() {
return this.entities.filter(e => e.active);
}
/**
* Main game loop
*/
start() {
if (this.running) return;
this.running = true;
this.lastTime = performance.now();
this.gameLoop();
}
/**
* Stop the game loop
*/
stop() {
this.running = false;
}
/**
* Game loop using requestAnimationFrame
*/
gameLoop = (currentTime = 0) => {
if (!this.running) return;
// Calculate delta time
this.deltaTime = (currentTime - this.lastTime) / 1000; // Convert to seconds
this.lastTime = currentTime;
// Clamp delta time to prevent large jumps
this.deltaTime = Math.min(this.deltaTime, 0.1);
// Update all systems
const menuSystem = this.systems.find(s => s.name === 'MenuSystem');
const gameState = menuSystem ? menuSystem.getGameState() : 'playing';
const isPaused = gameState === 'paused' || gameState === 'start';
this.systems.forEach(system => {
// Skip game systems if paused/start menu (but allow MenuSystem, UISystem, and RenderSystem)
if (isPaused && system.name !== 'MenuSystem' && system.name !== 'UISystem' && system.name !== 'RenderSystem') {
return;
}
system.update(this.deltaTime, this.entities);
});
// Update input system's previous states at end of frame
const inputSystem = this.systems.find(s => s.name === 'InputSystem');
if (inputSystem && inputSystem.updatePreviousStates) {
inputSystem.updatePreviousStates();
}
// Continue loop
requestAnimationFrame(this.gameLoop);
}
/**
* Clear the canvas
*/
clear() {
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
}
}

171
src/core/Engine.ts Normal file
View file

@ -0,0 +1,171 @@
import { System } from './System.ts';
import { Entity } from './Entity.ts';
import { EventBus } from './EventBus.ts';
import { LevelLoader } from './LevelLoader.ts';
import { GameState, SystemName } from './Constants.ts';
import type { TileMap } from './TileMap.ts';
import type { MenuSystem } from '../systems/MenuSystem.ts';
import type { InputSystem } from '../systems/InputSystem.ts';
/**
* Main game engine responsible for managing the ECS lifecycle, game loop, and system execution.
*/
export class Engine {
canvas: HTMLCanvasElement;
ctx: CanvasRenderingContext2D;
entities: Entity[];
systems: System[];
events: EventBus;
running: boolean;
lastTime: number;
deltaTime: number;
tileMap: TileMap | null;
/**
* @param canvas - The canvas element to render to
*/
constructor(canvas: HTMLCanvasElement) {
this.canvas = canvas;
const ctx = canvas.getContext('2d');
if (!ctx) {
throw new Error('Failed to get 2d rendering context from canvas');
}
this.ctx = ctx;
this.entities = [];
this.systems = [];
this.events = new EventBus();
this.running = false;
this.lastTime = 0;
this.canvas.width = 320;
this.canvas.height = 240;
this.canvas.style.imageRendering = 'pixelated';
this.ctx.imageSmoothingEnabled = false;
this.deltaTime = 0;
this.tileMap = LevelLoader.loadDesignedLevel(200, 150, 16);
}
/**
* Add a system and sort systems by priority.
* @param system - The system to add
* @returns This engine instance
*/
addSystem(system: System): Engine {
if (system instanceof System) {
system.init(this);
this.systems.push(system);
this.systems.sort((a, b) => a.priority - b.priority);
}
return this;
}
/**
* Emit a global event.
* @param event - Unique event identifier
* @param data - Optional event payload
*/
emit(event: string, data?: unknown): void {
this.events.emit(event, data);
}
/**
* Subscribe to a global event.
* @param event - Unique event identifier
* @param callback - Function to execute when event is emitted
* @returns Unsubscribe function
*/
on(event: string, callback: (data?: unknown) => void): () => void {
return this.events.on(event, callback);
}
/**
* Create a new entity and track it.
* @returns The newly created entity
*/
createEntity(): Entity {
const entity = new Entity();
this.entities.push(entity);
return entity;
}
/**
* Permanently remove an entity from the engine.
* @param entity - The entity to remove
*/
removeEntity(entity: Entity): void {
const index = this.entities.indexOf(entity);
if (index > -1) {
this.entities.splice(index, 1);
}
}
/**
* Retrieve all currently active entities.
* @returns List of active entities
*/
getEntities(): Entity[] {
return this.entities.filter((e) => e.active);
}
/**
* Start the game loop.
*/
start(): void {
if (this.running) return;
this.running = true;
this.lastTime = performance.now();
this.gameLoop();
}
/**
* Terminate the game loop.
*/
stop(): void {
this.running = false;
}
/**
* Core game loop executing system updates.
* @param currentTime - High-resolution timestamp
*/
gameLoop = (currentTime = 0): void => {
if (!this.running) return;
this.deltaTime = (currentTime - this.lastTime) / 1000;
this.lastTime = currentTime;
this.deltaTime = Math.min(this.deltaTime, 0.1);
const menuSystem = this.systems.find((s) => s.name === SystemName.MENU) as
| MenuSystem
| undefined;
const gameState = menuSystem ? menuSystem.getGameState() : GameState.PLAYING;
const isPaused = [GameState.PAUSED, GameState.START, GameState.GAME_OVER].includes(gameState);
const unskippedSystems = [SystemName.MENU, SystemName.UI, SystemName.RENDER, SystemName.MUSIC];
this.systems.forEach((system) => {
if (isPaused && !unskippedSystems.includes(system.name as SystemName)) {
return;
}
system.update(this.deltaTime, this.entities);
});
const inputSystem = this.systems.find((s) => s.name === SystemName.INPUT) as
| InputSystem
| undefined;
if (inputSystem && inputSystem.updatePreviousStates) {
inputSystem.updatePreviousStates();
}
requestAnimationFrame(this.gameLoop);
};
/**
* Clear the rendering surface.
*/
clear(): void {
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
}
}

View file

@ -1,58 +0,0 @@
/**
* Entity class - represents a game object with a unique ID
* Entities are just containers for components
*/
export class Entity {
static nextId = 0;
constructor() {
this.id = Entity.nextId++;
this.components = new Map();
this.active = true;
}
/**
* Add a component to this entity
*/
addComponent(component) {
this.components.set(component.type, component);
return this;
}
/**
* Get a component by type
*/
getComponent(type) {
return this.components.get(type);
}
/**
* Check if entity has a component
*/
hasComponent(type) {
return this.components.has(type);
}
/**
* Check if entity has all specified components
*/
hasComponents(...types) {
return types.every(type => this.components.has(type));
}
/**
* Remove a component
*/
removeComponent(type) {
this.components.delete(type);
return this;
}
/**
* Get all components
*/
getAllComponents() {
return Array.from(this.components.values());
}
}

82
src/core/Entity.ts Normal file
View file

@ -0,0 +1,82 @@
import { Component } from './Component.ts';
/**
* Entity class - represents a game object with a unique ID.
* Entities are containers for components.
*/
export class Entity {
private static nextId = 0;
readonly id: number;
private components: Map<string, Component>;
active: boolean;
owner?: number;
startX?: number;
startY?: number;
maxRange?: number;
lifetime?: number;
damage?: number;
deathTime?: number;
constructor() {
this.id = Entity.nextId++;
this.components = new Map();
this.active = true;
}
/**
* Add a component to this entity.
* @param component - The component to add
* @returns This entity for chaining
*/
addComponent(component: Component): Entity {
this.components.set(component.type, component);
return this;
}
/**
* Get a component by type.
* @param type - The component type name
* @returns The component instance if found
*/
getComponent<T extends Component>(type: string): T | undefined {
return this.components.get(type) as T | undefined;
}
/**
* Check if entity has a component.
* @param type - The component type name
* @returns True if the component exists
*/
hasComponent(type: string): boolean {
return this.components.has(type);
}
/**
* Check if entity has all specified components.
* @param types - List of component type names
* @returns True if all components exist
*/
hasComponents(...types: string[]): boolean {
return types.every((type) => this.components.has(type));
}
/**
* Remove a component.
* @param type - The component type name
* @returns This entity for chaining
*/
removeComponent(type: string): Entity {
this.components.delete(type);
return this;
}
/**
* Get all components attached to this entity.
* @returns Array of components
*/
getAllComponents(): Component[] {
return Array.from(this.components.values());
}
}

View file

@ -1,57 +0,0 @@
/**
* Lightweight EventBus for pub/sub communication between systems
*/
export const Events = {
// Combat Events
DAMAGE_DEALT: 'combat:damage_dealt',
ENTITY_DIED: 'combat:entity_died',
// Evolution Events
EVOLVED: 'evolution:evolved',
MUTATION_GAINED: 'evolution:mutation_gained',
// Leveling Events
EXP_GAINED: 'stats:exp_gained',
LEVEL_UP: 'stats:level_up',
// Skill Events
SKILL_LEARNED: 'skills:learned',
SKILL_COOLDOWN_STARTED: 'skills:cooldown_started'
};
export class EventBus {
constructor() {
this.listeners = new Map();
}
/**
* Subscribe to an event
*/
on(event, callback) {
if (!this.listeners.has(event)) {
this.listeners.set(event, []);
}
this.listeners.get(event).push(callback);
return () => this.off(event, callback);
}
/**
* Unsubscribe from an event
*/
off(event, callback) {
if (!this.listeners.has(event)) return;
const callbacks = this.listeners.get(event);
const index = callbacks.indexOf(callback);
if (index > -1) {
callbacks.splice(index, 1);
}
}
/**
* Emit an event
*/
emit(event, data) {
if (!this.listeners.has(event)) return;
this.listeners.get(event).forEach(callback => callback(data));
}
}

104
src/core/EventBus.ts Normal file
View file

@ -0,0 +1,104 @@
/**
* Enum for game-wide event types.
*/
export enum Events {
DAMAGE_DEALT = 'combat:damage_dealt',
ENTITY_DIED = 'combat:entity_died',
EVOLVED = 'evolution:evolved',
MUTATION_GAINED = 'evolution:mutation_gained',
EXP_GAINED = 'stats:exp_gained',
LEVEL_UP = 'stats:level_up',
SKILL_LEARNED = 'skills:learned',
ATTACK_PERFORMED = 'combat:attack_performed',
SKILL_COOLDOWN_STARTED = 'skills:cooldown_started',
ABSORPTION = 'absorption:absorbed',
PROJECTILE_CREATED = 'projectile:created',
PROJECTILE_IMPACT = 'projectile:impact',
}
/**
* Event data types
*/
export interface DamageDealtEvent {
x: number;
y: number;
value: number;
color: string;
}
export interface MutationGainedEvent {
name: string;
description?: string;
}
export interface SkillLearnedEvent {
id: string;
}
export interface EntityDiedEvent {
entity: unknown;
}
export interface AttackPerformedEvent {
entity: unknown;
angle: number;
}
type EventCallback = (data?: unknown) => void;
/**
* Lightweight EventBus for pub/sub communication between systems.
*/
export class EventBus {
private listeners: Map<string, EventCallback[]>;
constructor() {
this.listeners = new Map();
}
/**
* Subscribe to an event with a callback.
* @param event - The event name from the Events enum
* @param callback - The function to call when the event is emitted
* @returns An unsubscribe function
*/
on(event: string, callback: EventCallback): () => void {
if (!this.listeners.has(event)) {
this.listeners.set(event, []);
}
const callbacks = this.listeners.get(event);
if (callbacks) {
callbacks.push(callback);
}
return () => this.off(event, callback);
}
/**
* Unsubscribe a specific callback from an event.
* @param event - The event name
* @param callback - The original callback function to remove
*/
off(event: string, callback: EventCallback): void {
if (!this.listeners.has(event)) return;
const callbacks = this.listeners.get(event);
if (callbacks) {
const index = callbacks.indexOf(callback);
if (index > -1) {
callbacks.splice(index, 1);
}
}
}
/**
* Emit an event to all subscribers.
* @param event - The event name
* @param data - Data to pass to the callbacks
*/
emit(event: string, data?: unknown): void {
if (!this.listeners.has(event)) return;
const callbacks = this.listeners.get(event);
if (callbacks) {
callbacks.forEach((callback) => callback(data));
}
}
}

125
src/core/LevelLoader.ts Normal file
View file

@ -0,0 +1,125 @@
import { TileMap } from './TileMap.ts';
/**
* Utility class responsible for generating or loading level tile maps.
*/
export class LevelLoader {
/**
* Generates a simple arena level with walls at the boundaries and random obstacles.
* @param cols - Map width in tiles
* @param rows - Map height in tiles
* @param tileSize - Tile size in pixels
* @returns The generated tile map
*/
static loadSimpleLevel(cols: number, rows: number, tileSize: number): 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 {
if (Math.random() < 0.1) {
map.setTile(c, r, 1);
}
}
}
}
return map;
}
/**
* Generates a larger designed map with rooms, corridors, and interesting layout.
* @param cols - Map width in tiles (default 200)
* @param rows - Map height in tiles (default 150)
* @param tileSize - Tile size in pixels (default 16)
* @returns The generated tile map
*/
static loadDesignedLevel(cols = 200, rows = 150, tileSize = 16): TileMap {
const map = new TileMap(cols, rows, tileSize);
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
if (r === 0 || r === rows - 1 || c === 0 || c === cols - 1) {
map.setTile(c, r, 1);
} else {
map.setTile(c, r, 0);
}
}
}
const roomCount = 15;
const rooms: Array<{ x: number; y: number; w: number; h: number }> = [];
for (let i = 0; i < roomCount; i++) {
const roomW = 8 + Math.floor(Math.random() * 12);
const roomH = 8 + Math.floor(Math.random() * 12);
const roomX = 2 + Math.floor(Math.random() * (cols - roomW - 4));
const roomY = 2 + Math.floor(Math.random() * (rows - roomH - 4));
let overlaps = false;
for (const existingRoom of rooms) {
if (
roomX < existingRoom.x + existingRoom.w + 2 &&
roomX + roomW + 2 > existingRoom.x &&
roomY < existingRoom.y + existingRoom.h + 2 &&
roomY + roomH + 2 > existingRoom.y
) {
overlaps = true;
break;
}
}
if (!overlaps) {
rooms.push({ x: roomX, y: roomY, w: roomW, h: roomH });
for (let ry = roomY; ry < roomY + roomH; ry++) {
for (let rx = roomX; rx < roomX + roomW; rx++) {
if (rx > 0 && rx < cols - 1 && ry > 0 && ry < rows - 1) {
map.setTile(rx, ry, 0);
}
}
}
}
}
for (let i = 1; i < rooms.length; i++) {
const prevRoom = rooms[i - 1];
const currRoom = rooms[i];
const startX = Math.floor(prevRoom.x + prevRoom.w / 2);
const startY = Math.floor(prevRoom.y + prevRoom.h / 2);
const endX = Math.floor(currRoom.x + currRoom.w / 2);
const endY = Math.floor(currRoom.y + currRoom.h / 2);
let x = startX;
let y = startY;
while (x !== endX || y !== endY) {
if (x > 0 && x < cols - 1 && y > 0 && y < rows - 1) {
map.setTile(x, y, 0);
}
if (x < endX) x++;
else if (x > endX) x--;
if (y < endY) y++;
else if (y > endY) y--;
}
if (x > 0 && x < cols - 1 && y > 0 && y < rows - 1) {
map.setTile(x, y, 0);
}
}
for (let r = 1; r < rows - 1; r++) {
for (let c = 1; c < cols - 1; c++) {
if (map.getTile(c, r) === 0 && Math.random() < 0.03) {
map.setTile(c, r, 1);
}
}
}
return map;
}
}

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

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

35
src/core/Palette.ts Normal file
View file

@ -0,0 +1,35 @@
/**
* Limited color palette used throughout the game to ensure a consistent aesthetic.
*/
export const Palette = {
/** Highlights and UI Text */
WHITE: '#ffffff',
/** Energy and Slime core */
CYAN: '#0ce6f2',
/** Water and friendly elements */
SKY_BLUE: '#0098db',
/** Shadows and visual depth */
ROYAL_BLUE: '#1e579c',
/** Walls and obstacles */
DARK_BLUE: '#203562',
/** Background details */
DARKER_BLUE: '#252446',
/** Deep background and void */
VOID: '#201533',
/**
* Get all colors in the palette as an array.
* @returns Array of hex color strings
*/
getAll(): string[] {
return [
this.WHITE,
this.CYAN,
this.SKY_BLUE,
this.ROYAL_BLUE,
this.DARK_BLUE,
this.DARKER_BLUE,
this.VOID,
];
},
};

107
src/core/PixelFont.ts Normal file
View file

@ -0,0 +1,107 @@
/**
* Simple 5x7 Matrix Pixel Font data.
* Each character is represented by an array of 7 integers, where each integer is a 5-bit mask.
* Using Map for better minification/mangling support.
*/
const FONT_DATA = new Map<string, readonly number[]>([
['A', [0x0e, 0x11, 0x11, 0x1f, 0x11, 0x11, 0x11]],
['B', [0x1e, 0x11, 0x11, 0x1e, 0x11, 0x11, 0x1e]],
['C', [0x0e, 0x11, 0x11, 0x10, 0x11, 0x11, 0x0e]],
['D', [0x1e, 0x11, 0x11, 0x11, 0x11, 0x11, 0x1e]],
['E', [0x1f, 0x10, 0x10, 0x1e, 0x10, 0x10, 0x1f]],
['F', [0x1f, 0x10, 0x10, 0x1e, 0x10, 0x10, 0x10]],
['G', [0x0f, 0x10, 0x10, 0x17, 0x11, 0x11, 0x0f]],
['H', [0x11, 0x11, 0x11, 0x1f, 0x11, 0x11, 0x11]],
['I', [0x0e, 0x04, 0x04, 0x04, 0x04, 0x04, 0x0e]],
['J', [0x07, 0x02, 0x02, 0x02, 0x02, 0x12, 0x0c]],
['K', [0x11, 0x12, 0x14, 0x18, 0x14, 0x12, 0x11]],
['L', [0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x1f]],
['M', [0x11, 0x1b, 0x15, 0x15, 0x11, 0x11, 0x11]],
['N', [0x11, 0x11, 0x19, 0x15, 0x13, 0x11, 0x11]],
['O', [0x0e, 0x11, 0x11, 0x11, 0x11, 0x11, 0x0e]],
['P', [0x1e, 0x11, 0x11, 0x1e, 0x10, 0x10, 0x10]],
['Q', [0x0e, 0x11, 0x11, 0x11, 0x15, 0x12, 0x0d]],
['R', [0x1e, 0x11, 0x11, 0x1e, 0x14, 0x12, 0x11]],
['S', [0x0e, 0x11, 0x10, 0x0e, 0x01, 0x11, 0x0e]],
['T', [0x1f, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04]],
['U', [0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x0e]],
['V', [0x11, 0x11, 0x11, 0x11, 0x11, 0x0a, 0x04]],
['W', [0x11, 0x11, 0x11, 0x15, 0x15, 0x1b, 0x11]],
['X', [0x11, 0x11, 0x0a, 0x04, 0x0a, 0x11, 0x11]],
['Y', [0x11, 0x11, 0x0a, 0x04, 0x04, 0x04, 0x04]],
['Z', [0x1f, 0x01, 0x02, 0x04, 0x08, 0x10, 0x1f]],
['0', [0x0e, 0x11, 0x13, 0x15, 0x19, 0x11, 0x0e]],
['1', [0x04, 0x0c, 0x04, 0x04, 0x04, 0x04, 0x0e]],
['2', [0x0e, 0x11, 0x01, 0x02, 0x04, 0x08, 0x1f]],
['3', [0x1f, 0x02, 0x04, 0x02, 0x01, 0x11, 0x0e]],
['4', [0x02, 0x06, 0x0a, 0x12, 0x1f, 0x02, 0x02]],
['5', [0x1f, 0x10, 0x1e, 0x01, 0x01, 0x11, 0x0e]],
['6', [0x06, 0x08, 0x10, 0x1e, 0x11, 0x11, 0x0e]],
['7', [0x1f, 0x01, 0x02, 0x04, 0x08, 0x08, 0x08]],
['8', [0x0e, 0x11, 0x11, 0x0e, 0x11, 0x11, 0x0e]],
['9', [0x0e, 0x11, 0x11, 0x0f, 0x01, 0x02, 0x0c]],
[':', [0x00, 0x04, 0x00, 0x00, 0x00, 0x04, 0x00]],
['.', [0x00, 0x00, 0x00, 0x00, 0x00, 0x04, 0x00]],
[',', [0x00, 0x00, 0x00, 0x00, 0x00, 0x04, 0x08]],
['!', [0x04, 0x04, 0x04, 0x04, 0x04, 0x00, 0x04]],
['?', [0x0e, 0x11, 0x01, 0x02, 0x04, 0x00, 0x04]],
['+', [0x00, 0x04, 0x04, 0x1f, 0x04, 0x04, 0x00]],
['-', [0x00, 0x00, 0x00, 0x1f, 0x00, 0x00, 0x00]],
['/', [0x01, 0x02, 0x04, 0x08, 0x10, 0x10, 0x10]],
['(', [0x02, 0x04, 0x04, 0x04, 0x04, 0x04, 0x02]],
[')', [0x08, 0x04, 0x04, 0x04, 0x04, 0x04, 0x08]],
[' ', [0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]],
['|', [0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04]],
]);
/**
* Utility class for rendering text using a custom pixel font.
*/
export const PixelFont = {
/**
* Render a string of text to the canvas.
* @param ctx - The rendering context
* @param text - The text to draw
* @param x - Horizontal start position
* @param y - Vertical start position
* @param color - The color of the text
* @param scale - Pixel scale factor
*/
drawText(
ctx: CanvasRenderingContext2D,
text: string,
x: number,
y: number,
color = '#ffffff',
scale = 1
): void {
ctx.save();
ctx.fillStyle = color;
let cursorX = x;
const chars = text.toUpperCase().split('');
chars.forEach((char) => {
const glyph = FONT_DATA.get(char) || FONT_DATA.get('?');
if (!glyph) return;
for (let row = 0; row < 7; row++) {
for (let col = 0; col < 5; col++) {
if ((glyph[row] >> (4 - col)) & 1) {
ctx.fillRect(cursorX + col * scale, y + row * scale, scale, scale);
}
}
}
cursorX += 6 * scale;
});
ctx.restore();
},
/**
* Calculate the total width of a string of text when rendered.
* @param text - The text string
* @param scale - Pixel scale factor
* @returns Width in pixels
*/
getTextWidth(text: string, scale = 1): number {
return text.length * 6 * scale;
},
};

182
src/core/SpriteLibrary.ts Normal file
View file

@ -0,0 +1,182 @@
import { EntityType, AnimationState } from './Constants.ts';
/**
* A 2D grid of pixel values (0-3)
*/
export type SpriteFrame = number[][];
/**
* An array of frames forming an animation
*/
export type SpriteAnimation = SpriteFrame[];
/**
* A map of animation states to animations
*/
export type EntitySpriteData = Record<string, SpriteAnimation>;
/**
* Sprite Library defining pixel art grids as 2D arrays.
*
* Pixel Values:
* 0: Transparent
* 1: Primary Color (Entity Color)
* 2: Highlight (White / Shine)
* 3: Detail/Shade (Darker Blue / Eyes)
*/
export const SpriteLibrary: Record<string, EntitySpriteData> = {
[EntityType.SLIME]: {
[AnimationState.IDLE]: [
[
[0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 1, 1, 1, 1, 0, 0],
[0, 1, 1, 1, 1, 1, 1, 0],
[1, 1, 2, 1, 1, 2, 1, 1],
[1, 1, 3, 1, 1, 3, 1, 1],
[1, 1, 1, 1, 1, 1, 1, 1],
[1, 1, 1, 1, 1, 1, 1, 1],
[0, 1, 1, 1, 1, 1, 1, 0],
],
[
[0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 1, 1, 1, 1, 0, 0],
[0, 1, 1, 1, 1, 1, 1, 0],
[1, 1, 2, 1, 1, 2, 1, 1],
[1, 1, 3, 1, 1, 3, 1, 1],
[1, 1, 1, 1, 1, 1, 1, 1],
[1, 1, 1, 1, 1, 1, 1, 1],
],
],
[AnimationState.WALK]: [
[
[0, 0, 1, 1, 1, 1, 0, 0],
[0, 1, 1, 1, 1, 1, 1, 0],
[1, 1, 2, 1, 1, 2, 1, 1],
[1, 1, 3, 1, 1, 3, 1, 1],
[1, 1, 1, 1, 1, 1, 1, 1],
[1, 1, 1, 1, 1, 1, 1, 1],
[1, 3, 1, 1, 1, 1, 3, 1],
[0, 1, 1, 1, 1, 1, 1, 0],
],
[
[0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 1, 1, 1, 1, 0, 0],
[0, 1, 1, 1, 1, 1, 1, 0],
[1, 1, 2, 1, 1, 2, 1, 1],
[1, 1, 3, 1, 1, 3, 1, 1],
[1, 1, 1, 1, 1, 1, 1, 1],
[1, 1, 1, 1, 1, 1, 1, 1],
[1, 1, 1, 1, 1, 1, 1, 1],
],
],
},
[EntityType.HUMANOID]: {
[AnimationState.IDLE]: [
[
[0, 0, 0, 1, 1, 0, 0, 0],
[0, 0, 2, 1, 1, 2, 0, 0],
[0, 0, 0, 1, 1, 0, 0, 0],
[0, 1, 1, 1, 1, 1, 1, 0],
[1, 0, 2, 1, 1, 2, 0, 1],
[1, 0, 1, 1, 1, 1, 0, 1],
[0, 0, 1, 0, 0, 1, 0, 0],
[0, 0, 1, 0, 0, 1, 0, 0],
],
],
[AnimationState.WALK]: [
[
[0, 0, 0, 1, 1, 0, 0, 0],
[0, 0, 2, 1, 1, 2, 0, 0],
[0, 0, 0, 1, 1, 0, 0, 0],
[0, 1, 1, 1, 1, 1, 1, 0],
[0, 0, 2, 1, 1, 2, 0, 1],
[0, 0, 1, 1, 1, 1, 0, 1],
[0, 0, 1, 0, 0, 0, 0, 0],
[0, 1, 1, 0, 0, 0, 0, 0],
],
[
[0, 0, 0, 1, 1, 0, 0, 0],
[0, 0, 2, 1, 1, 2, 0, 0],
[0, 0, 0, 1, 1, 0, 0, 0],
[0, 1, 1, 1, 1, 1, 1, 0],
[1, 0, 2, 1, 1, 2, 0, 0],
[1, 0, 1, 1, 1, 1, 0, 0],
[0, 0, 0, 0, 0, 1, 0, 0],
[0, 0, 0, 0, 0, 1, 1, 0],
],
],
},
[EntityType.BEAST]: {
[AnimationState.IDLE]: [
[
[0, 0, 0, 0, 0, 0, 0, 0],
[1, 0, 0, 0, 0, 0, 0, 1],
[0, 1, 1, 1, 1, 1, 1, 0],
[1, 3, 1, 1, 1, 1, 3, 1],
[1, 1, 1, 1, 1, 1, 1, 1],
[0, 1, 1, 2, 2, 1, 1, 0],
[0, 1, 0, 0, 0, 0, 1, 0],
[0, 1, 0, 0, 0, 0, 1, 0],
],
],
[AnimationState.WALK]: [
[
[1, 0, 0, 0, 0, 0, 0, 1],
[0, 1, 1, 1, 1, 1, 1, 0],
[1, 3, 1, 1, 1, 1, 3, 1],
[1, 1, 1, 1, 1, 1, 1, 1],
[0, 1, 1, 2, 2, 1, 1, 0],
[0, 1, 0, 0, 0, 0, 1, 0],
[1, 1, 0, 0, 0, 0, 1, 1],
[0, 0, 0, 0, 0, 0, 0, 0],
],
[
[0, 0, 0, 0, 0, 0, 0, 0],
[1, 0, 0, 0, 0, 0, 0, 1],
[0, 1, 1, 1, 1, 1, 1, 0],
[1, 3, 1, 1, 1, 1, 3, 1],
[1, 1, 1, 1, 1, 1, 1, 1],
[0, 1, 1, 2, 2, 1, 1, 0],
[0, 0, 1, 0, 0, 1, 0, 0],
[0, 0, 1, 0, 0, 1, 0, 0],
],
],
},
[EntityType.ELEMENTAL]: {
[AnimationState.IDLE]: [
[
[0, 0, 2, 1, 1, 2, 0, 0],
[0, 1, 1, 2, 2, 1, 1, 0],
[1, 2, 1, 1, 1, 1, 2, 1],
[1, 1, 3, 3, 3, 3, 1, 1],
[1, 1, 1, 3, 3, 1, 1, 1],
[0, 1, 1, 1, 1, 1, 1, 0],
[0, 0, 1, 1, 1, 1, 0, 0],
[0, 0, 0, 1, 1, 0, 0, 0],
],
[
[0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 2, 1, 1, 2, 0, 0],
[0, 1, 1, 2, 2, 1, 1, 0],
[1, 2, 3, 3, 3, 3, 2, 1],
[1, 1, 1, 3, 3, 1, 1, 1],
[0, 1, 1, 1, 1, 1, 1, 0],
[0, 0, 1, 1, 1, 1, 0, 0],
[0, 0, 0, 1, 1, 0, 0, 0],
],
],
},
[EntityType.PROJECTILE]: {
[AnimationState.IDLE]: [
[
[1, 1],
[1, 1],
],
],
},
};

View file

@ -1,45 +0,0 @@
/**
* Base System class for ECS architecture
* Systems contain logic that operates on entities with specific components
*/
export class System {
constructor(name) {
this.name = name;
this.requiredComponents = [];
this.priority = 0; // Lower priority runs first
}
/**
* Check if an entity matches this system's requirements
*/
matches(entity) {
if (!entity.active) return false;
return this.requiredComponents.every(componentType =>
entity.hasComponent(componentType)
);
}
/**
* Update method - override in subclasses
*/
update(deltaTime, entities) {
// Filter entities that match this system's requirements
const matchingEntities = entities.filter(entity => this.matches(entity));
this.process(deltaTime, matchingEntities);
}
/**
* Process matching entities - override in subclasses
*/
process(_deltaTime, _entities) {
// Override in subclasses
}
/**
* Called when system is added to engine
*/
init(engine) {
this.engine = engine;
}
}

64
src/core/System.ts Normal file
View file

@ -0,0 +1,64 @@
import { Entity } from './Entity.ts';
import type { Engine } from './Engine.ts';
/**
* Base System class for ECS architecture.
* Systems contain logic that operates on entities with specific components.
*/
export class System {
/** Unique identifier for the system */
readonly name: string;
/** List of component types required by this system */
requiredComponents: string[];
/** Execution priority (lower runs first) */
priority: number;
/** Reference to the game engine */
protected engine!: Engine;
/**
* @param name - The unique name of the system
*/
constructor(name: string) {
this.name = name;
this.requiredComponents = [];
this.priority = 0;
}
/**
* Check if an entity matches this system's requirements.
* @param entity - The entity to check
* @returns True if the entity is active and has all required components
*/
matches(entity: Entity): boolean {
if (!entity.active) return false;
return this.requiredComponents.every((componentType) => entity.hasComponent(componentType));
}
/**
* Main update entry point called every frame.
* @param deltaTime - Time elapsed since last frame in seconds
* @param entities - All entities in the engine
*/
update(deltaTime: number, entities: Entity[]): void {
const matchingEntities = entities.filter((entity) => this.matches(entity));
this.process(deltaTime, matchingEntities);
}
/**
* Process matching entities. To be implemented by subclasses.
* @param _deltaTime - Time elapsed since last frame in seconds
* @param _entities - Filtered entities that match this system
*/
process(_deltaTime: number, _entities: Entity[]): void {}
/**
* Called when system is added to engine.
* @param engine - The game engine instance
*/
init(engine: Engine): void {
this.engine = engine;
}
}

68
src/core/TileMap.ts Normal file
View file

@ -0,0 +1,68 @@
/**
* Class representing a grid-based tile map for the game level.
*/
export class TileMap {
cols: number;
rows: number;
tileSize: number;
tiles: number[];
/**
* @param cols - Number of columns
* @param rows - Number of rows
* @param tileSize - Size of each tile in pixels
*/
constructor(cols: number, rows: number, tileSize: number) {
this.cols = cols;
this.rows = rows;
this.tileSize = tileSize;
this.tiles = new Array(cols * rows).fill(0);
}
/**
* Set the value of a specific tile.
* @param col - Tile column
* @param row - Tile row
* @param value - Tile type value
*/
setTile(col: number, row: number, value: number): void {
if (this.isValid(col, row)) {
this.tiles[row * this.cols + col] = value;
}
}
/**
* Get the type value of a tile. Treats out-of-bounds as solid.
* @param col - Tile column
* @param row - Tile row
* @returns The tile type value
*/
getTile(col: number, row: number): number {
if (this.isValid(col, row)) {
return this.tiles[row * this.cols + col];
}
return 1;
}
/**
* Check if a tile coordinate is within the map boundaries.
* @param col - Tile column
* @param row - Tile row
* @returns True if coordinates are valid
*/
isValid(col: number, row: number): boolean {
return col >= 0 && col < this.cols && row >= 0 && row < this.rows;
}
/**
* Check if a world position (x, y) collides with a solid tile.
* @param x - World X coordinate
* @param y - World Y coordinate
* @returns True if the position is solid
*/
isSolid(x: number, y: number): boolean {
const col = Math.floor(x / this.tileSize);
const row = Math.floor(y / this.tileSize);
return this.getTile(col, row) !== 0;
}
}

View file

@ -1,13 +0,0 @@
/**
* Base Item class
*/
export class Item {
constructor(id, name, type = 'consumable') {
this.id = id;
this.name = name;
this.type = type; // 'consumable', 'weapon', 'armor', 'accessory'
this.description = '';
this.value = 0;
}
}

46
src/items/Item.ts Normal file
View file

@ -0,0 +1,46 @@
/**
* Base class for all obtainable items in the game.
*/
export class Item {
id: string;
name: string;
type: string;
description: string;
value: number;
/**
* @param id - Unique identifier for the item
* @param name - Display name of the item
* @param type - Item category (e.g., 'consumable', 'weapon', 'armor', 'accessory')
*/
constructor(id: string, name: string, type = 'consumable') {
this.id = id;
this.name = name;
this.type = type;
this.description = '';
this.value = 0;
}
}
/**
* Weapon item with damage and attack speed properties
*/
export interface WeaponItem extends Item {
damage: number;
attackSpeed: number;
}
/**
* Armor item with defense and HP properties
*/
export interface ArmorItem extends Item {
defense: number;
maxHp: number;
}
/**
* Consumable item with heal amount property
*/
export interface ConsumableItem extends Item {
healAmount: number;
}

View file

@ -1,53 +0,0 @@
import { Item } from './Item.js';
/**
* Registry for all items in the game
*/
export class ItemRegistry {
static items = new Map();
static {
// Register items
// Weapons
this.register(this.createWeapon('iron_sword', 'Iron Sword', 15, 5));
this.register(this.createWeapon('steel_claw', 'Steel Claw', 20, 3));
// Armor
this.register(this.createArmor('leather_armor', 'Leather Armor', 10, 5));
// Consumables
this.register(this.createConsumable('health_potion', 'Health Potion', 50));
}
static register(item) {
this.items.set(item.id, item);
}
static get(id) {
return this.items.get(id);
}
static createWeapon(id, name, damage, speed) {
const item = new Item(id, name, 'weapon');
item.damage = damage;
item.attackSpeed = speed;
item.description = `Weapon: +${damage} damage, ${speed} speed`;
return item;
}
static createArmor(id, name, defense, hp) {
const item = new Item(id, name, 'armor');
item.defense = defense;
item.maxHp = hp;
item.description = `Armor: +${defense} defense, +${hp} HP`;
return item;
}
static createConsumable(id, name, healAmount) {
const item = new Item(id, name, 'consumable');
item.healAmount = healAmount;
item.description = `Restores ${healAmount} HP`;
return item;
}
}

78
src/items/ItemRegistry.ts Normal file
View file

@ -0,0 +1,78 @@
import { Item, WeaponItem, ArmorItem, ConsumableItem } from './Item.ts';
/**
* Static registry responsible for defining and storing all available items in the game.
*/
export class ItemRegistry {
static items = new Map<string, Item>();
static {
this.register(this.createWeapon('iron_sword', 'Iron Sword', 15, 5));
this.register(this.createWeapon('steel_claw', 'Steel Claw', 20, 3));
this.register(this.createArmor('leather_armor', 'Leather Armor', 10, 5));
this.register(this.createConsumable('health_potion', 'Health Potion', 50));
}
/**
* Register an item in the registry.
* @param item - The item instance to register
*/
static register(item: Item): void {
this.items.set(item.id, item);
}
/**
* Get an item template by its ID.
* @param id - The item identifier
* @returns The item template or undefined if not found
*/
static get(id: string): Item | undefined {
return this.items.get(id);
}
/**
* Create a weapon item template.
* @param id - Item identifier
* @param name - Display name
* @param damage - Attack damage bonus
* @param speed - Attack speed bonus
* @returns The generated weapon item
*/
static createWeapon(id: string, name: string, damage: number, speed: number): WeaponItem {
const item = new Item(id, name, 'weapon') as WeaponItem;
item.damage = damage;
item.attackSpeed = speed;
item.description = `Weapon: +${damage} damage, ${speed} speed`;
return item;
}
/**
* Create an armor item template.
* @param id - Item identifier
* @param name - Display name
* @param defense - Defense bonus
* @param hp - Bonus HP
* @returns The generated armor item
*/
static createArmor(id: string, name: string, defense: number, hp: number): ArmorItem {
const item = new Item(id, name, 'armor') as ArmorItem;
item.defense = defense;
item.maxHp = hp;
item.description = `Armor: +${defense} defense, +${hp} HP`;
return item;
}
/**
* Create a consumable item template.
* @param id - Item identifier
* @param name - Display name
* @param healAmount - Healing amount
* @returns The generated consumable item
*/
static createConsumable(id: string, name: string, healAmount: number): ConsumableItem {
const item = new Item(id, name, 'consumable') as ConsumableItem;
item.healAmount = healAmount;
item.description = `Restores ${healAmount} HP`;
return item;
}
}

View file

@ -1,158 +0,0 @@
import { Engine } from './core/Engine.js';
import { InputSystem } from './systems/InputSystem.js';
import { MovementSystem } from './systems/MovementSystem.js';
import { PlayerControllerSystem } from './systems/PlayerControllerSystem.js';
import { CombatSystem } from './systems/CombatSystem.js';
import { AISystem } from './systems/AISystem.js';
import { AbsorptionSystem } from './systems/AbsorptionSystem.js';
import { SkillSystem } from './systems/SkillSystem.js';
import { StealthSystem } from './systems/StealthSystem.js';
import { ProjectileSystem } from './systems/ProjectileSystem.js';
import { SkillEffectSystem } from './systems/SkillEffectSystem.js';
import { HealthRegenerationSystem } from './systems/HealthRegenerationSystem.js';
import { DeathSystem } from './systems/DeathSystem.js';
import { MenuSystem } from './systems/MenuSystem.js';
import { RenderSystem } from './systems/RenderSystem.js';
import { UISystem } from './systems/UISystem.js';
// Components
import { Position } from './components/Position.js';
import { Velocity } from './components/Velocity.js';
import { Sprite } from './components/Sprite.js';
import { Health } from './components/Health.js';
import { Stats } from './components/Stats.js';
import { Evolution } from './components/Evolution.js';
import { Skills } from './components/Skills.js';
import { Inventory } from './components/Inventory.js';
import { Combat } from './components/Combat.js';
import { Stealth } from './components/Stealth.js';
import { AI } from './components/AI.js';
import { Absorbable } from './components/Absorbable.js';
import { SkillProgress } from './components/SkillProgress.js';
// Initialize game
const canvas = document.getElementById('game-canvas');
if (!canvas) {
console.error('Canvas element not found!');
} else {
const engine = new Engine(canvas);
// Add systems in order
engine.addSystem(new MenuSystem(engine));
engine.addSystem(new InputSystem());
engine.addSystem(new PlayerControllerSystem());
engine.addSystem(new StealthSystem());
engine.addSystem(new AISystem());
engine.addSystem(new MovementSystem());
engine.addSystem(new CombatSystem());
engine.addSystem(new ProjectileSystem());
engine.addSystem(new AbsorptionSystem());
engine.addSystem(new SkillSystem());
engine.addSystem(new SkillEffectSystem());
engine.addSystem(new HealthRegenerationSystem());
engine.addSystem(new DeathSystem());
engine.addSystem(new RenderSystem(engine));
engine.addSystem(new UISystem(engine));
// Create player entity
const player = engine.createEntity();
player.addComponent(new Position(512, 384));
player.addComponent(new Velocity(0, 0, 200));
player.addComponent(new Sprite('#00ff96', 40, 40, 'slime'));
player.addComponent(new Health(100));
player.addComponent(new Stats());
player.addComponent(new Evolution());
// Give player a starting skill so they can test it
const playerSkills = new Skills();
playerSkills.addSkill('slime_gun', false); // Add slime_gun as starting skill (basic slime ability)
player.addComponent(playerSkills);
player.addComponent(new Inventory());
player.addComponent(new Combat());
player.addComponent(new Stealth());
player.addComponent(new SkillProgress()); // Track skill learning progress
// Create creatures
function createCreature(engine, x, y, type) {
const creature = engine.createEntity();
creature.addComponent(new Position(x, y));
creature.addComponent(new Velocity(0, 0, 100));
let color, evolutionData, skills;
switch (type) {
case 'humanoid':
color = '#ff5555'; // Humanoid red
evolutionData = { human: 10, beast: 0, slime: -2 };
skills = ['fire_breath'];
break;
case 'beast':
color = '#ffaa00'; // Beast orange
evolutionData = { human: 0, beast: 10, slime: -2 };
skills = ['pounce'];
break;
case 'elemental':
color = '#00bfff';
evolutionData = { human: 3, beast: 3, slime: 8 };
skills = ['fire_breath'];
break;
default:
color = '#888888';
evolutionData = { human: 2, beast: 2, slime: 2 };
skills = [];
}
creature.addComponent(new Sprite(color, 25, 25, 'circle'));
creature.addComponent(new Health(50 + Math.random() * 30));
creature.addComponent(new Stats());
creature.addComponent(new Combat());
creature.addComponent(new AI('wander'));
const absorbable = new Absorbable();
absorbable.setEvolutionData(evolutionData.human, evolutionData.beast, evolutionData.slime);
skills.forEach(skill => absorbable.addSkill(skill, 0.3));
creature.addComponent(absorbable);
return creature;
}
// Spawn initial creatures
for (let i = 0; i < 8; i++) {
const x = 100 + Math.random() * 824;
const y = 100 + Math.random() * 568;
const types = ['humanoid', 'beast', 'elemental'];
const type = types[Math.floor(Math.random() * types.length)];
createCreature(engine, x, y, type);
}
// Spawn new creatures periodically
setInterval(() => {
const existingCreatures = engine.getEntities().filter(e =>
e.hasComponent('AI') && e !== player
);
if (existingCreatures.length < 10) {
const x = 100 + Math.random() * 824;
const y = 100 + Math.random() * 568;
const types = ['humanoid', 'beast', 'elemental'];
const type = types[Math.floor(Math.random() * types.length)];
createCreature(engine, x, y, type);
}
}, 5000);
// Focus canvas for keyboard input
canvas.focus();
// Start engine but MenuSystem will control when gameplay begins
engine.start();
// Make engine globally available for debugging
window.gameEngine = engine;
window.player = player;
// Re-focus canvas on click
canvas.addEventListener('click', () => {
canvas.focus();
});
}

236
src/main.ts Normal file
View file

@ -0,0 +1,236 @@
import { Engine } from './core/Engine.ts';
import { InputSystem } from './systems/InputSystem.ts';
import { MovementSystem } from './systems/MovementSystem.ts';
import { PlayerControllerSystem } from './systems/PlayerControllerSystem.ts';
import { CombatSystem } from './systems/CombatSystem.ts';
import { AISystem } from './systems/AISystem.ts';
import { AbsorptionSystem } from './systems/AbsorptionSystem.ts';
import { SkillSystem } from './systems/SkillSystem.ts';
import { StealthSystem } from './systems/StealthSystem.ts';
import { ProjectileSystem } from './systems/ProjectileSystem.ts';
import { SkillEffectSystem } from './systems/SkillEffectSystem.ts';
import { HealthRegenerationSystem } from './systems/HealthRegenerationSystem.ts';
import { DeathSystem } from './systems/DeathSystem.ts';
import { MenuSystem } from './systems/MenuSystem.ts';
import { RenderSystem } from './systems/RenderSystem.ts';
import { UISystem } from './systems/UISystem.ts';
import { VFXSystem } from './systems/VFXSystem.ts';
import { MusicSystem } from './systems/MusicSystem.ts';
import { SoundEffectsSystem } from './systems/SoundEffectsSystem.ts';
import { CameraSystem } from './systems/CameraSystem.ts';
import { Position } from './components/Position.ts';
import { Velocity } from './components/Velocity.ts';
import { Sprite } from './components/Sprite.ts';
import { Health } from './components/Health.ts';
import { Stats } from './components/Stats.ts';
import { Evolution } from './components/Evolution.ts';
import { Skills } from './components/Skills.ts';
import { Inventory } from './components/Inventory.ts';
import { Combat } from './components/Combat.ts';
import { Stealth } from './components/Stealth.ts';
import { AI } from './components/AI.ts';
import { Absorbable } from './components/Absorbable.ts';
import { SkillProgress } from './components/SkillProgress.ts';
import { Intent } from './components/Intent.ts';
import { Music } from './components/Music.ts';
import { SoundEffects } from './components/SoundEffects.ts';
import { Camera } from './components/Camera.ts';
import { EntityType, ComponentType } from './core/Constants.ts';
import type { Entity } from './core/Entity.ts';
import { setupMusic, setupMusicHandlers } from './config/MusicConfig.ts';
import { setupSFX } from './config/SFXConfig.ts';
const canvas = document.getElementById('game-canvas') as HTMLCanvasElement;
if (!canvas) {
console.error('Canvas element not found!');
} else {
const engine = new Engine(canvas);
engine.addSystem(new MenuSystem(engine));
engine.addSystem(new InputSystem());
engine.addSystem(new MusicSystem());
engine.addSystem(new SoundEffectsSystem());
engine.addSystem(new CameraSystem());
engine.addSystem(new PlayerControllerSystem());
engine.addSystem(new StealthSystem());
engine.addSystem(new AISystem());
engine.addSystem(new MovementSystem());
engine.addSystem(new CombatSystem());
engine.addSystem(new ProjectileSystem());
engine.addSystem(new AbsorptionSystem());
engine.addSystem(new SkillSystem());
engine.addSystem(new SkillEffectSystem());
engine.addSystem(new HealthRegenerationSystem());
engine.addSystem(new DeathSystem());
engine.addSystem(new VFXSystem());
engine.addSystem(new RenderSystem(engine));
engine.addSystem(new UISystem(engine));
const player = engine.createEntity();
const startX = engine.tileMap ? (engine.tileMap.cols * engine.tileMap.tileSize) / 2 : 160;
const startY = engine.tileMap ? (engine.tileMap.rows * engine.tileMap.tileSize) / 2 : 120;
player.addComponent(new Position(startX, startY));
player.addComponent(new Velocity(0, 0));
player.addComponent(new Sprite('#00ff96', 14, 14, EntityType.SLIME));
player.addComponent(new Health(100));
player.addComponent(new Stats());
player.addComponent(new Evolution());
const playerSkills = new Skills();
playerSkills.addSkill('slime_gun', false);
player.addComponent(playerSkills);
player.addComponent(new Inventory());
player.addComponent(new Combat());
player.addComponent(new Stealth());
player.addComponent(new SkillProgress());
player.addComponent(new Intent());
const cameraEntity = engine.createEntity();
const camera = new Camera(canvas.width, canvas.height, 0.15);
if (engine.tileMap) {
const mapWidth = engine.tileMap.cols * engine.tileMap.tileSize;
const mapHeight = engine.tileMap.rows * engine.tileMap.tileSize;
camera.setBounds(mapWidth, mapHeight);
camera.x = startX;
camera.y = startY;
}
cameraEntity.addComponent(camera);
function createCreature(engine: Engine, x: number, y: number, type: EntityType): Entity {
const creature = engine.createEntity();
creature.addComponent(new Position(x, y));
creature.addComponent(new Velocity(0, 0));
let color: string;
let evolutionData: { human: number; beast: number; slime: number };
let skills: string[];
switch (type) {
case EntityType.HUMANOID:
color = '#ff5555';
evolutionData = { human: 10, beast: 0, slime: -2 };
skills = ['fire_breath'];
break;
case EntityType.BEAST:
color = '#ffaa00';
evolutionData = { human: 0, beast: 10, slime: -2 };
skills = ['pounce'];
break;
case EntityType.ELEMENTAL:
color = '#00bfff';
evolutionData = { human: 3, beast: 3, slime: 8 };
skills = ['fire_breath'];
break;
default:
color = '#888888';
evolutionData = { human: 2, beast: 2, slime: 2 };
skills = [];
}
creature.addComponent(new Sprite(color, 10, 10, type));
creature.addComponent(new Health(15 + Math.random() * 10));
creature.addComponent(new Stats());
creature.addComponent(new Combat());
creature.addComponent(new AI('wander'));
creature.addComponent(new Intent());
const absorbable = new Absorbable();
absorbable.setEvolutionData(evolutionData.human, evolutionData.beast, evolutionData.slime);
skills.forEach((skillId) => absorbable.addSkill(skillId, 0.3));
creature.addComponent(absorbable);
return creature;
}
const mapWidth = engine.tileMap ? engine.tileMap.cols * engine.tileMap.tileSize : 320;
const mapHeight = engine.tileMap ? engine.tileMap.rows * engine.tileMap.tileSize : 240;
function spawnEnemyNearPlayer(): void {
const playerPos = player.getComponent<Position>(ComponentType.POSITION);
if (!playerPos) return;
const spawnRadius = 150;
const minDistance = 80;
const maxAttempts = 10;
for (let attempt = 0; attempt < maxAttempts; attempt++) {
const angle = Math.random() * Math.PI * 2;
const distance = minDistance + Math.random() * (spawnRadius - minDistance);
const x = playerPos.x + Math.cos(angle) * distance;
const y = playerPos.y + Math.sin(angle) * distance;
if (x >= 50 && x <= mapWidth - 50 && y >= 50 && y <= mapHeight - 50) {
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(() => {
const existingCreatures = engine
.getEntities()
.filter((e) => e.hasComponent(ComponentType.AI) && e !== player);
if (existingCreatures.length < numberOfEnemies) {
spawnEnemyNearPlayer();
}
}, 5000);
const musicSystem = engine.systems.find((s) => s.name === 'MusicSystem') as
| MusicSystem
| undefined;
if (musicSystem) {
const musicEntity = engine.createEntity();
const music = new Music();
const audioCtx = musicSystem.getAudioContext();
setupMusic(music, audioCtx);
musicEntity.addComponent(music);
setupMusicHandlers(music, musicSystem, canvas);
const sfxSystem = engine.systems.find((s) => s.name === 'SoundEffectsSystem') as
| SoundEffectsSystem
| undefined;
if (sfxSystem) {
const sfxEntity = engine.createEntity();
const sfx = new SoundEffects(audioCtx);
setupSFX(sfx, audioCtx);
sfxEntity.addComponent(sfx);
}
} else {
canvas.addEventListener('click', () => {
canvas.focus();
});
}
canvas.focus();
engine.start();
interface WindowWithGame {
gameEngine?: Engine;
player?: Entity;
music?: Music;
}
(window as WindowWithGame).gameEngine = engine;
(window as WindowWithGame).player = player;
if (musicSystem) {
const musicEntity = engine.getEntities().find((e) => e.hasComponent(ComponentType.MUSIC));
if (musicEntity) {
const music = musicEntity.getComponent<Music>(ComponentType.MUSIC);
if (music) {
(window as WindowWithGame).music = music;
}
}
}
}

View file

@ -1,32 +0,0 @@
/**
* Base Skill class
*/
export class Skill {
constructor(id, name, cooldown = 2.0) {
this.id = id;
this.name = name;
this.cooldown = cooldown;
this.description = '';
}
/**
* Activate the skill
* @param {Entity} caster - Entity using the skill
* @param {Engine} engine - Game engine
* @returns {boolean} - Whether skill was successfully activated
*/
activate(_caster, _engine) {
// Override in subclasses
return false;
}
/**
* Check if skill can be used
*/
canUse(caster, _engine) {
const skills = caster.getComponent('Skills');
if (!skills) return false;
return !skills.isOnCooldown(this.id);
}
}

48
src/skills/Skill.ts Normal file
View file

@ -0,0 +1,48 @@
import type { Entity } from '../core/Entity.ts';
import type { Engine } from '../core/Engine.ts';
import { ComponentType } from '../core/Constants.ts';
import type { Skills } from '../components/Skills.ts';
/**
* Base class for all skills in the game.
*/
export class Skill {
id: string;
name: string;
cooldown: number;
description: string;
/**
* @param id - Unique identifier for the skill
* @param name - Display name of the skill
* @param cooldown - Cooldown duration in seconds
*/
constructor(id: string, name: string, cooldown = 2.0) {
this.id = id;
this.name = name;
this.cooldown = cooldown;
this.description = '';
}
/**
* Activate the skill's effects.
* @param _caster - Entity using the skill
* @param _engine - Game engine
* @returns Whether skill was successfully activated
*/
activate(_caster: Entity, _engine: Engine): boolean {
return false;
}
/**
* Check if the skill can be used by the caster.
* @param caster - The caster entity
* @param _engine - Game engine
* @returns True if the skill is not on cooldown
*/
canUse(caster: Entity, _engine: Engine): boolean {
const skills = caster.getComponent<Skills>(ComponentType.SKILLS);
if (!skills) return false;
return !skills.isOnCooldown(this.id);
}
}

View file

@ -1,36 +0,0 @@
import { SlimeGun } from './skills/WaterGun.js'; // File still named WaterGun.js but class is SlimeGun
import { FireBreath } from './skills/FireBreath.js';
import { Pounce } from './skills/Pounce.js';
import { StealthMode } from './skills/StealthMode.js';
/**
* Registry for all skills in the game
*/
export class SkillRegistry {
static skills = new Map();
static {
// Register all skills
this.register(new SlimeGun());
this.register(new FireBreath());
this.register(new Pounce());
this.register(new StealthMode());
}
static register(skill) {
this.skills.set(skill.id, skill);
}
static get(id) {
return this.skills.get(id);
}
static getAll() {
return Array.from(this.skills.values());
}
static has(id) {
return this.skills.has(id);
}
}

View file

@ -0,0 +1,53 @@
import { SlimeGun } from './skills/WaterGun.ts';
import { FireBreath } from './skills/FireBreath.ts';
import { Pounce } from './skills/Pounce.ts';
import { StealthMode } from './skills/StealthMode.ts';
import type { Skill } from './Skill.ts';
/**
* Static registry responsible for storing and retrieving all available skills.
*/
export class SkillRegistry {
static skills = new Map<string, Skill>();
static {
this.register(new SlimeGun());
this.register(new FireBreath());
this.register(new Pounce());
this.register(new StealthMode());
}
/**
* Register a skill instance in the registry.
* @param skill - The skill instance to register
*/
static register(skill: Skill): void {
this.skills.set(skill.id, skill);
}
/**
* Retrieve a skill instance by its unique ID.
* @param id - The skill identifier
* @returns The skill instance or undefined if not found
*/
static get(id: string): Skill | undefined {
return this.skills.get(id);
}
/**
* Get an array of all registered skills.
* @returns Array of all skills
*/
static getAll(): Skill[] {
return Array.from(this.skills.values());
}
/**
* Check if a skill ID is registered.
* @param id - The skill identifier
* @returns True if the skill exists in the registry
*/
static has(id: string): boolean {
return this.skills.has(id);
}
}

View file

@ -1,83 +0,0 @@
import { Skill } from '../Skill.js';
export class FireBreath extends Skill {
constructor() {
super('fire_breath', 'Fire Breath', 3.0);
this.description = 'Breathe fire in a cone';
this.damage = 25;
this.range = 150;
this.coneAngle = Math.PI / 3; // 60 degrees
this.duration = 0.5; // Animation duration
}
activate(caster, engine) {
if (!this.canUse(caster, engine)) return false;
const position = caster.getComponent('Position');
const stats = caster.getComponent('Stats');
const skills = caster.getComponent('Skills');
const inputSystem = engine.systems.find(s => s.name === 'InputSystem');
if (!position || !skills) return false;
// Calculate direction from player to mouse
let fireAngle = position.rotation;
if (inputSystem) {
const mouse = inputSystem.getMousePosition();
const dx = mouse.x - position.x;
const dy = mouse.y - position.y;
if (Math.abs(dx) > 0.1 || Math.abs(dy) > 0.1) {
fireAngle = Math.atan2(dy, dx);
position.rotation = fireAngle;
}
}
skills.setCooldown(this.id, this.cooldown);
// Add visual effect
const skillEffectSystem = engine.systems.find(s => s.name === 'SkillEffectSystem');
if (skillEffectSystem) {
skillEffectSystem.addEffect({
type: 'fire_breath',
x: position.x,
y: position.y,
angle: fireAngle,
range: this.range,
coneAngle: this.coneAngle,
lifetime: this.duration,
time: 0
});
}
// Damage all enemies in cone
const entities = engine.getEntities();
const damage = this.damage + (stats ? stats.intelligence * 0.5 : 0);
entities.forEach(entity => {
if (entity.id === caster.id) return;
if (!entity.hasComponent('Health')) return;
const targetPos = entity.getComponent('Position');
if (!targetPos) return;
const dx = targetPos.x - position.x;
const dy = targetPos.y - position.y;
const distance = Math.sqrt(dx * dx + dy * dy);
if (distance <= this.range) {
const angle = Math.atan2(dy, dx);
const angleDiff = Math.abs(angle - fireAngle);
const normalizedDiff = Math.abs(((angleDiff % (Math.PI * 2)) + Math.PI * 2) % (Math.PI * 2));
const minDiff = Math.min(normalizedDiff, Math.PI * 2 - normalizedDiff);
if (minDiff < this.coneAngle / 2) {
const health = entity.getComponent('Health');
health.takeDamage(damage);
}
}
});
return true;
}
}

View file

@ -0,0 +1,110 @@
import { Skill } from '../Skill.ts';
import { ComponentType, SystemName } from '../../core/Constants.ts';
import type { Entity } from '../../core/Entity.ts';
import type { Engine } from '../../core/Engine.ts';
import type { Position } from '../../components/Position.ts';
import type { Stats } from '../../components/Stats.ts';
import type { Skills } from '../../components/Skills.ts';
import type { Health } from '../../components/Health.ts';
import type { InputSystem } from '../../systems/InputSystem.ts';
import type { SkillEffectSystem } from '../../systems/SkillEffectSystem.ts';
/**
* Skill that deals damage in a cone-shaped area in front of the caster.
*/
export class FireBreath extends Skill {
damage: number;
range: number;
coneAngle: number;
duration: number;
constructor() {
super('fire_breath', 'Fire Breath', 3.0);
this.description = 'Breathe fire in a cone';
this.damage = 25;
this.range = 80;
this.coneAngle = Math.PI / 3;
this.duration = 0.5;
}
/**
* Activate the fire breath effect, damaging enemies within the cone.
* @param caster - The caster entity
* @param engine - The game engine
* @returns True if the skill was activated
*/
activate(caster: Entity, engine: Engine): boolean {
if (!this.canUse(caster, engine)) return false;
const position = caster.getComponent<Position>(ComponentType.POSITION);
const stats = caster.getComponent<Stats>(ComponentType.STATS);
const skills = caster.getComponent<Skills>(ComponentType.SKILLS);
const inputSystem = engine.systems.find((s) => s.name === SystemName.INPUT) as
| InputSystem
| undefined;
if (!position || !skills) return false;
let fireAngle = position.rotation;
if (inputSystem) {
const mouse = inputSystem.getMousePosition();
const dx = mouse.x - position.x;
const dy = mouse.y - position.y;
if (Math.abs(dx) > 0.1 || Math.abs(dy) > 0.1) {
fireAngle = Math.atan2(dy, dx);
position.rotation = fireAngle;
}
}
skills.setCooldown(this.id, this.cooldown);
const skillEffectSystem = engine.systems.find((s) => s.name === SystemName.SKILL_EFFECT) as
| SkillEffectSystem
| undefined;
if (skillEffectSystem) {
skillEffectSystem.addEffect({
type: 'fire_breath',
x: position.x,
y: position.y,
angle: fireAngle,
range: this.range,
coneAngle: this.coneAngle,
lifetime: this.duration,
time: 0,
});
}
const entities = engine.getEntities();
const damage = this.damage + (stats ? stats.intelligence * 0.5 : 0);
entities.forEach((entity) => {
if (entity.id === caster.id) return;
if (!entity.hasComponent(ComponentType.HEALTH)) return;
const targetPos = entity.getComponent<Position>(ComponentType.POSITION);
if (!targetPos) return;
const dx = targetPos.x - position.x;
const dy = targetPos.y - position.y;
const distance = Math.sqrt(dx * dx + dy * dy);
if (distance <= this.range) {
const angle = Math.atan2(dy, dx);
const angleDiff = Math.abs(angle - fireAngle);
const normalizedDiff = Math.abs(
((angleDiff % (Math.PI * 2)) + Math.PI * 2) % (Math.PI * 2)
);
const minDiff = Math.min(normalizedDiff, Math.PI * 2 - normalizedDiff);
if (minDiff < this.coneAngle / 2) {
const health = entity.getComponent<Health>(ComponentType.HEALTH);
if (health) {
health.takeDamage(damage);
}
}
}
});
return true;
}
}

View file

@ -1,97 +0,0 @@
import { Skill } from '../Skill.js';
export class Pounce extends Skill {
constructor() {
super('pounce', 'Pounce', 2.0);
this.description = 'Leap forward and damage enemies';
this.damage = 20;
this.range = 100;
this.dashSpeed = 400;
this.dashDuration = 0.2; // How long the dash lasts
}
activate(caster, engine) {
if (!this.canUse(caster, engine)) return false;
const position = caster.getComponent('Position');
const velocity = caster.getComponent('Velocity');
const stats = caster.getComponent('Stats');
const skills = caster.getComponent('Skills');
const inputSystem = engine.systems.find(s => s.name === 'InputSystem');
if (!position || !velocity || !skills) return false;
// Calculate direction from player to mouse
let dashAngle = position.rotation;
if (inputSystem) {
const mouse = inputSystem.getMousePosition();
const dx = mouse.x - position.x;
const dy = mouse.y - position.y;
if (Math.abs(dx) > 0.1 || Math.abs(dy) > 0.1) {
dashAngle = Math.atan2(dy, dx);
position.rotation = dashAngle;
}
}
skills.setCooldown(this.id, this.cooldown);
// Store start position for effect
const startX = position.x;
const startY = position.y;
// Dash forward
velocity.vx = Math.cos(dashAngle) * this.dashSpeed;
velocity.vy = Math.sin(dashAngle) * this.dashSpeed;
// Add visual effect
const skillEffectSystem = engine.systems.find(s => s.name === 'SkillEffectSystem');
if (skillEffectSystem) {
skillEffectSystem.addEffect({
type: 'pounce',
startX: startX,
startY: startY,
angle: dashAngle,
speed: this.dashSpeed,
lifetime: this.dashDuration,
time: 0
});
}
// Damage enemies at destination after dash
setTimeout(() => {
const entities = engine.getEntities();
const damage = this.damage + (stats ? stats.strength * 0.4 : 0);
entities.forEach(entity => {
if (entity.id === caster.id) return;
if (!entity.hasComponent('Health')) return;
const targetPos = entity.getComponent('Position');
if (!targetPos) return;
const dx = targetPos.x - position.x;
const dy = targetPos.y - position.y;
const distance = Math.sqrt(dx * dx + dy * dy);
if (distance <= this.range) {
const health = entity.getComponent('Health');
health.takeDamage(damage);
// Add impact effect
if (skillEffectSystem) {
skillEffectSystem.addEffect({
type: 'pounce_impact',
x: position.x,
y: position.y,
lifetime: 0.3,
time: 0
});
}
}
});
}, this.dashDuration * 1000);
return true;
}
}

161
src/skills/skills/Pounce.ts Normal file
View file

@ -0,0 +1,161 @@
import { Skill } from '../Skill.ts';
import { ComponentType, SystemName } from '../../core/Constants.ts';
import type { Entity } from '../../core/Entity.ts';
import type { Engine } from '../../core/Engine.ts';
import type { Position } from '../../components/Position.ts';
import type { Velocity } from '../../components/Velocity.ts';
import type { Sprite } from '../../components/Sprite.ts';
import type { Stats } from '../../components/Stats.ts';
import type { Skills } from '../../components/Skills.ts';
import type { Health } from '../../components/Health.ts';
import type { InputSystem } from '../../systems/InputSystem.ts';
import type { SkillEffectSystem, SkillEffect } from '../../systems/SkillEffectSystem.ts';
/**
* Skill that allows the caster to leap forward, dealing damage and knockback upon landing.
*/
export class Pounce extends Skill {
damage: number;
range: number;
dashSpeed: number;
dashDuration: number;
constructor() {
super('pounce', 'Pounce', 2.0);
this.description = 'Leap forward and damage enemies';
this.damage = 25;
this.range = 30;
this.dashSpeed = 300;
this.dashDuration = 0.2;
}
/**
* Activate the pounce leap, setting velocity and adding visual effects.
* @param caster - The caster entity
* @param engine - The game engine
* @returns True if the skill was activated
*/
activate(caster: Entity, engine: Engine): boolean {
if (!this.canUse(caster, engine)) return false;
const position = caster.getComponent<Position>(ComponentType.POSITION);
const velocity = caster.getComponent<Velocity>(ComponentType.VELOCITY);
const sprite = caster.getComponent<Sprite>(ComponentType.SPRITE);
const skills = caster.getComponent<Skills>(ComponentType.SKILLS);
const inputSystem = engine.systems.find((s) => s.name === SystemName.INPUT) as
| InputSystem
| undefined;
if (!position || !velocity || !skills) return false;
let dashAngle = position.rotation;
if (inputSystem) {
const mouse = inputSystem.getMousePosition();
const dx = mouse.x - position.x;
const dy = mouse.y - position.y;
if (Math.abs(dx) > 1 || Math.abs(dy) > 1) {
dashAngle = Math.atan2(dy, dx);
position.rotation = dashAngle;
}
}
skills.setCooldown(this.id, this.cooldown);
velocity.isLocked = true;
velocity.lockTimer = this.dashDuration;
velocity.vx = Math.cos(dashAngle) * this.dashSpeed;
velocity.vy = Math.sin(dashAngle) * this.dashSpeed;
const skillEffectSystem = engine.systems.find((s) => s.name === SystemName.SKILL_EFFECT) as
| SkillEffectSystem
| undefined;
if (skillEffectSystem) {
skillEffectSystem.addEffect({
type: 'pounce',
caster: caster,
startX: position.x,
startY: position.y,
angle: dashAngle,
lifetime: this.dashDuration,
time: 0,
onUpdate: (_deltaTime: number) => {
const effects = skillEffectSystem.getEffects();
const effect = effects.find(
(e: SkillEffect) => e.caster === caster && e.type === 'pounce'
);
if (!effect) return;
const progress = effect.time / effect.lifetime;
const jumpHeight = 15;
if (sprite) {
sprite.yOffset = -Math.sin(Math.PI * progress) * jumpHeight;
sprite.scale = 1.0 + Math.sin(Math.PI * progress) * 0.4;
}
},
onComplete: () => {
this.triggerImpact(caster, engine);
if (sprite) {
sprite.yOffset = 0;
sprite.scale = 1.0;
}
},
});
}
return true;
}
/**
* Execute the impact damage and knockback upon landing.
* @param caster - The caster entity
* @param engine - The game engine
*/
triggerImpact(caster: Entity, engine: Engine): void {
const position = caster.getComponent<Position>(ComponentType.POSITION);
const stats = caster.getComponent<Stats>(ComponentType.STATS);
if (!position) return;
const skillEffectSystem = engine.systems.find((s) => s.name === SystemName.SKILL_EFFECT) as
| SkillEffectSystem
| undefined;
if (skillEffectSystem) {
skillEffectSystem.addEffect({
type: 'pounce_impact',
x: position.x,
y: position.y,
lifetime: 0.3,
time: 0,
});
}
const damage = this.damage + (stats ? stats.strength * 0.5 : 0);
const entities = engine.getEntities();
entities.forEach((entity) => {
if (entity === caster) return;
if (!entity.hasComponent(ComponentType.HEALTH)) return;
const targetPos = entity.getComponent<Position>(ComponentType.POSITION);
if (!targetPos) return;
const dx = targetPos.x - position.x;
const dy = targetPos.y - position.y;
const distance = Math.sqrt(dx * dx + dy * dy);
if (distance <= this.range) {
const health = entity.getComponent<Health>(ComponentType.HEALTH);
if (health) {
health.takeDamage(damage);
}
const targetVel = entity.getComponent<Velocity>(ComponentType.VELOCITY);
if (targetVel) {
const angle = Math.atan2(dy, dx);
targetVel.vx += Math.cos(angle) * 150;
targetVel.vy += Math.sin(angle) * 150;
}
}
});
}
}

View file

@ -1,35 +0,0 @@
import { Skill } from '../Skill.js';
export class StealthMode extends Skill {
constructor() {
super('stealth_mode', 'Stealth Mode', 5.0);
this.description = 'Enter stealth mode';
this.duration = 10.0;
}
activate(caster, engine) {
if (!this.canUse(caster, engine)) return false;
const stealth = caster.getComponent('Stealth');
const skills = caster.getComponent('Skills');
const evolution = caster.getComponent('Evolution');
if (!stealth || !skills) return false;
skills.setCooldown(this.id, this.cooldown);
// Determine stealth type from evolution
const form = evolution ? evolution.getDominantForm() : 'slime';
stealth.enterStealth(form);
// Auto-exit after duration
setTimeout(() => {
if (stealth.isStealthed) {
stealth.exitStealth();
}
}, this.duration * 1000);
return true;
}
}

View file

@ -0,0 +1,49 @@
import { Skill } from '../Skill.ts';
import { ComponentType } from '../../core/Constants.ts';
import type { Entity } from '../../core/Entity.ts';
import type { Engine } from '../../core/Engine.ts';
import type { Stealth } from '../../components/Stealth.ts';
import type { Skills } from '../../components/Skills.ts';
import type { Evolution } from '../../components/Evolution.ts';
/**
* Skill that allows the caster to enter a stealthed state, reducing visibility.
*/
export class StealthMode extends Skill {
duration: number;
constructor() {
super('stealth_mode', 'Stealth Mode', 5.0);
this.description = 'Enter stealth mode';
this.duration = 10.0;
}
/**
* Activate stealth mode, applying form-based stealth to the caster.
* @param caster - The caster entity
* @param _engine - The game engine
* @returns True if the skill was activated
*/
activate(caster: Entity, _engine: Engine): boolean {
if (!this.canUse(caster, _engine)) return false;
const stealth = caster.getComponent<Stealth>(ComponentType.STEALTH);
const skills = caster.getComponent<Skills>(ComponentType.SKILLS);
const evolution = caster.getComponent<Evolution>(ComponentType.EVOLUTION);
if (!stealth || !skills) return false;
skills.setCooldown(this.id, this.cooldown);
const form = evolution ? evolution.getDominantForm() : 'slime';
stealth.enterStealth(form);
setTimeout(() => {
if (stealth.isStealthed) {
stealth.exitStealth();
}
}, this.duration * 1000);
return true;
}
}

View file

@ -1,84 +0,0 @@
import { Skill } from '../Skill.js';
import { Position } from '../../components/Position.js';
import { Velocity } from '../../components/Velocity.js';
import { Sprite } from '../../components/Sprite.js';
import { Health } from '../../components/Health.js';
export class SlimeGun extends Skill {
constructor() {
super('slime_gun', 'Slime Gun', 1.0);
this.description = 'Shoot a blob of slime at enemies (costs 1 HP)';
this.damage = 15;
this.range = 800; // Long range for a gun
this.speed = 600; // Faster projectile
this.hpCost = 1;
}
activate(caster, engine) {
if (!this.canUse(caster, engine)) return false;
const position = caster.getComponent('Position');
const health = caster.getComponent('Health');
const stats = caster.getComponent('Stats');
const skills = caster.getComponent('Skills');
const inputSystem = engine.systems.find(s => s.name === 'InputSystem');
if (!position || !skills || !health) return false;
// Check if we have enough HP
if (health.hp <= this.hpCost) {
return false; // Can't use if it would kill us
}
// Calculate direction from player to mouse
let shootAngle = position.rotation;
if (inputSystem) {
const mouse = inputSystem.getMousePosition();
const dx = mouse.x - position.x;
const dy = mouse.y - position.y;
if (Math.abs(dx) > 0.1 || Math.abs(dy) > 0.1) {
shootAngle = Math.atan2(dy, dx);
}
}
// Cost HP (sacrificing slime)
health.takeDamage(this.hpCost);
// Set cooldown
skills.setCooldown(this.id, this.cooldown);
// Create projectile (slime blob)
const projectile = engine.createEntity();
const startX = position.x;
const startY = position.y;
projectile.addComponent(new Position(startX, startY));
// Create velocity with high maxSpeed for projectiles
const projectileVelocity = new Velocity(
Math.cos(shootAngle) * this.speed,
Math.sin(shootAngle) * this.speed
);
projectileVelocity.maxSpeed = this.speed * 2; // Allow projectiles to move fast
projectile.addComponent(projectileVelocity);
// Slime-colored projectile
projectile.addComponent(new Sprite('#00ff96', 10, 10, 'slime'));
// Projectile has temporary health for collision detection
const projectileHealth = new Health(1);
projectileHealth.isProjectile = true;
projectile.addComponent(projectileHealth);
// Store projectile data
projectile.damage = this.damage + (stats ? stats.intelligence * 0.3 : 0);
projectile.owner = caster.id;
projectile.startX = startX;
projectile.startY = startY;
projectile.maxRange = this.range;
projectile.speed = this.speed;
// Lifetime as backup (should be longer than range travel time)
projectile.lifetime = (this.range / this.speed) + 1.0;
return true;
}
}

View file

@ -0,0 +1,101 @@
import { Skill } from '../Skill.ts';
import { ComponentType, SystemName, EntityType } from '../../core/Constants.ts';
import { Events } from '../../core/EventBus.ts';
import { Position } from '../../components/Position.ts';
import { Velocity } from '../../components/Velocity.ts';
import { Sprite } from '../../components/Sprite.ts';
import { Health } from '../../components/Health.ts';
import type { Entity } from '../../core/Entity.ts';
import type { Engine } from '../../core/Engine.ts';
import type { Stats } from '../../components/Stats.ts';
import type { Skills } from '../../components/Skills.ts';
import type { InputSystem } from '../../systems/InputSystem.ts';
/**
* Skill that fires a projectile, costing health but dealing ranged damage.
*/
export class SlimeGun extends Skill {
damage: number;
range: number;
speed: number;
hpCost: number;
constructor() {
super('slime_gun', 'Slime Gun', 1.0);
this.description = 'Shoot a blob of slime at enemies (costs 1 HP)';
this.damage = 15;
this.range = 250;
this.speed = 250;
this.hpCost = 1;
}
/**
* Activate the slime gun, sacrificing health to create a projectile.
* @param caster - The caster entity
* @param engine - The game engine
* @returns True if the projectile was successfully created
*/
activate(caster: Entity, engine: Engine): boolean {
if (!this.canUse(caster, engine)) return false;
const position = caster.getComponent<Position>(ComponentType.POSITION);
const health = caster.getComponent<Health>(ComponentType.HEALTH);
const stats = caster.getComponent<Stats>(ComponentType.STATS);
const skills = caster.getComponent<Skills>(ComponentType.SKILLS);
const inputSystem = engine.systems.find((s) => s.name === SystemName.INPUT) as
| InputSystem
| undefined;
if (!position || !skills || !health) return false;
if (health.hp <= this.hpCost) {
return false;
}
let shootAngle = position.rotation;
if (inputSystem) {
const mouse = inputSystem.getMousePosition();
const dx = mouse.x - position.x;
const dy = mouse.y - position.y;
if (Math.abs(dx) > 0.1 || Math.abs(dy) > 0.1) {
shootAngle = Math.atan2(dy, dx);
}
}
health.takeDamage(this.hpCost);
skills.setCooldown(this.id, this.cooldown);
const projectile = engine.createEntity();
const startX = position.x;
const startY = position.y;
projectile.addComponent(new Position(startX, startY));
const projectileVelocity = new Velocity(
Math.cos(shootAngle) * this.speed,
Math.sin(shootAngle) * this.speed
);
projectileVelocity.maxSpeed = this.speed * 2;
projectile.addComponent(projectileVelocity);
projectile.addComponent(new Sprite('#00ff96', 4, 4, EntityType.PROJECTILE));
const projectileHealth = new Health(1);
projectileHealth.isProjectile = true;
projectile.addComponent(projectileHealth);
projectile.damage = this.damage + (stats ? stats.intelligence * 0.3 : 0);
projectile.owner = caster.id;
projectile.startX = startX;
projectile.startY = startY;
projectile.maxRange = this.range;
projectile.lifetime = this.range / this.speed + 1.0;
engine.emit(Events.PROJECTILE_CREATED, {
x: startX,
y: startY,
angle: shootAngle,
});
return true;
}
}

View file

@ -1,205 +0,0 @@
import { System } from '../core/System.js';
import { GameConfig } from '../GameConfig.js';
export class AISystem extends System {
constructor() {
super('AISystem');
this.requiredComponents = ['Position', 'Velocity', 'AI'];
this.priority = 15;
}
process(deltaTime, entities) {
const playerController = this.engine.systems.find(s => s.name === 'PlayerControllerSystem');
const player = playerController ? playerController.getPlayerEntity() : null;
const playerPos = player?.getComponent('Position');
const config = GameConfig.AI;
entities.forEach(entity => {
const ai = entity.getComponent('AI');
const position = entity.getComponent('Position');
const velocity = entity.getComponent('Velocity');
const _stealth = entity.getComponent('Stealth');
if (!ai || !position || !velocity) return;
// Update wander timer
ai.wanderChangeTime += deltaTime;
// Detect player
if (playerPos) {
const dx = playerPos.x - position.x;
const dy = playerPos.y - position.y;
const distance = Math.sqrt(dx * dx + dy * dy);
// Update awareness based on distance and player stealth
const playerStealth = player?.getComponent('Stealth');
const playerVisibility = playerStealth ? playerStealth.visibility : 1.0;
if (distance < ai.alertRadius) {
const detectionChance = (1 - distance / ai.alertRadius) * playerVisibility;
ai.updateAwareness(detectionChance * deltaTime * config.awarenessGainMultiplier);
} else {
ai.updateAwareness(-deltaTime * config.awarenessLossRate); // Lose awareness over time
}
// Biological Reputation Logic
const playerEvolution = player?.getComponent('Evolution');
const playerForm = playerEvolution ? playerEvolution.getDominantForm() : 'slime';
const entityType = entity.getComponent('Sprite')?.color === '#ffaa00' ? 'beast' :
entity.getComponent('Sprite')?.color === '#ff5555' ? 'humanoid' : 'other';
// Check if player is "one of us" or "too scary"
let isPassive = false;
let shouldFlee = false;
if (entityType === 'humanoid' && playerForm === 'human') {
// Humanoids are passive to human-form slime unless awareness is maxed (hostile action taken)
if (ai.awareness < config.passiveAwarenessThreshold) isPassive = true;
} else if (entityType === 'beast' && playerForm === 'beast') {
// Beasts might flee from a dominant beast player
const playerStats = player?.getComponent('Stats');
const entityStats = entity.getComponent('Stats');
if (playerStats && entityStats && playerStats.level > entityStats.level) {
shouldFlee = true;
}
}
// Behavior based on awareness, reputation, and distance
if (shouldFlee && ai.awareness > config.fleeAwarenessThreshold) {
ai.setBehavior('flee');
ai.state = 'fleeing';
ai.setTarget(player.id);
} else if (isPassive) {
if (ai.behaviorType === 'chase' || ai.behaviorType === 'combat') {
ai.setBehavior('wander');
ai.state = 'idle';
ai.clearTarget();
}
} else if (ai.awareness > config.detectionAwarenessThreshold && distance < ai.chaseRadius) {
if (ai.behaviorType !== 'flee') {
// Check if in attack range - if so, use combat behavior
const combat = entity.getComponent('Combat');
if (combat && distance <= combat.attackRange) {
ai.setBehavior('combat');
ai.state = 'combat';
} else {
ai.setBehavior('chase');
ai.state = 'chasing';
}
ai.setTarget(player.id);
}
} else if (ai.awareness < 0.3) {
if (ai.behaviorType === 'chase' || ai.behaviorType === 'combat') {
ai.setBehavior('wander');
ai.state = 'idle';
ai.clearTarget();
}
} else if (ai.behaviorType === 'chase') {
// Update from chase to combat if in range
const combat = entity.getComponent('Combat');
if (combat && distance <= combat.attackRange) {
ai.setBehavior('combat');
ai.state = 'combat';
}
}
}
// Execute behavior
switch (ai.behaviorType) {
case 'wander':
this.wander(entity, ai, velocity, deltaTime);
break;
case 'chase':
this.chase(entity, ai, velocity, position, playerPos);
break;
case 'flee':
this.flee(entity, ai, velocity, position, playerPos);
break;
case 'combat':
this.combat(entity, ai, velocity, position, playerPos);
break;
}
});
}
wander(entity, ai, velocity, _deltaTime) {
ai.state = 'moving';
// Change direction periodically
if (ai.wanderChangeTime >= ai.wanderChangeInterval) {
ai.wanderDirection = Math.random() * Math.PI * 2;
ai.wanderChangeTime = 0;
ai.wanderChangeInterval = 1 + Math.random() * 2;
}
velocity.vx = Math.cos(ai.wanderDirection) * ai.wanderSpeed;
velocity.vy = Math.sin(ai.wanderDirection) * ai.wanderSpeed;
}
chase(entity, ai, velocity, position, targetPos) {
if (!targetPos) return;
const dx = targetPos.x - position.x;
const dy = targetPos.y - position.y;
const distance = Math.sqrt(dx * dx + dy * dy);
// Check if we should switch to combat
const combat = entity.getComponent('Combat');
if (combat && distance <= combat.attackRange) {
ai.setBehavior('combat');
ai.state = 'combat';
return;
}
ai.state = 'chasing';
if (distance > 0.1) {
const speed = ai.wanderSpeed * 1.5;
velocity.vx = (dx / distance) * speed;
velocity.vy = (dy / distance) * speed;
} else {
velocity.vx = 0;
velocity.vy = 0;
}
}
flee(entity, ai, velocity, position, targetPos) {
if (!targetPos) return;
ai.state = 'fleeing';
const dx = position.x - targetPos.x;
const dy = position.y - targetPos.y;
const distance = Math.sqrt(dx * dx + dy * dy);
if (distance > 0.1) {
const speed = ai.wanderSpeed * 1.2;
velocity.vx = (dx / distance) * speed;
velocity.vy = (dy / distance) * speed;
}
}
combat(entity, ai, velocity, position, targetPos) {
if (!targetPos) return;
ai.state = 'attacking';
// Stop moving when in combat range - let CombatSystem handle attacks
const dx = targetPos.x - position.x;
const dy = targetPos.y - position.y;
const distance = Math.sqrt(dx * dx + dy * dy);
const combat = entity.getComponent('Combat');
if (combat && distance > combat.attackRange) {
// Move closer if out of range
const speed = ai.wanderSpeed;
velocity.vx = (dx / distance) * speed;
velocity.vy = (dy / distance) * speed;
} else {
// Stop and face target
velocity.vx *= 0.5;
velocity.vy *= 0.5;
if (position) {
position.rotation = Math.atan2(dy, dx);
}
}
}
}

256
src/systems/AISystem.ts Normal file
View file

@ -0,0 +1,256 @@
import { System } from '../core/System.ts';
import { GameConfig } from '../GameConfig.ts';
import { SystemName, ComponentType } from '../core/Constants.ts';
import type { Entity } from '../core/Entity.ts';
import type { Health } from '../components/Health.ts';
import type { AI } from '../components/AI.ts';
import type { Position } from '../components/Position.ts';
import type { Velocity } from '../components/Velocity.ts';
import type { Stealth } from '../components/Stealth.ts';
import type { Evolution } from '../components/Evolution.ts';
import type { Sprite } from '../components/Sprite.ts';
import type { Stats } from '../components/Stats.ts';
import type { Combat } from '../components/Combat.ts';
import type { Intent } from '../components/Intent.ts';
import type { PlayerControllerSystem } from './PlayerControllerSystem.ts';
/**
* System responsible for managing AI behaviors (wandering, chasing, fleeing, combat).
*/
export class AISystem extends System {
constructor() {
super(SystemName.AI);
this.requiredComponents = [ComponentType.POSITION, ComponentType.VELOCITY, ComponentType.AI];
this.priority = 15;
}
/**
* Process AI logic for all entities with an AI component.
* @param deltaTime - Time elapsed since last frame in seconds
* @param entities - Entities matching system requirements
*/
process(deltaTime: number, entities: Entity[]): void {
const playerController = this.engine.systems.find(
(s) => s.name === SystemName.PLAYER_CONTROLLER
) as PlayerControllerSystem | undefined;
const player = playerController?.getPlayerEntity();
if (!player) return;
const playerPos = player?.getComponent<Position>(ComponentType.POSITION);
const config = GameConfig.AI;
entities.forEach((entity) => {
const health = entity.getComponent<Health>(ComponentType.HEALTH);
const ai = entity.getComponent<AI>(ComponentType.AI);
const position = entity.getComponent<Position>(ComponentType.POSITION);
const velocity = entity.getComponent<Velocity>(ComponentType.VELOCITY);
if (!ai || !position || !velocity) return;
if (health && health.isDead() && !health.isProjectile) {
velocity.vx = 0;
velocity.vy = 0;
return;
}
ai.wanderChangeTime += deltaTime;
if (playerPos) {
const dx = playerPos.x - position.x;
const dy = playerPos.y - position.y;
const distance = Math.sqrt(dx * dx + dy * dy);
const playerStealth = player?.getComponent<Stealth>(ComponentType.STEALTH);
const playerVisibility = playerStealth ? playerStealth.visibility : 1.0;
if (distance < ai.alertRadius) {
const detectionChance = (1 - distance / ai.alertRadius) * playerVisibility;
ai.updateAwareness(detectionChance * deltaTime * config.awarenessGainMultiplier);
} else {
ai.updateAwareness(-deltaTime * config.awarenessLossRate);
}
const playerEvolution = player?.getComponent<Evolution>(ComponentType.EVOLUTION);
const playerForm = playerEvolution ? playerEvolution.getDominantForm() : 'slime';
const sprite = entity.getComponent<Sprite>(ComponentType.SPRITE);
const entityType =
sprite?.color === '#ffaa00'
? 'beast'
: sprite?.color === '#ff5555'
? 'humanoid'
: 'other';
let isPassive = false;
let shouldFlee = false;
if (entityType === 'humanoid' && playerForm === 'human') {
if (ai.awareness < config.passiveAwarenessThreshold) isPassive = true;
} else if (entityType === 'beast' && playerForm === 'beast') {
const playerStats = player?.getComponent<Stats>(ComponentType.STATS);
const entityStats = entity.getComponent<Stats>(ComponentType.STATS);
if (playerStats && entityStats && playerStats.level > entityStats.level) {
shouldFlee = true;
}
}
if (shouldFlee && ai.awareness > config.fleeAwarenessThreshold) {
ai.setBehavior('flee');
ai.state = 'fleeing';
ai.setTarget(player.id);
} else if (isPassive) {
if (ai.behaviorType === 'chase' || ai.behaviorType === 'combat') {
ai.setBehavior('wander');
ai.state = 'idle';
ai.clearTarget();
}
} else if (ai.awareness > config.detectionAwarenessThreshold && distance < ai.chaseRadius) {
if (ai.behaviorType !== 'flee') {
const combat = entity.getComponent<Combat>(ComponentType.COMBAT);
if (combat && distance <= combat.attackRange) {
ai.setBehavior('combat');
ai.state = 'combat';
} else {
ai.setBehavior('chase');
ai.state = 'chasing';
}
ai.setTarget(player.id);
}
} else if (ai.awareness < 0.3) {
if (ai.behaviorType === 'chase' || ai.behaviorType === 'combat') {
ai.setBehavior('wander');
ai.state = 'idle';
ai.clearTarget();
}
} else if (ai.behaviorType === 'chase') {
const combat = entity.getComponent<Combat>(ComponentType.COMBAT);
if (combat && distance <= combat.attackRange) {
ai.setBehavior('combat');
ai.state = 'combat';
}
}
}
switch (ai.behaviorType) {
case 'wander':
this.wander(entity, ai, velocity, deltaTime);
break;
case 'chase':
this.chase(entity, ai, velocity, position, playerPos);
break;
case 'flee':
this.flee(entity, ai, velocity, position, playerPos);
break;
case 'combat':
this.combat(entity, ai, velocity, position, playerPos);
break;
}
});
}
/**
* Execute wandering behavior, moving in a random direction.
*/
wander(_entity: Entity, ai: AI, velocity: Velocity, _deltaTime: number): void {
ai.state = 'moving';
if (ai.wanderChangeTime >= ai.wanderChangeInterval) {
ai.wanderDirection = Math.random() * Math.PI * 2;
ai.wanderChangeTime = 0;
ai.wanderChangeInterval = 1 + Math.random() * 2;
}
velocity.vx = Math.cos(ai.wanderDirection) * ai.wanderSpeed;
velocity.vy = Math.sin(ai.wanderDirection) * ai.wanderSpeed;
}
/**
* Execute chasing behavior, moving toward a target.
*/
chase(
entity: Entity,
ai: AI,
velocity: Velocity,
position: Position,
targetPos: Position | undefined
): void {
if (!targetPos) return;
const dx = targetPos.x - position.x;
const dy = targetPos.y - position.y;
const distance = Math.sqrt(dx * dx + dy * dy);
const combat = entity.getComponent<Combat>(ComponentType.COMBAT);
if (combat && distance <= combat.attackRange) {
ai.setBehavior('combat');
ai.state = 'combat';
return;
}
ai.state = 'chasing';
if (distance > 0.1) {
const speed = ai.wanderSpeed * 1.5;
velocity.vx = (dx / distance) * speed;
velocity.vy = (dy / distance) * speed;
} else {
velocity.vx = 0;
velocity.vy = 0;
}
}
/**
* Execute fleeing behavior, moving away from a target.
*/
flee(
_entity: Entity,
ai: AI,
velocity: Velocity,
position: Position,
targetPos: Position | undefined
): void {
if (!targetPos) return;
ai.state = 'fleeing';
const dx = position.x - targetPos.x;
const dy = position.y - targetPos.y;
const distance = Math.sqrt(dx * dx + dy * dy);
if (distance > 0.1) {
const speed = ai.wanderSpeed * 1.2;
velocity.vx = (dx / distance) * speed;
velocity.vy = (dy / distance) * speed;
}
}
/**
* Execute combat behavior, moving into range and setting attack intent.
*/
combat(
entity: Entity,
ai: AI,
velocity: Velocity,
position: Position,
targetPos: Position | undefined
): void {
if (!targetPos) return;
ai.state = 'attacking';
const dx = targetPos.x - position.x;
const dy = targetPos.y - position.y;
const distance = Math.sqrt(dx * dx + dy * dy);
const combat = entity.getComponent<Combat>(ComponentType.COMBAT);
if (combat && distance > combat.attackRange) {
const speed = ai.wanderSpeed;
velocity.vx = (dx / distance) * speed;
velocity.vy = (dy / distance) * speed;
} else {
velocity.vx *= 0.5;
velocity.vy *= 0.5;
position.rotation = Math.atan2(dy, dx);
const intent = entity.getComponent<Intent>(ComponentType.INTENT);
if (intent) {
intent.setIntent('attack', { targetX: targetPos.x, targetY: targetPos.y });
}
}
}
}

View file

@ -1,176 +0,0 @@
import { System } from '../core/System.js';
import { GameConfig } from '../GameConfig.js';
import { Events } from '../core/EventBus.js';
export class AbsorptionSystem extends System {
constructor() {
super('AbsorptionSystem');
this.requiredComponents = ['Position', 'Absorbable'];
this.priority = 25;
this.absorptionEffects = []; // Visual effects
}
process(deltaTime, _entities) {
const playerController = this.engine.systems.find(s => s.name === 'PlayerControllerSystem');
const player = playerController ? playerController.getPlayerEntity() : null;
if (!player) return;
const playerPos = player.getComponent('Position');
const playerEvolution = player.getComponent('Evolution');
const playerSkills = player.getComponent('Skills');
const playerStats = player.getComponent('Stats');
const skillProgress = player.getComponent('SkillProgress');
if (!playerPos || !playerEvolution) return;
// Get ALL entities (including inactive ones) for absorption check
const allEntities = this.engine.entities; // Get raw entities array, not filtered
const config = GameConfig.Absorption;
// Check for absorbable entities near player
allEntities.forEach(entity => {
if (entity === player) return;
// Allow inactive entities if they're dead and absorbable
if (!entity.active) {
const health = entity.getComponent('Health');
const absorbable = entity.getComponent('Absorbable');
// Only process inactive entities if they're dead and not yet absorbed
if (!health || !health.isDead() || !absorbable || absorbable.absorbed) {
return;
}
}
if (!entity.hasComponent('Absorbable')) return;
if (!entity.hasComponent('Health')) return;
const absorbable = entity.getComponent('Absorbable');
const health = entity.getComponent('Health');
const entityPos = entity.getComponent('Position');
if (!entityPos) return;
// Check if creature is dead and in absorption range
if (health.isDead() && !absorbable.absorbed) {
const dx = playerPos.x - entityPos.x;
const dy = playerPos.y - entityPos.y;
const distance = Math.sqrt(dx * dx + dy * dy);
if (distance <= config.range) {
this.absorbEntity(player, entity, absorbable, playerEvolution, playerSkills, playerStats, skillProgress);
}
}
});
// Update visual effects
this.updateEffects(deltaTime);
}
absorbEntity(player, entity, absorbable, evolution, skills, stats, skillProgress) {
if (absorbable.absorbed) return;
absorbable.absorbed = true;
const entityPos = entity.getComponent('Position');
const health = player.getComponent('Health');
const config = GameConfig.Absorption;
// Add evolution points
evolution.addEvolution(
absorbable.evolutionData.human,
absorbable.evolutionData.beast,
absorbable.evolutionData.slime
);
// Track skill progress (need to absorb multiple times to learn)
// Always track progress for ALL skills the enemy has, regardless of roll
if (skillProgress && absorbable.skillsGranted && absorbable.skillsGranted.length > 0) {
absorbable.skillsGranted.forEach(skill => {
// Always add progress when absorbing an enemy with this skill
const currentProgress = skillProgress.addSkillProgress(skill.id);
const required = skillProgress.requiredAbsorptions;
// If we've absorbed enough, learn the skill
if (currentProgress >= required && !skills.hasSkill(skill.id)) {
skills.addSkill(skill.id, false);
this.engine.emit(Events.SKILL_LEARNED, { id: skill.id });
console.log(`Learned skill: ${skill.id}!`);
}
});
}
// Heal from absorption (slime recovers by consuming)
if (health) {
const healPercent = config.healPercentMin + Math.random() * (config.healPercentMax - config.healPercentMin);
const healAmount = health.maxHp * healPercent;
health.heal(healAmount);
}
// Check for mutation
if (absorbable.shouldMutate() && stats) {
this.applyMutation(stats);
evolution.checkMutations(stats, this.engine);
}
// Visual effect
if (entityPos) {
this.addAbsorptionEffect(entityPos.x, entityPos.y);
}
// Mark as absorbed - DeathSystem will handle removal after absorption window
// Don't remove immediately, let DeathSystem handle it
}
applyMutation(stats) {
// Random stat mutation
const mutations = [
{ stat: 'strength', amount: 5 },
{ stat: 'agility', amount: 5 },
{ stat: 'intelligence', amount: 5 },
{ stat: 'constitution', amount: 5 },
{ stat: 'perception', amount: 5 },
];
const mutation = mutations[Math.floor(Math.random() * mutations.length)];
stats[mutation.stat] += mutation.amount;
// Could also add negative mutations
if (Math.random() < 0.3) {
const negativeStat = mutations[Math.floor(Math.random() * mutations.length)];
stats[negativeStat.stat] = Math.max(1, stats[negativeStat.stat] - 2);
}
}
addAbsorptionEffect(x, y) {
for (let i = 0; i < 20; i++) {
this.absorptionEffects.push({
x,
y,
vx: (Math.random() - 0.5) * 200,
vy: (Math.random() - 0.5) * 200,
lifetime: 0.5 + Math.random() * 0.5,
size: 3 + Math.random() * 5,
color: `hsl(${120 + Math.random() * 60}, 100%, 50%)`
});
}
}
updateEffects(deltaTime) {
for (let i = this.absorptionEffects.length - 1; i >= 0; i--) {
const effect = this.absorptionEffects[i];
effect.x += effect.vx * deltaTime;
effect.y += effect.vy * deltaTime;
effect.lifetime -= deltaTime;
effect.vx *= 0.95;
effect.vy *= 0.95;
if (effect.lifetime <= 0) {
this.absorptionEffects.splice(i, 1);
}
}
}
getEffects() {
return this.absorptionEffects;
}
}

View file

@ -0,0 +1,172 @@
import { System } from '../core/System.ts';
import { GameConfig } from '../GameConfig.ts';
import { Events } from '../core/EventBus.ts';
import { SystemName, ComponentType } from '../core/Constants.ts';
import type { Entity } from '../core/Entity.ts';
import type { Position } from '../components/Position.ts';
import type { Evolution } from '../components/Evolution.ts';
import type { Skills } from '../components/Skills.ts';
import type { Stats } from '../components/Stats.ts';
import type { SkillProgress } from '../components/SkillProgress.ts';
import type { Absorbable } from '../components/Absorbable.ts';
import type { Health } from '../components/Health.ts';
import type { PlayerControllerSystem } from './PlayerControllerSystem.ts';
import type { VFXSystem } from './VFXSystem.ts';
/**
* System responsible for identifying dead absorbable entities near the player and processing absorption.
*/
export class AbsorptionSystem extends System {
constructor() {
super(SystemName.ABSORPTION);
this.requiredComponents = [ComponentType.POSITION, ComponentType.ABSORBABLE];
this.priority = 25;
}
/**
* Check for absorbable entities within range of the player and initiate absorption if applicable.
* @param _deltaTime - Time elapsed since last frame
* @param _entities - Matching entities (not used, uses raw engine entities)
*/
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);
const playerEvolution = player.getComponent<Evolution>(ComponentType.EVOLUTION);
const playerSkills = player.getComponent<Skills>(ComponentType.SKILLS);
const playerStats = player.getComponent<Stats>(ComponentType.STATS);
const skillProgress = player.getComponent<SkillProgress>(ComponentType.SKILL_PROGRESS);
if (!playerPos || !playerEvolution) return;
const allEntities = this.engine.entities;
const config = GameConfig.Absorption;
allEntities.forEach((entity) => {
if (entity === player) return;
if (!entity.active) {
const health = entity.getComponent<Health>(ComponentType.HEALTH);
const absorbable = entity.getComponent<Absorbable>(ComponentType.ABSORBABLE);
if (!health || !health.isDead() || !absorbable || absorbable.absorbed) {
return;
}
}
if (!entity.hasComponent(ComponentType.ABSORBABLE)) return;
if (!entity.hasComponent(ComponentType.HEALTH)) return;
const absorbable = entity.getComponent<Absorbable>(ComponentType.ABSORBABLE);
const health = entity.getComponent<Health>(ComponentType.HEALTH);
const entityPos = entity.getComponent<Position>(ComponentType.POSITION);
if (!entityPos) return;
if (health && health.isDead() && absorbable && !absorbable.absorbed) {
const dx = playerPos.x - entityPos.x;
const dy = playerPos.y - entityPos.y;
const distance = Math.sqrt(dx * dx + dy * dy);
if (distance <= config.range) {
this.absorbEntity(
player,
entity,
absorbable,
playerEvolution,
playerSkills,
playerStats,
skillProgress
);
}
}
});
}
/**
* Process the absorption of an entity by the player.
*/
absorbEntity(
player: Entity,
entity: Entity,
absorbable: Absorbable,
evolution: Evolution,
skills: Skills | undefined,
stats: Stats | undefined,
skillProgress: SkillProgress | undefined
): void {
if (absorbable.absorbed) return;
absorbable.absorbed = true;
const entityPos = entity.getComponent<Position>(ComponentType.POSITION);
const health = player.getComponent<Health>(ComponentType.HEALTH);
const config = GameConfig.Absorption;
evolution.addEvolution(
absorbable.evolutionData.human,
absorbable.evolutionData.beast,
absorbable.evolutionData.slime
);
if (skillProgress && absorbable.skillsGranted && absorbable.skillsGranted.length > 0) {
absorbable.skillsGranted.forEach((skill) => {
const currentProgress = skillProgress.addSkillProgress(skill.id);
const required = skillProgress.requiredAbsorptions;
if (currentProgress >= required && skills && !skills.hasSkill(skill.id)) {
skills.addSkill(skill.id, false);
this.engine.emit(Events.SKILL_LEARNED, { id: skill.id });
console.log(`Learned skill: ${skill.id}!`);
}
});
}
if (health) {
const healPercent =
config.healPercentMin + Math.random() * (config.healPercentMax - config.healPercentMin);
const healAmount = health.maxHp * healPercent;
health.heal(healAmount);
}
if (absorbable.shouldMutate() && stats) {
this.applyMutation(stats);
evolution.checkMutations(stats, this.engine);
}
if (entityPos) {
const vfxSystem = this.engine.systems.find((s) => s.name === SystemName.VFX) as
| VFXSystem
| undefined;
if (vfxSystem) {
vfxSystem.createAbsorption(entityPos.x, entityPos.y);
}
}
this.engine.emit(Events.ABSORPTION, { entity });
}
/**
* Apply a random stat mutation (positive or negative) to an entity's stats.
* @param stats - The stats component to mutate
*/
applyMutation(stats: Stats): void {
type StatName = 'strength' | 'agility' | 'intelligence' | 'constitution' | 'perception';
const mutations: Array<{ stat: StatName; amount: number }> = [
{ stat: 'strength', amount: 5 },
{ stat: 'agility', amount: 5 },
{ stat: 'intelligence', amount: 5 },
{ stat: 'constitution', amount: 5 },
{ stat: 'perception', amount: 5 },
];
const mutation = mutations[Math.floor(Math.random() * mutations.length)];
stats[mutation.stat] += mutation.amount;
if (Math.random() < 0.3) {
const negativeStat = mutations[Math.floor(Math.random() * mutations.length)];
stats[negativeStat.stat] = Math.max(1, stats[negativeStat.stat] - 2);
}
}
}

View file

@ -0,0 +1,50 @@
import { System } from '../core/System.ts';
import { SystemName, ComponentType } from '../core/Constants.ts';
import type { Entity } from '../core/Entity.ts';
import type { Camera } from '../components/Camera.ts';
import type { Position } from '../components/Position.ts';
import type { PlayerControllerSystem } from './PlayerControllerSystem.ts';
/**
* System responsible for camera movement and following the player.
*/
export class CameraSystem extends System {
constructor() {
super(SystemName.CAMERA);
this.requiredComponents = [ComponentType.CAMERA];
this.priority = 0;
}
/**
* Update camera position to smoothly follow the player.
* @param deltaTime - Time elapsed since last frame in seconds
* @param entities - Filtered entities with Camera component
*/
process(deltaTime: number, entities: Entity[]): void {
const playerController = this.engine.systems.find(
(s) => s.name === SystemName.PLAYER_CONTROLLER
) as PlayerControllerSystem | undefined;
const player = playerController ? playerController.getPlayerEntity() : null;
if (!player) return;
const playerPos = player.getComponent<Position>(ComponentType.POSITION);
if (!playerPos) return;
entities.forEach((entity) => {
const camera = entity.getComponent<Camera>(ComponentType.CAMERA);
if (!camera) return;
camera.targetX = playerPos.x;
camera.targetY = playerPos.y;
const dx = camera.targetX - camera.x;
const dy = camera.targetY - camera.y;
camera.x += dx * camera.smoothness;
camera.y += dy * camera.smoothness;
camera.clampToBounds();
});
}
}

View file

@ -1,190 +0,0 @@
import { System } from '../core/System.js';
import { GameConfig } from '../GameConfig.js';
import { Events } from '../core/EventBus.js';
export class CombatSystem extends System {
constructor() {
super('CombatSystem');
this.requiredComponents = ['Position', 'Combat', 'Health'];
this.priority = 20;
}
process(deltaTime, entities) {
// Update combat cooldowns
entities.forEach(entity => {
const combat = entity.getComponent('Combat');
if (combat) {
combat.update(deltaTime);
}
});
// Handle player attacks
const playerController = this.engine.systems.find(s => s.name === 'PlayerControllerSystem');
const player = playerController ? playerController.getPlayerEntity() : null;
if (player && player.hasComponent('Combat')) {
this.handlePlayerCombat(player, deltaTime);
}
// Handle creature attacks
const creatures = entities.filter(e =>
e.hasComponent('AI') &&
e.hasComponent('Combat') &&
e !== player
);
creatures.forEach(creature => {
this.handleCreatureCombat(creature, player, deltaTime);
});
// Check for collisions and apply damage
this.processCombatCollisions(entities, deltaTime);
}
handlePlayerCombat(player, _deltaTime) {
const inputSystem = this.engine.systems.find(s => s.name === 'InputSystem');
const combat = player.getComponent('Combat');
const position = player.getComponent('Position');
if (!inputSystem || !combat || !position) return;
const currentTime = Date.now() / 1000;
// Attack on mouse click or space (use justPressed to prevent spam)
const mouseClick = inputSystem.isMouseButtonJustPressed(0);
const spacePress = inputSystem.isKeyJustPressed(' ') || inputSystem.isKeyJustPressed('space');
if ((mouseClick || spacePress) && combat.canAttack(currentTime)) {
// Calculate attack direction from player to mouse
const mouse = inputSystem.getMousePosition();
const dx = mouse.x - position.x;
const dy = mouse.y - position.y;
const attackAngle = Math.atan2(dy, dx);
// Update player rotation to face attack direction
position.rotation = attackAngle;
combat.attack(currentTime, attackAngle);
// Check for nearby enemies to damage
this.performAttack(player, combat, position);
}
}
handleCreatureCombat(creature, player, _deltaTime) {
const ai = creature.getComponent('AI');
const combat = creature.getComponent('Combat');
const position = creature.getComponent('Position');
const playerPos = player?.getComponent('Position');
if (!ai || !combat || !position) return;
// Attack player if in range and aware (check both combat state and chase behavior)
if (playerPos && ai.awareness > 0.5 && (ai.state === 'combat' || ai.behaviorType === 'combat' || (ai.behaviorType === 'chase' && ai.awareness > 0.7))) {
const dx = playerPos.x - position.x;
const dy = playerPos.y - position.y;
const distance = Math.sqrt(dx * dx + dy * dy);
if (distance <= combat.attackRange) {
const currentTime = Date.now() / 1000;
if (combat.canAttack(currentTime)) {
const angle = Math.atan2(dy, dx);
combat.attack(currentTime, angle);
this.performAttack(creature, combat, position);
}
}
}
}
performAttack(attacker, combat, attackerPos) {
const entities = this.engine.getEntities();
const stats = attacker.getComponent('Stats');
const _baseDamage = stats ?
(combat.attackDamage + stats.strength * 0.5) :
combat.attackDamage;
entities.forEach(target => {
if (target === attacker) return;
if (!target.hasComponent('Health')) return;
const targetPos = target.getComponent('Position');
if (!targetPos) return;
// Check if in attack range and angle
const dx = targetPos.x - attackerPos.x;
const dy = targetPos.y - attackerPos.y;
const distance = Math.sqrt(dx * dx + dy * dy);
if (distance <= combat.attackRange) {
const angle = Math.atan2(dy, dx);
const angleDiff = Math.abs(angle - combat.attackDirection);
const normalizedDiff = Math.abs(((angleDiff % (Math.PI * 2)) + Math.PI * 2) % (Math.PI * 2));
const minDiff = Math.min(normalizedDiff, Math.PI * 2 - normalizedDiff);
// Attack arc
const attackArc = GameConfig.Combat.defaultAttackArc;
if (minDiff < attackArc) {
const health = target.getComponent('Health');
const config = GameConfig.Combat;
const stats = attacker.getComponent('Stats');
const baseDamage = stats ? (combat.attackDamage + stats.strength * 0.5) : combat.attackDamage;
// Defense bonus from Hardened Shell
let finalDamage = baseDamage;
const targetEvolution = target.getComponent('Evolution');
if (targetEvolution && targetEvolution.mutationEffects.hardenedShell) {
finalDamage *= config.hardenedShellReduction;
}
const actualDamage = health.takeDamage(finalDamage);
// Emit event for UI/VFX
this.engine.emit(Events.DAMAGE_DEALT, {
x: targetPos.x,
y: targetPos.y,
value: actualDamage,
color: '#ffffff'
});
// Damage reflection from Electric Skin
if (targetEvolution && targetEvolution.mutationEffects.electricSkin) {
const attackerHealth = attacker.getComponent('Health');
if (attackerHealth) {
const reflectedDamage = actualDamage * config.damageReflectionPercent;
attackerHealth.takeDamage(reflectedDamage);
this.engine.emit(Events.DAMAGE_DEALT, {
x: attackerPos.x,
y: attackerPos.y,
value: reflectedDamage,
color: '#00ffff'
});
}
}
// If target is dead, emit event
if (health.isDead()) {
this.engine.emit(Events.ENTITY_DIED, { entity: target });
target.active = false;
}
// Apply knockback
const velocity = target.getComponent('Velocity');
if (velocity) {
const knockbackPower = config.knockbackPower;
const kx = Math.cos(angle) * knockbackPower;
const ky = Math.sin(angle) * knockbackPower;
velocity.vx += kx;
velocity.vy += ky;
}
}
}
});
}
processCombatCollisions(_entities, _deltaTime) {
// This can be expanded for projectile collisions, area effects, etc.
}
}

182
src/systems/CombatSystem.ts Normal file
View file

@ -0,0 +1,182 @@
import { System } from '../core/System.ts';
import { GameConfig } from '../GameConfig.ts';
import { Events } from '../core/EventBus.ts';
import { SystemName, ComponentType } from '../core/Constants.ts';
import type { Entity } from '../core/Entity.ts';
import type { Combat } from '../components/Combat.ts';
import type { Intent } from '../components/Intent.ts';
import type { Position } from '../components/Position.ts';
import type { Health } from '../components/Health.ts';
import type { Sprite } from '../components/Sprite.ts';
import type { Stats } from '../components/Stats.ts';
import type { Evolution } from '../components/Evolution.ts';
import type { Velocity } from '../components/Velocity.ts';
/**
* System responsible for managing combat interactions, attack intent processing, and damage application.
*/
export class CombatSystem extends System {
constructor() {
super(SystemName.COMBAT);
this.requiredComponents = [ComponentType.POSITION, ComponentType.COMBAT, ComponentType.HEALTH];
this.priority = 20;
}
/**
* Process combat updates, timers, and entity attack intents.
* @param deltaTime - Time elapsed since last frame in seconds
* @param entities - Entities matching system requirements
*/
process(deltaTime: number, entities: Entity[]): void {
const currentTime = Date.now() / 1000;
entities.forEach((entity) => {
const combat = entity.getComponent<Combat>(ComponentType.COMBAT);
const intent = entity.getComponent<Intent>(ComponentType.INTENT);
const position = entity.getComponent<Position>(ComponentType.POSITION);
if (combat) {
combat.update(deltaTime);
}
if (intent && intent.action === 'attack' && combat && position) {
if (combat.canAttack(currentTime)) {
const { targetX, targetY } = intent.data;
const dx = targetX - position.x;
const dy = targetY - position.y;
const angle = Math.atan2(dy, dx);
this.performAttack(entity, angle);
}
intent.clear();
}
});
this.processCombatCollisions(entities, deltaTime);
}
/**
* Initiate an attack from an entity at a specific angle.
* @param attacker - The entity performing the attack
* @param angle - The attack angle in radians
*/
performAttack(attacker: Entity, angle: number): void {
const attackerPos = attacker.getComponent<Position>(ComponentType.POSITION);
const combat = attacker.getComponent<Combat>(ComponentType.COMBAT);
if (!attackerPos || !combat) return;
const currentTime = Date.now() / 1000;
combat.attack(currentTime, angle);
this.engine.emit(Events.ATTACK_PERFORMED, { entity: attacker, angle });
const entities = this.engine.getEntities();
entities.forEach((target) => {
if (target === attacker) return;
if (!target.hasComponent(ComponentType.HEALTH)) return;
const targetPos = target.getComponent<Position>(ComponentType.POSITION);
if (!targetPos) return;
const dx = targetPos.x - attackerPos.x;
const dy = targetPos.y - attackerPos.y;
const distance = Math.sqrt(dx * dx + dy * dy);
const targetSprite = target.getComponent<Sprite>(ComponentType.SPRITE);
const targetRadius = targetSprite ? Math.max(targetSprite.width, targetSprite.height) / 2 : 0;
if (distance <= combat.attackRange + targetRadius) {
const targetAngle = Math.atan2(dy, dx);
const angleDiff = Math.abs(targetAngle - angle);
const normalizedDiff = Math.abs(
((angleDiff % (Math.PI * 2)) + Math.PI * 2) % (Math.PI * 2)
);
const minDiff = Math.min(normalizedDiff, Math.PI * 2 - normalizedDiff);
const attackArc = GameConfig.Combat.defaultAttackArc || Math.PI / 3;
if (minDiff < attackArc) {
this.applyDamage(attacker, target);
const vfxSystem = this.engine.systems.find((s) => s.name === SystemName.VFX) as
| VFXSystem
| undefined;
if (vfxSystem) {
vfxSystem.createImpact(targetPos.x, targetPos.y, '#ffffff', targetAngle);
}
}
}
});
}
/**
* Apply damage and effects from one entity to another.
* @param attacker - The attacking entity
* @param target - The target entity being damaged
*/
applyDamage(attacker: Entity, target: Entity): void {
const health = target.getComponent<Health>(ComponentType.HEALTH);
const combat = attacker.getComponent<Combat>(ComponentType.COMBAT);
const stats = attacker.getComponent<Stats>(ComponentType.STATS);
const targetPos = target.getComponent<Position>(ComponentType.POSITION);
const attackerPos = attacker.getComponent<Position>(ComponentType.POSITION);
if (!health || !combat || !targetPos || !attackerPos) return;
const config = GameConfig.Combat;
const baseDamage = stats ? combat.attackDamage + stats.strength * 0.5 : combat.attackDamage;
let finalDamage = baseDamage;
const targetEvolution = target.getComponent<Evolution>(ComponentType.EVOLUTION);
if (targetEvolution && targetEvolution.mutationEffects.hardenedShell) {
finalDamage *= config.hardenedShellReduction;
}
const actualDamage = health.takeDamage(finalDamage);
this.engine.emit(Events.DAMAGE_DEALT, {
x: targetPos.x,
y: targetPos.y,
value: actualDamage,
color: '#ffffff',
});
if (targetEvolution && targetEvolution.mutationEffects.electricSkin) {
const attackerHealth = attacker.getComponent<Health>(ComponentType.HEALTH);
if (attackerHealth) {
const reflectedDamage = actualDamage * config.damageReflectionPercent;
attackerHealth.takeDamage(reflectedDamage);
this.engine.emit(Events.DAMAGE_DEALT, {
x: attackerPos.x,
y: attackerPos.y,
value: reflectedDamage,
color: '#00ffff',
});
}
}
const velocity = target.getComponent<Velocity>(ComponentType.VELOCITY);
if (velocity) {
const dx = targetPos.x - attackerPos.x;
const dy = targetPos.y - attackerPos.y;
const dist = Math.sqrt(dx * dx + dy * dy);
const kx = (dx / dist) * config.knockbackPower;
const ky = (dy / dist) * config.knockbackPower;
velocity.vx += kx;
velocity.vy += ky;
}
if (health.isDead()) {
this.engine.emit(Events.ENTITY_DIED, { entity: target });
}
}
/**
* Process collision-based combat events. Placeholder for future expansion.
* @param _entities - The entities to check
* @param _deltaTime - Time elapsed since last frame
*/
processCombatCollisions(_entities: Entity[], _deltaTime: number): void {}
}

View file

@ -1,56 +0,0 @@
import { System } from '../core/System.js';
/**
* System to handle entity death - removes dead entities immediately
*/
export class DeathSystem extends System {
constructor() {
super('DeathSystem');
this.requiredComponents = ['Health'];
this.priority = 50; // Run after absorption (absorption is priority 25)
}
update(deltaTime, _entities) {
// Override to check ALL entities, not just active ones
// Get all entities including inactive ones to check dead entities
const allEntities = this.engine.entities;
this.process(deltaTime, allEntities);
}
process(deltaTime, allEntities) {
allEntities.forEach(entity => {
const health = entity.getComponent('Health');
if (!health) return;
// Check if entity is dead
if (health.isDead()) {
// Don't remove player
const evolution = entity.getComponent('Evolution');
if (evolution) return; // Player has Evolution component
// Mark as inactive immediately so it stops being processed by other systems
if (entity.active) {
entity.active = false;
entity.deathTime = Date.now(); // Set death time when first marked dead
}
// Check if it's absorbable - if so, give a short window for absorption
const absorbable = entity.getComponent('Absorbable');
if (absorbable && !absorbable.absorbed) {
// Give 3 seconds for player to absorb, then remove
const timeSinceDeath = (Date.now() - entity.deathTime) / 1000;
if (timeSinceDeath > 3.0) {
this.engine.removeEntity(entity);
}
} else {
// Not absorbable or already absorbed - remove after short delay
const timeSinceDeath = (Date.now() - entity.deathTime) / 1000;
if (timeSinceDeath > 0.5) {
this.engine.removeEntity(entity);
}
}
}
});
}
}

View file

@ -0,0 +1,75 @@
import { System } from '../core/System.ts';
import { SystemName, ComponentType } from '../core/Constants.ts';
import type { Entity } from '../core/Entity.ts';
import type { Health } from '../components/Health.ts';
import type { Evolution } from '../components/Evolution.ts';
import type { Absorbable } from '../components/Absorbable.ts';
import type { MenuSystem } from './MenuSystem.ts';
/**
* System responsible for managing entity death, game over states, and cleanup of dead entities.
*/
export class DeathSystem extends System {
constructor() {
super(SystemName.DEATH);
this.requiredComponents = [ComponentType.HEALTH];
this.priority = 50;
}
/**
* Override update to process all entities (including inactive/dead ones).
* @param deltaTime - Time elapsed since last frame
* @param _entities - Filtered active entities (not used, uses raw engine entities)
*/
update(deltaTime: number, _entities: Entity[]): void {
const allEntities = this.engine.entities;
this.process(deltaTime, allEntities);
}
/**
* Process death logic for all entities.
* @param _deltaTime - Time elapsed since last frame
* @param allEntities - All entities in the engine
*/
process(_deltaTime: number, allEntities: Entity[]): void {
allEntities.forEach((entity) => {
const health = entity.getComponent<Health>(ComponentType.HEALTH);
if (!health) return;
if (health.isDead()) {
const evolution = entity.getComponent<Evolution>(ComponentType.EVOLUTION);
if (evolution) {
const menuSystem = this.engine.systems.find((s) => s.name === SystemName.MENU) as
| MenuSystem
| undefined;
if (menuSystem) {
menuSystem.showGameOver();
}
return;
}
if (entity.active || !entity.deathTime) {
if (entity.active) {
entity.active = false;
}
if (!entity.deathTime) {
entity.deathTime = Date.now();
}
}
const absorbable = entity.getComponent<Absorbable>(ComponentType.ABSORBABLE);
if (absorbable && !absorbable.absorbed) {
const timeSinceDeath = (Date.now() - (entity.deathTime || 0)) / 1000;
if (timeSinceDeath > 3.0) {
this.engine.removeEntity(entity);
}
} else {
const timeSinceDeath = (Date.now() - (entity.deathTime || 0)) / 1000;
if (timeSinceDeath > 0.5) {
this.engine.removeEntity(entity);
}
}
}
});
}
}

View file

@ -1,27 +0,0 @@
import { System } from '../core/System.js';
/**
* System to handle health regeneration
*/
export class HealthRegenerationSystem extends System {
constructor() {
super('HealthRegenerationSystem');
this.requiredComponents = ['Health'];
this.priority = 35;
}
process(deltaTime, entities) {
entities.forEach(entity => {
const health = entity.getComponent('Health');
if (!health || health.regeneration <= 0) return;
// Regenerate health over time
// Only regenerate if not recently damaged (5 seconds)
const timeSinceDamage = (Date.now() - health.lastDamageTime) / 1000;
if (timeSinceDamage > 5) {
health.heal(health.regeneration * deltaTime);
}
});
}
}

View file

@ -0,0 +1,32 @@
import { System } from '../core/System.ts';
import { SystemName, ComponentType } from '../core/Constants.ts';
import type { Entity } from '../core/Entity.ts';
import type { Health } from '../components/Health.ts';
/**
* System responsible for managing health regeneration for entities over time.
*/
export class HealthRegenerationSystem extends System {
constructor() {
super(SystemName.HEALTH_REGEN);
this.requiredComponents = [ComponentType.HEALTH];
this.priority = 35;
}
/**
* Process health regeneration for entities that haven't been damaged recently.
* @param deltaTime - Time elapsed since last frame in seconds
* @param entities - Entities matching system requirements
*/
process(deltaTime: number, entities: Entity[]): void {
entities.forEach((entity) => {
const health = entity.getComponent<Health>(ComponentType.HEALTH);
if (!health || health.regeneration <= 0) return;
const timeSinceDamage = (Date.now() - health.lastDamageTime) / 1000;
if (timeSinceDamage > 5) {
health.heal(health.regeneration * deltaTime);
}
});
}
}

View file

@ -1,153 +0,0 @@
import { System } from '../core/System.js';
export class InputSystem extends System {
constructor() {
super('InputSystem');
this.requiredComponents = []; // No required components - handles input globally
this.priority = 0; // Run first
this.keys = {};
this.keysPrevious = {}; // Track previous frame key states
this.mouse = {
x: 0,
y: 0,
buttons: {},
buttonsPrevious: {}
};
}
init(engine) {
super.init(engine);
this.setupEventListeners();
}
setupEventListeners() {
window.addEventListener('keydown', (e) => {
const key = e.key.toLowerCase();
const code = e.code.toLowerCase();
// Store by key name
this.keys[key] = true;
this.keys[code] = true;
// Handle special keys
if (key === ' ') {
this.keys['space'] = true;
}
if (code === 'space') {
this.keys['space'] = true;
}
// Arrow keys
if (code === 'arrowup') this.keys['arrowup'] = true;
if (code === 'arrowdown') this.keys['arrowdown'] = true;
if (code === 'arrowleft') this.keys['arrowleft'] = true;
if (code === 'arrowright') this.keys['arrowright'] = true;
// Prevent default for game keys
if ([' ', 'w', 'a', 's', 'd', '1', '2', '3', '4', '5', '6', '7', '8', '9'].includes(key)) {
e.preventDefault();
}
});
window.addEventListener('keyup', (e) => {
const key = e.key.toLowerCase();
const code = e.code.toLowerCase();
this.keys[key] = false;
this.keys[code] = false;
// Handle special keys
if (key === ' ') {
this.keys['space'] = false;
}
if (code === 'space') {
this.keys['space'] = false;
}
// Arrow keys
if (code === 'arrowup') this.keys['arrowup'] = false;
if (code === 'arrowdown') this.keys['arrowdown'] = false;
if (code === 'arrowleft') this.keys['arrowleft'] = false;
if (code === 'arrowright') this.keys['arrowright'] = false;
});
window.addEventListener('mousemove', (e) => {
if (this.engine && this.engine.canvas) {
const canvas = this.engine.canvas;
const rect = canvas.getBoundingClientRect();
this.mouse.x = e.clientX - rect.left;
this.mouse.y = e.clientY - rect.top;
}
});
window.addEventListener('mousedown', (e) => {
this.mouse.buttons[e.button] = true;
});
window.addEventListener('mouseup', (e) => {
this.mouse.buttons[e.button] = false;
});
}
process(_deltaTime, _entities) {
// Don't update previous states here - that happens at end of frame
// This allows other systems to check isKeyJustPressed during the frame
}
/**
* Update previous states - called at end of frame
*/
updatePreviousStates() {
// Deep copy current states to previous for next frame
this.keysPrevious = {};
for (const key in this.keys) {
this.keysPrevious[key] = this.keys[key];
}
this.mouse.buttonsPrevious = {};
for (const button in this.mouse.buttons) {
this.mouse.buttonsPrevious[button] = this.mouse.buttons[button];
}
}
/**
* Check if a key is currently pressed
*/
isKeyPressed(key) {
return this.keys[key.toLowerCase()] === true;
}
/**
* Check if a key was just pressed (not held from previous frame)
*/
isKeyJustPressed(key) {
const keyLower = key.toLowerCase();
const isPressed = this.keys[keyLower] === true;
const wasPressed = this.keysPrevious[keyLower] === true;
return isPressed && !wasPressed;
}
/**
* Get mouse position
*/
getMousePosition() {
return { x: this.mouse.x, y: this.mouse.y };
}
/**
* Check if mouse button is pressed
*/
isMouseButtonPressed(button = 0) {
return this.mouse.buttons[button] === true;
}
/**
* Check if mouse button was just pressed
*/
isMouseButtonJustPressed(button = 0) {
const isPressed = this.mouse.buttons[button] === true;
const wasPressed = this.mouse.buttonsPrevious[button] === true;
return isPressed && !wasPressed;
}
}

200
src/systems/InputSystem.ts Normal file
View file

@ -0,0 +1,200 @@
import { System } from '../core/System.ts';
import { SystemName, ComponentType } from '../core/Constants.ts';
import type { Engine } from '../core/Engine.ts';
import type { Entity } from '../core/Entity.ts';
import type { Camera } from '../components/Camera.ts';
interface MouseState {
x: number;
y: number;
buttons: Record<number, boolean>;
buttonsPrevious: Record<number, boolean>;
}
/**
* System responsible for capturing and managing keyboard and mouse input.
*/
export class InputSystem extends System {
keys: Record<string, boolean>;
keysPrevious: Record<string, boolean>;
mouse: MouseState;
constructor() {
super(SystemName.INPUT);
this.requiredComponents = [];
this.priority = 0;
this.keys = {};
this.keysPrevious = {};
this.mouse = {
x: 0,
y: 0,
buttons: {},
buttonsPrevious: {},
};
}
/**
* Initialize the system and set up event listeners.
* @param engine - The game engine instance
*/
init(engine: Engine): void {
super.init(engine);
this.setupEventListeners();
}
/**
* Set up browser event listeners for keyboard and mouse.
*/
setupEventListeners(): void {
window.addEventListener('keydown', (e) => {
const key = e.key.toLowerCase();
const code = e.code.toLowerCase();
this.keys[key] = true;
this.keys[code] = true;
if (key === ' ') {
this.keys['space'] = true;
}
if (code === 'space') {
this.keys['space'] = true;
}
if (code === 'arrowup') this.keys['arrowup'] = true;
if (code === 'arrowdown') this.keys['arrowdown'] = true;
if (code === 'arrowleft') this.keys['arrowleft'] = true;
if (code === 'arrowright') this.keys['arrowright'] = true;
if ([' ', 'w', 'a', 's', 'd', '1', '2', '3', '4', '5', '6', '7', '8', '9'].includes(key)) {
e.preventDefault();
}
});
window.addEventListener('keyup', (e) => {
const key = e.key.toLowerCase();
const code = e.code.toLowerCase();
this.keys[key] = false;
this.keys[code] = false;
if (key === ' ') {
this.keys['space'] = false;
}
if (code === 'space') {
this.keys['space'] = false;
}
if (code === 'arrowup') this.keys['arrowup'] = false;
if (code === 'arrowdown') this.keys['arrowdown'] = false;
if (code === 'arrowleft') this.keys['arrowleft'] = false;
if (code === 'arrowright') this.keys['arrowright'] = false;
});
window.addEventListener('mousemove', (e) => {
if (this.engine && this.engine.canvas) {
const canvas = this.engine.canvas;
const rect = canvas.getBoundingClientRect();
const scaleX = canvas.width / rect.width;
const scaleY = canvas.height / rect.height;
this.mouse.x = (e.clientX - rect.left) * scaleX;
this.mouse.y = (e.clientY - rect.top) * scaleY;
}
});
window.addEventListener('mousedown', (e) => {
this.mouse.buttons[e.button] = true;
});
window.addEventListener('mouseup', (e) => {
this.mouse.buttons[e.button] = false;
});
}
/**
* Process input state (placeholder as processing happens via events).
* @param _deltaTime - Time elapsed
* @param _entities - Matching entities
*/
process(_deltaTime: number, _entities: Entity[]): void {}
/**
* Update previous frame states. Should be called at the end of each frame.
*/
updatePreviousStates(): void {
this.keysPrevious = {};
for (const key in this.keys) {
this.keysPrevious[key] = this.keys[key];
}
this.mouse.buttonsPrevious = {};
for (const button in this.mouse.buttons) {
this.mouse.buttonsPrevious[button] = this.mouse.buttons[button];
}
}
/**
* Check if a key is currently being held down.
* @param key - The key name or code
* @returns True if the key is pressed
*/
isKeyPressed(key: string): boolean {
return this.keys[key.toLowerCase()] === true;
}
/**
* Check if a key was pressed in the current frame.
* @param key - The key name or code
* @returns True if the key was just pressed
*/
isKeyJustPressed(key: string): boolean {
const keyLower = key.toLowerCase();
const isPressed = this.keys[keyLower] === true;
const wasPressed = this.keysPrevious[keyLower] === true;
return isPressed && !wasPressed;
}
/**
* Get the current mouse position in world coordinates.
* @returns The mouse coordinates in world space
*/
getMousePosition(): { x: number; y: number } {
if (!this.engine) {
return { x: this.mouse.x, y: this.mouse.y };
}
const cameraEntity = this.engine.entities.find((e) => e.hasComponent(ComponentType.CAMERA));
if (!cameraEntity) {
return { x: this.mouse.x, y: this.mouse.y };
}
const camera = cameraEntity.getComponent<Camera>(ComponentType.CAMERA);
if (!camera) {
return { x: this.mouse.x, y: this.mouse.y };
}
const worldX = this.mouse.x + camera.x - camera.viewportWidth / 2;
const worldY = this.mouse.y + camera.y - camera.viewportHeight / 2;
return { x: worldX, y: worldY };
}
/**
* Check if a mouse button is currently being held down.
* @param button - The button index (0=left, 1=middle, 2=right)
* @returns True if the button is pressed
*/
isMouseButtonPressed(button = 0): boolean {
return this.mouse.buttons[button] === true;
}
/**
* Check if a mouse button was pressed in the current frame.
* @param button - The button index
* @returns True if the button was just pressed
*/
isMouseButtonJustPressed(button = 0): boolean {
const isPressed = this.mouse.buttons[button] === true;
const wasPressed = this.mouse.buttonsPrevious[button] === true;
return isPressed && !wasPressed;
}
}

View file

@ -1,110 +0,0 @@
import { System } from '../core/System.js';
/**
* System to handle game menus (start, pause)
*/
export class MenuSystem extends System {
constructor(engine) {
super('MenuSystem');
this.requiredComponents = []; // No required components
this.priority = 1; // Run early
this.engine = engine;
this.ctx = engine.ctx;
this.gameState = 'start'; // 'start', 'playing', 'paused'
this.paused = false;
}
init(engine) {
super.init(engine);
this.setupInput();
}
setupInput() {
window.addEventListener('keydown', (e) => {
if (e.key === 'Escape' || e.key === 'p' || e.key === 'P') {
if (this.gameState === 'playing') {
this.togglePause();
}
}
if (e.key === 'Enter' || e.key === ' ') {
if (this.gameState === 'start') {
this.startGame();
} else if (this.gameState === 'paused') {
this.resumeGame();
}
}
});
}
startGame() {
this.gameState = 'playing';
this.paused = false;
if (!this.engine.running) {
this.engine.start();
}
}
togglePause() {
if (this.gameState === 'playing') {
this.gameState = 'paused';
this.paused = true;
} else if (this.gameState === 'paused') {
this.resumeGame();
}
}
resumeGame() {
this.gameState = 'playing';
this.paused = false;
}
process(_deltaTime, _entities) {
// Don't update game systems if paused or at start menu
if (this.gameState === 'paused' || this.gameState === 'start') {
// Pause all other systems
this.engine.systems.forEach(system => {
if (system !== this && system.name !== 'MenuSystem' && system.name !== 'UISystem') {
// Systems will check game state themselves
}
});
}
}
drawMenu() {
const ctx = this.ctx;
const width = this.engine.canvas.width;
const height = this.engine.canvas.height;
// Dark overlay
ctx.fillStyle = 'rgba(0, 0, 0, 0.7)';
ctx.fillRect(0, 0, width, height);
ctx.fillStyle = '#ffffff';
ctx.font = 'bold 48px Courier New';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
if (this.gameState === 'start') {
ctx.fillText('SLIME GENESIS', width / 2, height / 2 - 100);
ctx.font = '24px Courier New';
ctx.fillText('Press ENTER or SPACE to Start', width / 2, height / 2);
ctx.font = '16px Courier New';
ctx.fillText('WASD: Move | Mouse: Aim | Click/Space: Attack', width / 2, height / 2 + 50);
ctx.fillText('Shift: Stealth | 1-9: Skills | ESC: Pause', width / 2, height / 2 + 80);
} else if (this.gameState === 'paused') {
ctx.fillText('PAUSED', width / 2, height / 2 - 50);
ctx.font = '24px Courier New';
ctx.fillText('Press ENTER or SPACE to Resume', width / 2, height / 2);
ctx.fillText('Press ESC to Pause/Unpause', width / 2, height / 2 + 40);
}
}
getGameState() {
return this.gameState;
}
isPaused() {
return this.paused || this.gameState === 'start';
}
}

200
src/systems/MenuSystem.ts Normal file
View file

@ -0,0 +1,200 @@
import { System } from '../core/System.ts';
import { PixelFont } from '../core/PixelFont.ts';
import { Palette } from '../core/Palette.ts';
import { GameState, ComponentType, SystemName } from '../core/Constants.ts';
import type { Entity } from '../core/Entity.ts';
import type { Engine } from '../core/Engine.ts';
import type { UISystem } from './UISystem.ts';
/**
* System responsible for managing game states (start, playing, paused, game over) and rendering menus.
*/
export class MenuSystem extends System {
gameState: GameState;
paused: boolean;
ctx: CanvasRenderingContext2D;
/**
* @param engine - The game engine instance
*/
constructor(engine: Engine) {
super(SystemName.MENU);
this.requiredComponents = [];
this.priority = 1;
this.engine = engine;
this.ctx = engine.ctx;
this.gameState = GameState.START;
this.paused = false;
}
/**
* Initialize the menu system and set up input listeners.
* @param engine - The game engine instance
*/
init(engine: Engine): void {
super.init(engine);
this.setupInput();
}
/**
* Set up keyboard event listeners for menu navigation and state toggling.
*/
setupInput(): void {
window.addEventListener('keydown', (e) => {
if (e.key === 'Escape' || e.key === 'p' || e.key === 'P') {
if (this.gameState === GameState.PLAYING) {
this.togglePause();
}
}
if (e.key === 'Enter' || e.key === ' ') {
if (this.gameState === GameState.START) {
this.startGame();
} else if (this.gameState === GameState.PAUSED) {
this.resumeGame();
} else if (this.gameState === GameState.GAME_OVER) {
this.restartGame();
}
}
});
}
/**
* Transition the game state to Game Over.
*/
showGameOver(): void {
this.gameState = GameState.GAME_OVER;
this.paused = true;
}
/**
* Restart the game by reloading the page.
*/
restartGame(): void {
window.location.reload();
}
/**
* Transition the game state from Start menu to Playing.
*/
startGame(): void {
this.gameState = GameState.PLAYING;
this.paused = false;
if (!this.engine.running) {
this.engine.start();
}
}
/**
* Toggle between Paused and Playing states.
*/
togglePause(): void {
if (this.gameState === GameState.PLAYING) {
this.gameState = GameState.PAUSED;
this.paused = true;
} else if (this.gameState === GameState.PAUSED) {
this.resumeGame();
}
}
/**
* Transition the game state from Paused to Playing.
*/
resumeGame(): void {
this.gameState = GameState.PLAYING;
this.paused = false;
}
/**
* Process menu logic (handled via events).
* @param _deltaTime - Time elapsed
* @param _entities - Filtered entities
*/
process(_deltaTime: number, _entities: Entity[]): void {}
/**
* Draw the current menu screen based on the game state.
*/
drawMenu(): void {
const ctx = this.ctx;
const width = this.engine.canvas.width;
const height = this.engine.canvas.height;
ctx.fillStyle = 'rgba(32, 21, 51, 0.8)';
ctx.fillRect(0, 0, width, height);
if (this.gameState === GameState.START) {
const title = 'SLIME GENESIS';
const titleW = PixelFont.getTextWidth(title, 2);
PixelFont.drawText(ctx, title, (width - titleW) / 2, height / 2 - 40, Palette.CYAN, 2);
const start = 'PRESS ENTER TO START';
const startW = PixelFont.getTextWidth(start, 1);
PixelFont.drawText(ctx, start, (width - startW) / 2, height / 2, Palette.WHITE, 1);
const instructions = [
'WASD: MOVE | CLICK: ATTACK',
'NUMS: SKILLS | ESC: PAUSE',
'COLLECT DNA TO EVOLVE',
];
instructions.forEach((line, i) => {
const lineW = PixelFont.getTextWidth(line, 1);
PixelFont.drawText(
ctx,
line,
(width - lineW) / 2,
height / 2 + 25 + i * 10,
Palette.ROYAL_BLUE,
1
);
});
} else if (this.gameState === GameState.PAUSED) {
const paused = 'PAUSED';
const pausedW = PixelFont.getTextWidth(paused, 2);
PixelFont.drawText(ctx, paused, (width - pausedW) / 2, 20, Palette.SKY_BLUE, 2);
const resume = 'PRESS ENTER TO RESUME';
const resumeW = PixelFont.getTextWidth(resume, 1);
PixelFont.drawText(ctx, resume, (width - resumeW) / 2, 45, Palette.WHITE, 1);
const player = this.engine.getEntities().find((e) => e.hasComponent(ComponentType.EVOLUTION));
const uiSystem = this.engine.systems.find((s) => s.name === SystemName.UI) as
| UISystem
| undefined;
if (player && uiSystem) {
uiSystem.drawStats(player, 20, 80);
uiSystem.drawSkillProgress(player, width - 110, 80);
uiSystem.drawMutations(player, 20, 185);
}
} else if (this.gameState === GameState.GAME_OVER) {
const dead = 'YOU PERISHED';
const deadW = PixelFont.getTextWidth(dead, 2);
PixelFont.drawText(ctx, dead, (width - deadW) / 2, height / 2 - 30, Palette.WHITE, 2);
const sub = 'YOUR DNA SUSTAINS THE CYCLE';
const subW = PixelFont.getTextWidth(sub, 1);
PixelFont.drawText(ctx, sub, (width - subW) / 2, height / 2 - 5, Palette.ROYAL_BLUE, 1);
const restart = 'PRESS ENTER TO REBORN';
const restartW = PixelFont.getTextWidth(restart, 1);
PixelFont.drawText(ctx, restart, (width - restartW) / 2, height / 2 + 30, Palette.CYAN, 1);
}
}
/**
* Get the current game state identifier.
* @returns The game state string
*/
getGameState(): GameState {
return this.gameState;
}
/**
* Check if the game is currently paused or in the start menu.
* @returns True if paused or at start
*/
isPaused(): boolean {
return this.paused || this.gameState === GameState.START;
}
}

View file

@ -1,62 +0,0 @@
import { System } from '../core/System.js';
export class MovementSystem extends System {
constructor() {
super('MovementSystem');
this.requiredComponents = ['Position', 'Velocity'];
this.priority = 10;
}
process(deltaTime, entities) {
entities.forEach(entity => {
const position = entity.getComponent('Position');
const velocity = entity.getComponent('Velocity');
const health = entity.getComponent('Health');
if (!position || !velocity) return;
// Check if this is a projectile
const isProjectile = health && health.isProjectile;
// Apply velocity with max speed limit (skip for projectiles)
if (!isProjectile) {
const speed = Math.sqrt(velocity.vx * velocity.vx + velocity.vy * velocity.vy);
if (speed > velocity.maxSpeed) {
const factor = velocity.maxSpeed / speed;
velocity.vx *= factor;
velocity.vy *= factor;
}
}
// Update position
position.x += velocity.vx * deltaTime;
position.y += velocity.vy * deltaTime;
// Apply friction (skip for projectiles - they should maintain speed)
if (!isProjectile) {
const friction = 0.9;
velocity.vx *= Math.pow(friction, deltaTime * 60);
velocity.vy *= Math.pow(friction, deltaTime * 60);
}
// Boundary checking
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;
}
});
}
}

View file

@ -0,0 +1,119 @@
import { System } from '../core/System.ts';
import { SystemName, ComponentType } from '../core/Constants.ts';
import type { Entity } from '../core/Entity.ts';
import type { Position } from '../components/Position.ts';
import type { Velocity } from '../components/Velocity.ts';
import type { Health } from '../components/Health.ts';
/**
* System responsible for moving entities based on their velocity and handling collisions.
*/
export class MovementSystem extends System {
constructor() {
super(SystemName.MOVEMENT);
this.requiredComponents = [ComponentType.POSITION, ComponentType.VELOCITY];
this.priority = 10;
}
/**
* Update the position of entities based on their velocity, applying friction and collision detection.
* @param deltaTime - Time elapsed since last frame in seconds
* @param entities - Entities matching system requirements
*/
process(deltaTime: number, entities: Entity[]): void {
entities.forEach((entity) => {
const position = entity.getComponent<Position>(ComponentType.POSITION);
const velocity = entity.getComponent<Velocity>(ComponentType.VELOCITY);
const health = entity.getComponent<Health>(ComponentType.HEALTH);
if (!position || !velocity) return;
if (velocity.lockTimer > 0) {
velocity.lockTimer -= deltaTime;
if (velocity.lockTimer <= 0) {
velocity.lockTimer = 0;
velocity.isLocked = false;
}
}
const isProjectile = health && health.isProjectile;
if (!isProjectile && !velocity.isLocked) {
const speed = Math.sqrt(velocity.vx * velocity.vx + velocity.vy * velocity.vy);
if (speed > velocity.maxSpeed) {
const factor = velocity.maxSpeed / speed;
velocity.vx *= factor;
velocity.vy *= factor;
}
}
const tileMap = this.engine.tileMap;
const nextX = position.x + velocity.vx * deltaTime;
if (tileMap && tileMap.isSolid(nextX, position.y)) {
velocity.vx = 0;
if (velocity.isLocked) {
velocity.lockTimer = 0;
velocity.isLocked = false;
}
} else {
position.x = nextX;
}
const nextY = position.y + velocity.vy * deltaTime;
if (tileMap && tileMap.isSolid(position.x, nextY)) {
velocity.vy = 0;
if (velocity.isLocked) {
velocity.lockTimer = 0;
velocity.isLocked = false;
}
} else {
position.y = nextY;
}
if (!isProjectile) {
const friction = velocity.isLocked ? 0.98 : 0.9;
velocity.vx *= Math.pow(friction, deltaTime * 60);
velocity.vy *= Math.pow(friction, deltaTime * 60);
}
if (tileMap) {
const mapWidth = tileMap.cols * tileMap.tileSize;
const mapHeight = tileMap.rows * tileMap.tileSize;
if (position.x < 0) {
position.x = 0;
velocity.vx = 0;
} else if (position.x > mapWidth) {
position.x = mapWidth;
velocity.vx = 0;
}
if (position.y < 0) {
position.y = 0;
velocity.vy = 0;
} else if (position.y > mapHeight) {
position.y = mapHeight;
velocity.vy = 0;
}
} else {
const canvas = this.engine.canvas;
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;
}
}
});
}
}

View file

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

Some files were not shown because too many files have changed in this diff Show more