Compare commits
16 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
109cee0052 | ||
| 2858898ec2 | |||
| c859e20ffc | |||
| 62e58f77ae | |||
|
|
b32ac22be8 | ||
| 71c8129f37 | |||
| 66719912ba | |||
| 5a24d6a2af | |||
| 2213f64e60 | |||
| 143072f0a0 | |||
|
|
9e640aa7be | ||
| 0d3bce4d4f | |||
| f01e6af519 | |||
| c582f2004e | |||
| 3db2bb9160 | |||
| e9db84abd1 |
121 changed files with 7731 additions and 3740 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -3,5 +3,3 @@ dist/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
*.log
|
*.log
|
||||||
.vite/
|
.vite/
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
7
.husky/pre-commit
Executable file
7
.husky/pre-commit
Executable 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
4
.lintstagedrc.json
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
{
|
||||||
|
"*.{ts,tsx,js,jsx}": ["eslint --fix", "prettier --write"],
|
||||||
|
"*.{json,css,md}": ["prettier --write"]
|
||||||
|
}
|
||||||
5
.prettierignore
Normal file
5
.prettierignore
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
*.min.js
|
||||||
|
package-lock.json
|
||||||
|
|
||||||
9
.prettierrc.json
Normal file
9
.prettierrc.json
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
{
|
||||||
|
"semi": true,
|
||||||
|
"trailingComma": "es5",
|
||||||
|
"singleQuote": true,
|
||||||
|
"printWidth": 100,
|
||||||
|
"tabWidth": 2,
|
||||||
|
"useTabs": false,
|
||||||
|
"arrowParens": "always"
|
||||||
|
}
|
||||||
134
README.md
134
README.md
|
|
@ -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
|
||||||
|
|
|
||||||
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
||||||
0.2.0
|
0.5.0
|
||||||
|
|
|
||||||
|
|
@ -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'],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -51,7 +51,7 @@
|
||||||
<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
882
package-lock.json
generated
File diff suppressed because it is too large
Load diff
15
package.json
15
package.json
|
|
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ name: slime
|
||||||
|
|
||||||
services:
|
services:
|
||||||
app:
|
app:
|
||||||
image: git.jusemon.com/jusemon/slime:0.2.0
|
image: git.jusemon.com/jusemon/slime:0.5.0
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
|
|
|
||||||
|
|
@ -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: 30, // Scaled down
|
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,
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
|
|
@ -1,53 +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 = 60; // Scaled for 320x240
|
|
||||||
this.chaseRadius = 120;
|
|
||||||
this.fleeRadius = 40;
|
|
||||||
|
|
||||||
// Behavior parameters
|
|
||||||
this.wanderSpeed = 20; // Slower wander
|
|
||||||
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
73
src/components/AI.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,55 +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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
87
src/components/Absorbable.ts
Normal file
87
src/components/Absorbable.ts
Normal 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
58
src/components/Camera.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
import { Component } from '../core/Component.ts';
|
||||||
|
import { ComponentType } from '../core/Constants.ts';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Component for camera/viewport management.
|
||||||
|
*/
|
||||||
|
export class Camera extends Component {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
targetX: number;
|
||||||
|
targetY: number;
|
||||||
|
smoothness: number;
|
||||||
|
bounds: {
|
||||||
|
minX: number;
|
||||||
|
maxX: number;
|
||||||
|
minY: number;
|
||||||
|
maxY: number;
|
||||||
|
};
|
||||||
|
viewportWidth: number;
|
||||||
|
viewportHeight: number;
|
||||||
|
|
||||||
|
constructor(viewportWidth: number, viewportHeight: number, smoothness = 0.15) {
|
||||||
|
super(ComponentType.CAMERA);
|
||||||
|
this.x = 0;
|
||||||
|
this.y = 0;
|
||||||
|
this.targetX = 0;
|
||||||
|
this.targetY = 0;
|
||||||
|
this.smoothness = smoothness;
|
||||||
|
this.viewportWidth = viewportWidth;
|
||||||
|
this.viewportHeight = viewportHeight;
|
||||||
|
this.bounds = {
|
||||||
|
minX: 0,
|
||||||
|
maxX: 0,
|
||||||
|
minY: 0,
|
||||||
|
maxY: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set camera bounds based on map size.
|
||||||
|
* @param mapWidth - Total map width in pixels
|
||||||
|
* @param mapHeight - Total map height in pixels
|
||||||
|
*/
|
||||||
|
setBounds(mapWidth: number, mapHeight: number): void {
|
||||||
|
this.bounds.minX = this.viewportWidth / 2;
|
||||||
|
this.bounds.maxX = mapWidth - this.viewportWidth / 2;
|
||||||
|
this.bounds.minY = this.viewportHeight / 2;
|
||||||
|
this.bounds.maxY = mapHeight - this.viewportHeight / 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clamp camera position to bounds.
|
||||||
|
*/
|
||||||
|
clampToBounds(): void {
|
||||||
|
this.x = Math.max(this.bounds.minX, Math.min(this.bounds.maxX, this.x));
|
||||||
|
this.y = Math.max(this.bounds.minY, Math.min(this.bounds.maxY, this.y));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,53 +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 = 15; // Melee range for pixel art
|
|
||||||
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
87
src/components/Combat.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,106 +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
140
src/components/Evolution.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
57
src/components/Health.ts
Normal 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
39
src/components/Intent.ts
Normal 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 = {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,72 +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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
97
src/components/Inventory.ts
Normal file
97
src/components/Inventory.ts
Normal 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
234
src/components/Music.ts
Normal file
|
|
@ -0,0 +1,234 @@
|
||||||
|
import { Component } from '../core/Component.ts';
|
||||||
|
import { ComponentType } from '../core/Constants.ts';
|
||||||
|
import type { Sequence } from '../core/Music.ts';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Component for managing background music and sound effects.
|
||||||
|
*/
|
||||||
|
export class Music extends Component {
|
||||||
|
sequences: Map<string, Sequence>;
|
||||||
|
currentSequence: Sequence | null;
|
||||||
|
activeSequences: Set<Sequence>;
|
||||||
|
volume: number;
|
||||||
|
enabled: boolean;
|
||||||
|
private sequenceChain: string[];
|
||||||
|
private currentChainIndex: number;
|
||||||
|
private sequenceVolumes: Map<Sequence, number>;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super(ComponentType.MUSIC);
|
||||||
|
this.sequences = new Map();
|
||||||
|
this.currentSequence = null;
|
||||||
|
this.activeSequences = new Set();
|
||||||
|
this.volume = 0.5;
|
||||||
|
this.enabled = true;
|
||||||
|
this.sequenceChain = [];
|
||||||
|
this.currentChainIndex = 0;
|
||||||
|
this.sequenceVolumes = new Map();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a music sequence.
|
||||||
|
* @param name - Unique identifier for the sequence
|
||||||
|
* @param sequence - The sequence instance
|
||||||
|
*/
|
||||||
|
addSequence(name: string, sequence: Sequence): void {
|
||||||
|
this.sequences.set(name, sequence);
|
||||||
|
if (sequence.gain) {
|
||||||
|
sequence.gain.gain.value = this.volume;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Play a sequence by name.
|
||||||
|
* @param name - The sequence identifier
|
||||||
|
*/
|
||||||
|
playSequence(name: string): void {
|
||||||
|
if (!this.enabled) return;
|
||||||
|
|
||||||
|
const sequence = this.sequences.get(name);
|
||||||
|
if (sequence) {
|
||||||
|
this.stop();
|
||||||
|
this.currentSequence = sequence;
|
||||||
|
if (sequence.gain) {
|
||||||
|
sequence.gain.gain.value = this.volume;
|
||||||
|
}
|
||||||
|
sequence.play();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Play multiple sequences simultaneously (polyphony).
|
||||||
|
* @param sequenceConfigs - Array of configs with name, optional delay in beats, and optional loop
|
||||||
|
*/
|
||||||
|
playSequences(sequenceConfigs: Array<{ name: string; delay?: number; loop?: boolean }>): void {
|
||||||
|
if (!this.enabled || sequenceConfigs.length === 0) return;
|
||||||
|
|
||||||
|
const firstSeq = this.sequences.get(sequenceConfigs[0].name);
|
||||||
|
if (!firstSeq || !firstSeq.ac) return;
|
||||||
|
|
||||||
|
const ac = firstSeq.ac;
|
||||||
|
const when = ac.currentTime;
|
||||||
|
const tempo = firstSeq.tempo || 120;
|
||||||
|
|
||||||
|
sequenceConfigs.forEach((config) => {
|
||||||
|
const sequence = this.sequences.get(config.name);
|
||||||
|
if (!sequence) return;
|
||||||
|
|
||||||
|
if (config.loop !== undefined) {
|
||||||
|
sequence.loop = config.loop;
|
||||||
|
}
|
||||||
|
if (sequence.gain) {
|
||||||
|
sequence.gain.gain.value = this.volume;
|
||||||
|
}
|
||||||
|
|
||||||
|
const delaySeconds = config.delay ? (60 / tempo) * config.delay : 0;
|
||||||
|
sequence.play(when + delaySeconds);
|
||||||
|
this.activeSequences.add(sequence);
|
||||||
|
|
||||||
|
if (!this.currentSequence) {
|
||||||
|
this.currentSequence = sequence;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Chain multiple sequences together in order (sequential playback).
|
||||||
|
* @param sequenceNames - Array of sequence names to play in order
|
||||||
|
*/
|
||||||
|
chainSequences(sequenceNames: string[]): void {
|
||||||
|
if (!this.enabled || sequenceNames.length === 0) return;
|
||||||
|
|
||||||
|
this.stop();
|
||||||
|
this.sequenceChain = sequenceNames;
|
||||||
|
this.currentChainIndex = 0;
|
||||||
|
|
||||||
|
this.playNextInChain();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Play the next sequence in the chain.
|
||||||
|
*/
|
||||||
|
private playNextInChain(): void {
|
||||||
|
if (!this.enabled || this.sequenceChain.length === 0) return;
|
||||||
|
|
||||||
|
const seqName = this.sequenceChain[this.currentChainIndex];
|
||||||
|
const sequence = this.sequences.get(seqName);
|
||||||
|
if (!sequence) return;
|
||||||
|
|
||||||
|
this.currentSequence = sequence;
|
||||||
|
sequence.loop = false;
|
||||||
|
if (sequence.gain) {
|
||||||
|
sequence.gain.gain.value = this.volume;
|
||||||
|
}
|
||||||
|
|
||||||
|
sequence.play();
|
||||||
|
if (sequence.osc) {
|
||||||
|
const nextIndex = (this.currentChainIndex + 1) % this.sequenceChain.length;
|
||||||
|
sequence.osc.onended = () => {
|
||||||
|
if (this.enabled) {
|
||||||
|
this.currentChainIndex = nextIndex;
|
||||||
|
this.playNextInChain();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop current playback.
|
||||||
|
*/
|
||||||
|
stop(): void {
|
||||||
|
this.activeSequences.forEach((seq) => {
|
||||||
|
seq.stop();
|
||||||
|
});
|
||||||
|
this.activeSequences.clear();
|
||||||
|
if (this.currentSequence) {
|
||||||
|
this.currentSequence.stop();
|
||||||
|
this.currentSequence = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the volume (0.0 to 1.0).
|
||||||
|
* @param volume - Volume level
|
||||||
|
*/
|
||||||
|
setVolume(volume: number): void {
|
||||||
|
this.volume = Math.max(0, Math.min(1, volume));
|
||||||
|
if (this.currentSequence && this.currentSequence.gain) {
|
||||||
|
this.currentSequence.gain.gain.value = this.volume;
|
||||||
|
}
|
||||||
|
this.sequences.forEach((seq) => {
|
||||||
|
if (seq.gain) {
|
||||||
|
seq.gain.gain.value = this.volume;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enable or disable music playback.
|
||||||
|
* @param enabled - Whether music should be enabled
|
||||||
|
*/
|
||||||
|
setEnabled(enabled: boolean): void {
|
||||||
|
this.enabled = enabled;
|
||||||
|
if (!enabled) {
|
||||||
|
this.stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pause all active sequences by setting their gain to 0.
|
||||||
|
*/
|
||||||
|
pause(): void {
|
||||||
|
this.activeSequences.forEach((seq) => {
|
||||||
|
if (seq.gain) {
|
||||||
|
const currentVolume = seq.gain.gain.value;
|
||||||
|
this.sequenceVolumes.set(seq, currentVolume);
|
||||||
|
seq.gain.gain.value = 0;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (this.currentSequence && this.currentSequence.gain) {
|
||||||
|
const currentVolume = this.currentSequence.gain.gain.value;
|
||||||
|
this.sequenceVolumes.set(this.currentSequence, currentVolume);
|
||||||
|
this.currentSequence.gain.gain.value = 0;
|
||||||
|
}
|
||||||
|
this.sequences.forEach((seq) => {
|
||||||
|
if (seq.gain && seq.gain.gain.value > 0) {
|
||||||
|
const currentVolume = seq.gain.gain.value;
|
||||||
|
this.sequenceVolumes.set(seq, currentVolume);
|
||||||
|
seq.gain.gain.value = 0;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resume all active sequences by restoring their volume.
|
||||||
|
*/
|
||||||
|
resume(): void {
|
||||||
|
this.activeSequences.forEach((seq) => {
|
||||||
|
if (seq.gain) {
|
||||||
|
const savedVolume = this.sequenceVolumes.get(seq);
|
||||||
|
if (savedVolume !== undefined) {
|
||||||
|
seq.gain.gain.value = savedVolume;
|
||||||
|
} else {
|
||||||
|
seq.gain.gain.value = this.volume;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (this.currentSequence && this.currentSequence.gain) {
|
||||||
|
const savedVolume = this.sequenceVolumes.get(this.currentSequence);
|
||||||
|
if (savedVolume !== undefined) {
|
||||||
|
this.currentSequence.gain.gain.value = savedVolume;
|
||||||
|
} else {
|
||||||
|
this.currentSequence.gain.gain.value = this.volume;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.sequences.forEach((seq) => {
|
||||||
|
if (seq.gain) {
|
||||||
|
const savedVolume = this.sequenceVolumes.get(seq);
|
||||||
|
if (savedVolume !== undefined) {
|
||||||
|
seq.gain.gain.value = savedVolume;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,12 +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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
23
src/components/Position.ts
Normal file
23
src/components/Position.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,46 +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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
56
src/components/SkillProgress.ts
Normal file
56
src/components/SkillProgress.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,70 +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
86
src/components/Skills.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
73
src/components/SoundEffects.ts
Normal file
73
src/components/SoundEffects.ts
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
import { Component } from '../core/Component.ts';
|
||||||
|
import { ComponentType } from '../core/Constants.ts';
|
||||||
|
import type { Sequence } from '../core/Music.ts';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Component for managing sound effects.
|
||||||
|
* Sound effects are short, one-shot audio sequences.
|
||||||
|
*/
|
||||||
|
export class SoundEffects extends Component {
|
||||||
|
sounds: Map<string, Sequence>;
|
||||||
|
volume: number;
|
||||||
|
enabled: boolean;
|
||||||
|
audioContext: AudioContext | null;
|
||||||
|
|
||||||
|
constructor(audioContext?: AudioContext) {
|
||||||
|
super(ComponentType.SOUND_EFFECTS);
|
||||||
|
this.sounds = new Map();
|
||||||
|
this.volume = 0.15; // Reduced default volume
|
||||||
|
this.enabled = true;
|
||||||
|
this.audioContext = audioContext || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a sound effect sequence.
|
||||||
|
* @param name - Unique identifier for the sound
|
||||||
|
* @param sequence - The sequence instance (should be short, non-looping)
|
||||||
|
*/
|
||||||
|
addSound(name: string, sequence: Sequence): void {
|
||||||
|
sequence.loop = false; // SFX should never loop
|
||||||
|
this.sounds.set(name, sequence);
|
||||||
|
if (sequence.gain) {
|
||||||
|
sequence.gain.gain.value = this.volume;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Play a sound effect by name.
|
||||||
|
* @param name - The sound identifier
|
||||||
|
*/
|
||||||
|
play(name: string): void {
|
||||||
|
if (!this.enabled) return;
|
||||||
|
|
||||||
|
const sound = this.sounds.get(name);
|
||||||
|
if (sound) {
|
||||||
|
sound.stop();
|
||||||
|
if (sound.gain) {
|
||||||
|
sound.gain.gain.value = this.volume;
|
||||||
|
}
|
||||||
|
sound.play();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the volume (0.0 to 1.0).
|
||||||
|
* @param volume - Volume level
|
||||||
|
*/
|
||||||
|
setVolume(volume: number): void {
|
||||||
|
this.volume = Math.max(0, Math.min(1, volume));
|
||||||
|
this.sounds.forEach((seq) => {
|
||||||
|
if (seq.gain) {
|
||||||
|
seq.gain.gain.value = this.volume;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enable or disable sound effects.
|
||||||
|
* @param enabled - Whether sound effects should be enabled
|
||||||
|
*/
|
||||||
|
setEnabled(enabled: boolean): void {
|
||||||
|
this.enabled = enabled;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,21 +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.animationState = 'idle'; // 'idle', 'walk'
|
|
||||||
this.animationSpeed = 4; // frames per second
|
|
||||||
this.morphAmount = 0; // Legacy slime morphing
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
43
src/components/Sprite.ts
Normal file
43
src/components/Sprite.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,56 +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
68
src/components/Stats.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,49 +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
80
src/components/Stealth.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,12 +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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
26
src/components/Velocity.ts
Normal file
26
src/components/Velocity.ts
Normal 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
156
src/config/MusicConfig.ts
Normal file
|
|
@ -0,0 +1,156 @@
|
||||||
|
import { Sequence } from '../core/Music.ts';
|
||||||
|
import type { Music } from '../components/Music.ts';
|
||||||
|
import type { MusicSystem } from '../systems/MusicSystem.ts';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configure and setup background music.
|
||||||
|
* @param music - Music component instance
|
||||||
|
* @param audioCtx - AudioContext instance
|
||||||
|
*/
|
||||||
|
export function setupMusic(music: Music, audioCtx: AudioContext): void {
|
||||||
|
const tempo = 132;
|
||||||
|
|
||||||
|
const lead = new Sequence(audioCtx, tempo, [
|
||||||
|
'F4 e',
|
||||||
|
'Ab4 e',
|
||||||
|
'C5 e',
|
||||||
|
'F5 e',
|
||||||
|
'C5 e',
|
||||||
|
'Ab4 e',
|
||||||
|
'F4 e',
|
||||||
|
'C4 e',
|
||||||
|
'F4 e',
|
||||||
|
'Ab4 e',
|
||||||
|
'C5 e',
|
||||||
|
'F5 e',
|
||||||
|
'C5 e',
|
||||||
|
'Ab4 e',
|
||||||
|
'F4 e',
|
||||||
|
'C4 e',
|
||||||
|
'G4 e',
|
||||||
|
'Bb4 e',
|
||||||
|
'D5 e',
|
||||||
|
'G5 e',
|
||||||
|
'D5 e',
|
||||||
|
'Bb4 e',
|
||||||
|
'G4 e',
|
||||||
|
'D4 e',
|
||||||
|
'F4 e',
|
||||||
|
'Ab4 e',
|
||||||
|
'C5 e',
|
||||||
|
'F5 e',
|
||||||
|
'C5 e',
|
||||||
|
'Ab4 e',
|
||||||
|
'F4 e',
|
||||||
|
'C4 e',
|
||||||
|
]);
|
||||||
|
lead.staccato = 0.1;
|
||||||
|
lead.smoothing = 0.3;
|
||||||
|
lead.waveType = 'triangle';
|
||||||
|
lead.loop = true;
|
||||||
|
if (lead.gain) {
|
||||||
|
lead.gain.gain.value = 0.8;
|
||||||
|
}
|
||||||
|
music.addSequence('lead', lead);
|
||||||
|
|
||||||
|
const harmony = new Sequence(audioCtx, tempo, [
|
||||||
|
'C4 e',
|
||||||
|
'Eb4 e',
|
||||||
|
'F4 e',
|
||||||
|
'Ab4 e',
|
||||||
|
'F4 e',
|
||||||
|
'Eb4 e',
|
||||||
|
'C4 e',
|
||||||
|
'Ab3 e',
|
||||||
|
'C4 e',
|
||||||
|
'Eb4 e',
|
||||||
|
'F4 e',
|
||||||
|
'Ab4 e',
|
||||||
|
'F4 e',
|
||||||
|
'Eb4 e',
|
||||||
|
'C4 e',
|
||||||
|
'Ab3 e',
|
||||||
|
'D4 e',
|
||||||
|
'F4 e',
|
||||||
|
'G4 e',
|
||||||
|
'Bb4 e',
|
||||||
|
'G4 e',
|
||||||
|
'F4 e',
|
||||||
|
'D4 e',
|
||||||
|
'Bb3 e',
|
||||||
|
'C4 e',
|
||||||
|
'Eb4 e',
|
||||||
|
'F4 e',
|
||||||
|
'Ab4 e',
|
||||||
|
'F4 e',
|
||||||
|
'Eb4 e',
|
||||||
|
'C4 e',
|
||||||
|
'Ab3 e',
|
||||||
|
]);
|
||||||
|
harmony.staccato = 0.15;
|
||||||
|
harmony.smoothing = 0.4;
|
||||||
|
harmony.waveType = 'triangle';
|
||||||
|
harmony.loop = true;
|
||||||
|
if (harmony.gain) {
|
||||||
|
harmony.gain.gain.value = 0.6;
|
||||||
|
}
|
||||||
|
music.addSequence('harmony', harmony);
|
||||||
|
|
||||||
|
const bass = new Sequence(audioCtx, tempo, [
|
||||||
|
'F2 q',
|
||||||
|
'C3 q',
|
||||||
|
'F2 q',
|
||||||
|
'C3 q',
|
||||||
|
'G2 q',
|
||||||
|
'D3 q',
|
||||||
|
'G2 q',
|
||||||
|
'D3 q',
|
||||||
|
'F2 q',
|
||||||
|
'C3 q',
|
||||||
|
'F2 q',
|
||||||
|
'C3 q',
|
||||||
|
]);
|
||||||
|
bass.staccato = 0.05;
|
||||||
|
bass.smoothing = 0.5;
|
||||||
|
bass.waveType = 'triangle';
|
||||||
|
bass.loop = true;
|
||||||
|
if (bass.gain) {
|
||||||
|
bass.gain.gain.value = 0.7;
|
||||||
|
}
|
||||||
|
if (bass.bass) {
|
||||||
|
bass.bass.gain.value = 4;
|
||||||
|
bass.bass.frequency.value = 80;
|
||||||
|
}
|
||||||
|
music.addSequence('bass', bass);
|
||||||
|
|
||||||
|
music.playSequences([
|
||||||
|
{ name: 'lead', loop: true },
|
||||||
|
{ name: 'harmony', loop: true },
|
||||||
|
{ name: 'bass', loop: true },
|
||||||
|
]);
|
||||||
|
music.setVolume(0.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setup music event handlers for canvas interaction.
|
||||||
|
* @param music - Music component instance
|
||||||
|
* @param musicSystem - MusicSystem instance
|
||||||
|
* @param canvas - Canvas element
|
||||||
|
*/
|
||||||
|
export function setupMusicHandlers(
|
||||||
|
music: Music,
|
||||||
|
musicSystem: MusicSystem,
|
||||||
|
canvas: HTMLCanvasElement
|
||||||
|
): void {
|
||||||
|
canvas.addEventListener('click', () => {
|
||||||
|
musicSystem.resumeAudioContext();
|
||||||
|
if (music.enabled && music.activeSequences.size === 0) {
|
||||||
|
music.playSequences([
|
||||||
|
{ name: 'lead', loop: true },
|
||||||
|
{ name: 'harmony', loop: true },
|
||||||
|
{ name: 'bass', loop: true },
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
canvas.focus();
|
||||||
|
});
|
||||||
|
}
|
||||||
35
src/config/SFXConfig.ts
Normal file
35
src/config/SFXConfig.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
import { Sequence } from '../core/Music.ts';
|
||||||
|
import type { SoundEffects } from '../components/SoundEffects.ts';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configure and setup sound effects.
|
||||||
|
* @param sfx - SoundEffects component instance
|
||||||
|
* @param ac - AudioContext instance
|
||||||
|
*/
|
||||||
|
export function setupSFX(sfx: SoundEffects, ac: AudioContext): void {
|
||||||
|
const attackSound = new Sequence(ac, 120, ['C5 s']);
|
||||||
|
attackSound.staccato = 0.8;
|
||||||
|
sfx.addSound('attack', attackSound);
|
||||||
|
|
||||||
|
const absorbSound = new Sequence(ac, 120, ['G4 e']);
|
||||||
|
absorbSound.staccato = 0.5;
|
||||||
|
sfx.addSound('absorb', absorbSound);
|
||||||
|
|
||||||
|
const skillSound = new Sequence(ac, 120, ['A4 e']);
|
||||||
|
skillSound.staccato = 0.6;
|
||||||
|
sfx.addSound('skill', skillSound);
|
||||||
|
|
||||||
|
const damageSound = new Sequence(ac, 120, ['F4 s']);
|
||||||
|
damageSound.staccato = 0.8;
|
||||||
|
sfx.addSound('damage', damageSound);
|
||||||
|
|
||||||
|
const shootSound = new Sequence(ac, 120, ['C5 s']);
|
||||||
|
shootSound.staccato = 0.9;
|
||||||
|
sfx.addSound('shoot', shootSound);
|
||||||
|
|
||||||
|
const impactSound = new Sequence(ac, 120, ['G4 s']);
|
||||||
|
impactSound.staccato = 0.7;
|
||||||
|
sfx.addSound('impact', impactSound);
|
||||||
|
|
||||||
|
sfx.setVolume(0.02);
|
||||||
|
}
|
||||||
102
src/core/ColorSampler.ts
Normal file
102
src/core/ColorSampler.ts
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
import type { TileMap } from './TileMap.ts';
|
||||||
|
import { Palette } from './Palette.ts';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Utility for sampling colors from the background and tile map.
|
||||||
|
*/
|
||||||
|
export class ColorSampler {
|
||||||
|
private static cache: Map<string, string> = new Map();
|
||||||
|
private static cacheFrame: number = 0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sample the dominant color from a region around a position based on tile map and background.
|
||||||
|
* @param tileMap - The tile map to sample from
|
||||||
|
* @param x - Center X coordinate in world space
|
||||||
|
* @param y - Center Y coordinate in world space
|
||||||
|
* @param radius - Sampling radius in pixels
|
||||||
|
* @returns Dominant color as hex string (e.g., '#1a1a2e')
|
||||||
|
*/
|
||||||
|
static sampleDominantColor(
|
||||||
|
tileMap: TileMap | null,
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
radius: number
|
||||||
|
): string {
|
||||||
|
const cacheKey = `${Math.floor(x / 20)}_${Math.floor(y / 20)}`;
|
||||||
|
const currentFrame = Math.floor(Date.now() / 200);
|
||||||
|
|
||||||
|
if (currentFrame !== this.cacheFrame) {
|
||||||
|
this.cache.clear();
|
||||||
|
this.cacheFrame = currentFrame;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.cache.has(cacheKey)) {
|
||||||
|
return this.cache.get(cacheKey) || Palette.VOID;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!tileMap) {
|
||||||
|
return Palette.VOID;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tileSize = tileMap.tileSize;
|
||||||
|
const startCol = Math.max(0, Math.floor((x - radius) / tileSize));
|
||||||
|
const endCol = Math.min(tileMap.cols, Math.ceil((x + radius) / tileSize));
|
||||||
|
const startRow = Math.max(0, Math.floor((y - radius) / tileSize));
|
||||||
|
const endRow = Math.min(tileMap.rows, Math.ceil((y + radius) / tileSize));
|
||||||
|
|
||||||
|
const colorCounts: Map<string, number> = new Map();
|
||||||
|
let totalTiles = 0;
|
||||||
|
|
||||||
|
for (let r = startRow; r < endRow; r++) {
|
||||||
|
for (let c = startCol; c < endCol; c++) {
|
||||||
|
const tileType = tileMap.getTile(c, r);
|
||||||
|
let color: string;
|
||||||
|
|
||||||
|
if (tileType === 1) {
|
||||||
|
color = Palette.DARK_BLUE;
|
||||||
|
} else {
|
||||||
|
const distFromCenter = Math.sqrt(
|
||||||
|
Math.pow(c * tileSize - x, 2) + Math.pow(r * tileSize - y, 2)
|
||||||
|
);
|
||||||
|
if (distFromCenter < radius) {
|
||||||
|
const noise = Math.sin(c * 0.1 + r * 0.1) * 0.1;
|
||||||
|
if (Math.random() < 0.3 + noise) {
|
||||||
|
color = Palette.DARKER_BLUE;
|
||||||
|
} else {
|
||||||
|
color = Palette.VOID;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
colorCounts.set(color, (colorCounts.get(color) || 0) + 1);
|
||||||
|
totalTiles++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (totalTiles === 0) {
|
||||||
|
return Palette.VOID;
|
||||||
|
}
|
||||||
|
|
||||||
|
let dominantColor = Palette.VOID;
|
||||||
|
let maxCount = 0;
|
||||||
|
|
||||||
|
colorCounts.forEach((count, color) => {
|
||||||
|
if (count > maxCount) {
|
||||||
|
maxCount = count;
|
||||||
|
dominantColor = color;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.cache.set(cacheKey, dominantColor);
|
||||||
|
return dominantColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear the color sampling cache.
|
||||||
|
*/
|
||||||
|
static clearCache(): void {
|
||||||
|
this.cache.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,15 +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
25
src/core/Component.ts
Normal 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
88
src/core/Constants.ts
Normal 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',
|
||||||
|
}
|
||||||
|
|
@ -1,150 +0,0 @@
|
||||||
import { System } from './System.js';
|
|
||||||
import { Entity } from './Entity.js';
|
|
||||||
import { EventBus } from './EventBus.js';
|
|
||||||
import { LevelLoader } from './LevelLoader.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 internal resolution (low-res for pixel art)
|
|
||||||
this.canvas.width = 320;
|
|
||||||
this.canvas.height = 240;
|
|
||||||
|
|
||||||
// Apply CSS for sharp pixel scaling
|
|
||||||
this.canvas.style.imageRendering = 'pixelated'; // Standard
|
|
||||||
// Fallbacks for other browsers if needed (mostly covered by modern standards, but good to be safe)
|
|
||||||
this.canvas.style.imageRendering = '-moz-crisp-edges';
|
|
||||||
this.canvas.style.imageRendering = 'crisp-edges';
|
|
||||||
this.ctx.imageSmoothingEnabled = false;
|
|
||||||
|
|
||||||
// Game state
|
|
||||||
this.deltaTime = 0;
|
|
||||||
|
|
||||||
// Initialize standard map (320x240 / 16px tiles = 20x15)
|
|
||||||
this.tileMap = LevelLoader.loadSimpleLevel(20, 15, 16);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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' || gameState === 'gameOver';
|
|
||||||
|
|
||||||
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
171
src/core/Engine.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,59 +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
82
src/core/Entity.ts
Normal 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
104
src/core/EventBus.ts
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
import { TileMap } from './TileMap.js';
|
|
||||||
|
|
||||||
export class LevelLoader {
|
|
||||||
static loadSimpleLevel(cols, rows, tileSize) {
|
|
||||||
const map = new TileMap(cols, rows, tileSize);
|
|
||||||
|
|
||||||
// Create a box arena for testing
|
|
||||||
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); // Wall
|
|
||||||
} else {
|
|
||||||
// Random obstacles
|
|
||||||
if (Math.random() < 0.1) {
|
|
||||||
map.setTile(c, r, 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return map;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
125
src/core/LevelLoader.ts
Normal file
125
src/core/LevelLoader.ts
Normal 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
269
src/core/Music.ts
Normal file
|
|
@ -0,0 +1,269 @@
|
||||||
|
/**
|
||||||
|
* Note class - represents a single musical note
|
||||||
|
*/
|
||||||
|
export class Note {
|
||||||
|
frequency: number;
|
||||||
|
duration: number;
|
||||||
|
|
||||||
|
constructor(str: string) {
|
||||||
|
const couple = str.split(/\s+/);
|
||||||
|
this.frequency = Note.getFrequency(couple[0]) || 0;
|
||||||
|
this.duration = Note.getDuration(couple[1]) || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a note name (e.g. 'A4') to a frequency (e.g. 440.00)
|
||||||
|
*/
|
||||||
|
static getFrequency(name: string): number {
|
||||||
|
const enharmonics = 'B#-C|C#-Db|D|D#-Eb|E-Fb|E#-F|F#-Gb|G|G#-Ab|A|A#-Bb|B-Cb';
|
||||||
|
const middleC = 440 * Math.pow(Math.pow(2, 1 / 12), -9);
|
||||||
|
const octaveOffset = 4;
|
||||||
|
const num = /(\d+)/;
|
||||||
|
const offsets: Record<string, number> = {};
|
||||||
|
|
||||||
|
enharmonics.split('|').forEach((val, i) => {
|
||||||
|
val.split('-').forEach((note) => {
|
||||||
|
offsets[note] = i;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const couple = name.split(num);
|
||||||
|
const distance = offsets[couple[0]] ?? 0;
|
||||||
|
const octaveDiff = parseInt(couple[1] || String(octaveOffset), 10) - octaveOffset;
|
||||||
|
const freq = middleC * Math.pow(Math.pow(2, 1 / 12), distance);
|
||||||
|
return freq * Math.pow(2, octaveDiff);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a duration string (e.g. 'q') to a number (e.g. 1)
|
||||||
|
*/
|
||||||
|
static getDuration(symbol: string): number {
|
||||||
|
const numeric = /^[0-9.]+$/;
|
||||||
|
if (numeric.test(symbol)) {
|
||||||
|
return parseFloat(symbol);
|
||||||
|
}
|
||||||
|
return symbol
|
||||||
|
.toLowerCase()
|
||||||
|
.split('')
|
||||||
|
.reduce((prev, curr) => {
|
||||||
|
return (
|
||||||
|
prev +
|
||||||
|
(curr === 'w'
|
||||||
|
? 4
|
||||||
|
: curr === 'h'
|
||||||
|
? 2
|
||||||
|
: curr === 'q'
|
||||||
|
? 1
|
||||||
|
: curr === 'e'
|
||||||
|
? 0.5
|
||||||
|
: curr === 's'
|
||||||
|
? 0.25
|
||||||
|
: 0)
|
||||||
|
);
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sequence class - manages playback of musical sequences
|
||||||
|
*/
|
||||||
|
export class Sequence {
|
||||||
|
ac: AudioContext;
|
||||||
|
tempo: number;
|
||||||
|
loop: boolean;
|
||||||
|
smoothing: number;
|
||||||
|
staccato: number;
|
||||||
|
notes: Note[];
|
||||||
|
gain: GainNode;
|
||||||
|
bass: BiquadFilterNode | null;
|
||||||
|
mid: BiquadFilterNode | null;
|
||||||
|
treble: BiquadFilterNode | null;
|
||||||
|
waveType: OscillatorType | 'custom';
|
||||||
|
customWave?: [Float32Array, Float32Array];
|
||||||
|
osc: OscillatorNode | null;
|
||||||
|
|
||||||
|
constructor(ac?: AudioContext, tempo = 120, arr?: (Note | string)[]) {
|
||||||
|
this.ac = ac || new AudioContext();
|
||||||
|
this.tempo = tempo;
|
||||||
|
this.loop = true;
|
||||||
|
this.smoothing = 0;
|
||||||
|
this.staccato = 0;
|
||||||
|
this.notes = [];
|
||||||
|
this.bass = null;
|
||||||
|
this.mid = null;
|
||||||
|
this.treble = null;
|
||||||
|
this.osc = null;
|
||||||
|
this.waveType = 'square';
|
||||||
|
this.gain = this.ac.createGain();
|
||||||
|
this.createFxNodes();
|
||||||
|
if (arr) {
|
||||||
|
this.push(...arr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create gain and EQ nodes, then connect them
|
||||||
|
*/
|
||||||
|
createFxNodes(): void {
|
||||||
|
const eq: Array<[string, number]> = [
|
||||||
|
['bass', 100],
|
||||||
|
['mid', 1000],
|
||||||
|
['treble', 2500],
|
||||||
|
];
|
||||||
|
let prev: AudioNode = this.gain;
|
||||||
|
|
||||||
|
eq.forEach((config) => {
|
||||||
|
const filter = this.ac.createBiquadFilter();
|
||||||
|
filter.type = 'peaking';
|
||||||
|
filter.frequency.value = config[1];
|
||||||
|
prev.connect(filter);
|
||||||
|
prev = filter;
|
||||||
|
|
||||||
|
if (config[0] === 'bass') {
|
||||||
|
this.bass = filter;
|
||||||
|
} else if (config[0] === 'mid') {
|
||||||
|
this.mid = filter;
|
||||||
|
} else if (config[0] === 'treble') {
|
||||||
|
this.treble = filter;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
prev.connect(this.ac.destination);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Accepts Note instances or strings (e.g. 'A4 e')
|
||||||
|
*/
|
||||||
|
push(...notes: (Note | string)[]): this {
|
||||||
|
notes.forEach((note) => {
|
||||||
|
this.notes.push(note instanceof Note ? note : new Note(note));
|
||||||
|
});
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a custom waveform
|
||||||
|
*/
|
||||||
|
createCustomWave(real: number[], imag?: number[]): void {
|
||||||
|
if (!imag) {
|
||||||
|
imag = real;
|
||||||
|
}
|
||||||
|
this.waveType = 'custom';
|
||||||
|
this.customWave = [new Float32Array(real), new Float32Array(imag)];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recreate the oscillator node (happens on every play)
|
||||||
|
*/
|
||||||
|
createOscillator(): this {
|
||||||
|
this.stop();
|
||||||
|
this.osc = this.ac.createOscillator();
|
||||||
|
|
||||||
|
if (this.customWave) {
|
||||||
|
this.osc.setPeriodicWave(this.ac.createPeriodicWave(this.customWave[0], this.customWave[1]));
|
||||||
|
} else {
|
||||||
|
this.osc.type = this.waveType === 'custom' ? 'square' : this.waveType;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.gain) {
|
||||||
|
this.osc.connect(this.gain);
|
||||||
|
}
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schedule a note to play at the given time
|
||||||
|
*/
|
||||||
|
scheduleNote(index: number, when: number): number {
|
||||||
|
const duration = (60 / this.tempo) * this.notes[index].duration;
|
||||||
|
const cutoff = duration * (1 - (this.staccato || 0));
|
||||||
|
|
||||||
|
this.setFrequency(this.notes[index].frequency, when);
|
||||||
|
|
||||||
|
if (this.smoothing && this.notes[index].frequency) {
|
||||||
|
this.slide(index, when, cutoff);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.setFrequency(0, when + cutoff);
|
||||||
|
return when + duration;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the next note
|
||||||
|
*/
|
||||||
|
getNextNote(index: number): Note {
|
||||||
|
return this.notes[index < this.notes.length - 1 ? index + 1 : 0];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How long do we wait before beginning the slide?
|
||||||
|
*/
|
||||||
|
getSlideStartDelay(duration: number): number {
|
||||||
|
return duration - Math.min(duration, (60 / this.tempo) * this.smoothing);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Slide the note at index into the next note
|
||||||
|
*/
|
||||||
|
slide(index: number, when: number, cutoff: number): this {
|
||||||
|
const next = this.getNextNote(index);
|
||||||
|
const start = this.getSlideStartDelay(cutoff);
|
||||||
|
this.setFrequency(this.notes[index].frequency, when + start);
|
||||||
|
this.rampFrequency(next.frequency, when + cutoff);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set frequency at time
|
||||||
|
*/
|
||||||
|
setFrequency(freq: number, when: number): this {
|
||||||
|
if (this.osc) {
|
||||||
|
this.osc.frequency.setValueAtTime(freq, when);
|
||||||
|
}
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ramp to frequency at time
|
||||||
|
*/
|
||||||
|
rampFrequency(freq: number, when: number): this {
|
||||||
|
if (this.osc) {
|
||||||
|
this.osc.frequency.linearRampToValueAtTime(freq, when);
|
||||||
|
}
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run through all notes in the sequence and schedule them
|
||||||
|
*/
|
||||||
|
play(when?: number): this {
|
||||||
|
const startTime = typeof when === 'number' ? when : this.ac.currentTime;
|
||||||
|
|
||||||
|
this.createOscillator();
|
||||||
|
if (this.osc) {
|
||||||
|
this.osc.start(startTime);
|
||||||
|
|
||||||
|
let currentTime = startTime;
|
||||||
|
this.notes.forEach((_note, i) => {
|
||||||
|
currentTime = this.scheduleNote(i, currentTime);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.osc.stop(currentTime);
|
||||||
|
this.osc.onended = this.loop ? () => this.play(currentTime) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop playback
|
||||||
|
*/
|
||||||
|
stop(): this {
|
||||||
|
if (this.osc) {
|
||||||
|
this.osc.onended = null;
|
||||||
|
this.osc.disconnect();
|
||||||
|
this.osc = null;
|
||||||
|
}
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,27 +0,0 @@
|
||||||
/**
|
|
||||||
* Limited 7-color palette for the game
|
|
||||||
*/
|
|
||||||
export const Palette = {
|
|
||||||
WHITE: '#ffffff', // Highlights, UI Text
|
|
||||||
CYAN: '#0ce6f2', // Energy, Slime core
|
|
||||||
SKY_BLUE: '#0098db', // Water, Friendly elements
|
|
||||||
ROYAL_BLUE: '#1e579c', // Shadows, Depth
|
|
||||||
DARK_BLUE: '#203562', // Walls, Obstacles
|
|
||||||
DARKER_BLUE: '#252446', // Background details
|
|
||||||
VOID: '#201533', // Void, Deep Background
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get all colors as an array
|
|
||||||
*/
|
|
||||||
getAll() {
|
|
||||||
return [
|
|
||||||
this.WHITE,
|
|
||||||
this.CYAN,
|
|
||||||
this.SKY_BLUE,
|
|
||||||
this.ROYAL_BLUE,
|
|
||||||
this.DARK_BLUE,
|
|
||||||
this.DARKER_BLUE,
|
|
||||||
this.VOID
|
|
||||||
];
|
|
||||||
}
|
|
||||||
};
|
|
||||||
35
src/core/Palette.ts
Normal file
35
src/core/Palette.ts
Normal 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,
|
||||||
|
];
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -1,80 +0,0 @@
|
||||||
/**
|
|
||||||
* Simple 5x7 Matrix Pixel Font
|
|
||||||
* Each character is a 5x7 bitmask
|
|
||||||
*/
|
|
||||||
const FONT_DATA = {
|
|
||||||
'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]
|
|
||||||
};
|
|
||||||
|
|
||||||
export const PixelFont = {
|
|
||||||
drawText(ctx, text, x, y, color = '#ffffff', scale = 1) {
|
|
||||||
ctx.save();
|
|
||||||
ctx.fillStyle = color;
|
|
||||||
let cursorX = x;
|
|
||||||
|
|
||||||
const chars = text.toUpperCase().split('');
|
|
||||||
chars.forEach(char => {
|
|
||||||
const glyph = FONT_DATA[char] || FONT_DATA['?'];
|
|
||||||
for (let row = 0; row < 7; row++) {
|
|
||||||
for (let col = 0; col < 5; col++) {
|
|
||||||
if ((glyph[row] >> (4 - col)) & 1) {
|
|
||||||
ctx.fillRect(cursorX + col * scale, y + row * scale, scale, scale);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
cursorX += 6 * scale; // 5 width + 1 spacing
|
|
||||||
});
|
|
||||||
ctx.restore();
|
|
||||||
},
|
|
||||||
|
|
||||||
getTextWidth(text, scale = 1) {
|
|
||||||
return text.length * 6 * scale;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
107
src/core/PixelFont.ts
Normal file
107
src/core/PixelFont.ts
Normal 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;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -1,23 +1,41 @@
|
||||||
|
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.
|
* Sprite Library defining pixel art grids as 2D arrays.
|
||||||
|
*
|
||||||
|
* Pixel Values:
|
||||||
* 0: Transparent
|
* 0: Transparent
|
||||||
* 1: Primary Color (Entity Color)
|
* 1: Primary Color (Entity Color)
|
||||||
* 2: Highlight (White / Shine)
|
* 2: Highlight (White / Shine)
|
||||||
* 3: Detail/Shade (Darker Blue / Eyes)
|
* 3: Detail/Shade (Darker Blue / Eyes)
|
||||||
*/
|
*/
|
||||||
export const SpriteLibrary = {
|
export const SpriteLibrary: Record<string, EntitySpriteData> = {
|
||||||
// 8x8 Slime - Bottom-heavy blob
|
[EntityType.SLIME]: {
|
||||||
slime: {
|
[AnimationState.IDLE]: [
|
||||||
idle: [
|
|
||||||
[
|
[
|
||||||
[0, 0, 0, 0, 0, 0, 0, 0],
|
[0, 0, 0, 0, 0, 0, 0, 0],
|
||||||
[0, 0, 1, 1, 1, 1, 0, 0], // Top
|
[0, 0, 1, 1, 1, 1, 0, 0],
|
||||||
[0, 1, 1, 1, 1, 1, 1, 0],
|
[0, 1, 1, 1, 1, 1, 1, 0],
|
||||||
[1, 1, 2, 1, 1, 2, 1, 1], // Highlights
|
[1, 1, 2, 1, 1, 2, 1, 1],
|
||||||
[1, 1, 3, 1, 1, 3, 1, 1], // Eyes
|
[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],
|
[1, 1, 1, 1, 1, 1, 1, 1],
|
||||||
[0, 1, 1, 1, 1, 1, 1, 0] // Flat-ish base
|
[0, 1, 1, 1, 1, 1, 1, 0],
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
[0, 0, 0, 0, 0, 0, 0, 0],
|
[0, 0, 0, 0, 0, 0, 0, 0],
|
||||||
|
|
@ -27,10 +45,10 @@ export const SpriteLibrary = {
|
||||||
[1, 1, 2, 1, 1, 2, 1, 1],
|
[1, 1, 2, 1, 1, 2, 1, 1],
|
||||||
[1, 1, 3, 1, 1, 3, 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] // Squashed base
|
[1, 1, 1, 1, 1, 1, 1, 1],
|
||||||
]
|
|
||||||
],
|
],
|
||||||
walk: [
|
],
|
||||||
|
[AnimationState.WALK]: [
|
||||||
[
|
[
|
||||||
[0, 0, 1, 1, 1, 1, 0, 0],
|
[0, 0, 1, 1, 1, 1, 0, 0],
|
||||||
[0, 1, 1, 1, 1, 1, 1, 0],
|
[0, 1, 1, 1, 1, 1, 1, 0],
|
||||||
|
|
@ -39,7 +57,7 @@ export const SpriteLibrary = {
|
||||||
[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, 1, 1, 1, 1, 1, 1],
|
||||||
[1, 3, 1, 1, 1, 1, 3, 1],
|
[1, 3, 1, 1, 1, 1, 3, 1],
|
||||||
[0, 1, 1, 1, 1, 1, 1, 0]
|
[0, 1, 1, 1, 1, 1, 1, 0],
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
[0, 0, 0, 0, 0, 0, 0, 0],
|
[0, 0, 0, 0, 0, 0, 0, 0],
|
||||||
|
|
@ -49,14 +67,13 @@ export const SpriteLibrary = {
|
||||||
[1, 1, 3, 1, 1, 3, 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],
|
[1, 1, 1, 1, 1, 1, 1, 1],
|
||||||
[1, 1, 1, 1, 1, 1, 1, 1]
|
[1, 1, 1, 1, 1, 1, 1, 1],
|
||||||
]
|
],
|
||||||
]
|
],
|
||||||
},
|
},
|
||||||
|
|
||||||
// 8x8 Humanoid - Simple Walk Cycle
|
[EntityType.HUMANOID]: {
|
||||||
humanoid: {
|
[AnimationState.IDLE]: [
|
||||||
idle: [
|
|
||||||
[
|
[
|
||||||
[0, 0, 0, 1, 1, 0, 0, 0],
|
[0, 0, 0, 1, 1, 0, 0, 0],
|
||||||
[0, 0, 2, 1, 1, 2, 0, 0],
|
[0, 0, 2, 1, 1, 2, 0, 0],
|
||||||
|
|
@ -65,10 +82,10 @@ export const SpriteLibrary = {
|
||||||
[1, 0, 2, 1, 1, 2, 0, 1],
|
[1, 0, 2, 1, 1, 2, 0, 1],
|
||||||
[1, 0, 1, 1, 1, 1, 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],
|
||||||
[0, 0, 1, 0, 0, 1, 0, 0]
|
[0, 0, 1, 0, 0, 1, 0, 0],
|
||||||
]
|
|
||||||
],
|
],
|
||||||
walk: [
|
],
|
||||||
|
[AnimationState.WALK]: [
|
||||||
[
|
[
|
||||||
[0, 0, 0, 1, 1, 0, 0, 0],
|
[0, 0, 0, 1, 1, 0, 0, 0],
|
||||||
[0, 0, 2, 1, 1, 2, 0, 0],
|
[0, 0, 2, 1, 1, 2, 0, 0],
|
||||||
|
|
@ -77,7 +94,7 @@ export const SpriteLibrary = {
|
||||||
[0, 0, 2, 1, 1, 2, 0, 1],
|
[0, 0, 2, 1, 1, 2, 0, 1],
|
||||||
[0, 0, 1, 1, 1, 1, 0, 1],
|
[0, 0, 1, 1, 1, 1, 0, 1],
|
||||||
[0, 0, 1, 0, 0, 0, 0, 0],
|
[0, 0, 1, 0, 0, 0, 0, 0],
|
||||||
[0, 1, 1, 0, 0, 0, 0, 0]
|
[0, 1, 1, 0, 0, 0, 0, 0],
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
[0, 0, 0, 1, 1, 0, 0, 0],
|
[0, 0, 0, 1, 1, 0, 0, 0],
|
||||||
|
|
@ -87,14 +104,13 @@ export const SpriteLibrary = {
|
||||||
[1, 0, 2, 1, 1, 2, 0, 0],
|
[1, 0, 2, 1, 1, 2, 0, 0],
|
||||||
[1, 0, 1, 1, 1, 1, 0, 0],
|
[1, 0, 1, 1, 1, 1, 0, 0],
|
||||||
[0, 0, 0, 0, 0, 1, 0, 0],
|
[0, 0, 0, 0, 0, 1, 0, 0],
|
||||||
[0, 0, 0, 0, 0, 1, 1, 0]
|
[0, 0, 0, 0, 0, 1, 1, 0],
|
||||||
]
|
],
|
||||||
]
|
],
|
||||||
},
|
},
|
||||||
|
|
||||||
// 8x8 Beast - Bounding Cycle
|
[EntityType.BEAST]: {
|
||||||
beast: {
|
[AnimationState.IDLE]: [
|
||||||
idle: [
|
|
||||||
[
|
[
|
||||||
[0, 0, 0, 0, 0, 0, 0, 0],
|
[0, 0, 0, 0, 0, 0, 0, 0],
|
||||||
[1, 0, 0, 0, 0, 0, 0, 1],
|
[1, 0, 0, 0, 0, 0, 0, 1],
|
||||||
|
|
@ -103,10 +119,10 @@ export const SpriteLibrary = {
|
||||||
[1, 1, 1, 1, 1, 1, 1, 1],
|
[1, 1, 1, 1, 1, 1, 1, 1],
|
||||||
[0, 1, 1, 2, 2, 1, 1, 0],
|
[0, 1, 1, 2, 2, 1, 1, 0],
|
||||||
[0, 1, 0, 0, 0, 0, 1, 0],
|
[0, 1, 0, 0, 0, 0, 1, 0],
|
||||||
[0, 1, 0, 0, 0, 0, 1, 0]
|
[0, 1, 0, 0, 0, 0, 1, 0],
|
||||||
]
|
|
||||||
],
|
],
|
||||||
walk: [
|
],
|
||||||
|
[AnimationState.WALK]: [
|
||||||
[
|
[
|
||||||
[1, 0, 0, 0, 0, 0, 0, 1],
|
[1, 0, 0, 0, 0, 0, 0, 1],
|
||||||
[0, 1, 1, 1, 1, 1, 1, 0],
|
[0, 1, 1, 1, 1, 1, 1, 0],
|
||||||
|
|
@ -115,7 +131,7 @@ export const SpriteLibrary = {
|
||||||
[0, 1, 1, 2, 2, 1, 1, 0],
|
[0, 1, 1, 2, 2, 1, 1, 0],
|
||||||
[0, 1, 0, 0, 0, 0, 1, 0],
|
[0, 1, 0, 0, 0, 0, 1, 0],
|
||||||
[1, 1, 0, 0, 0, 0, 1, 1],
|
[1, 1, 0, 0, 0, 0, 1, 1],
|
||||||
[0, 0, 0, 0, 0, 0, 0, 0]
|
[0, 0, 0, 0, 0, 0, 0, 0],
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
[0, 0, 0, 0, 0, 0, 0, 0],
|
[0, 0, 0, 0, 0, 0, 0, 0],
|
||||||
|
|
@ -125,14 +141,13 @@ export const SpriteLibrary = {
|
||||||
[1, 1, 1, 1, 1, 1, 1, 1],
|
[1, 1, 1, 1, 1, 1, 1, 1],
|
||||||
[0, 1, 1, 2, 2, 1, 1, 0],
|
[0, 1, 1, 2, 2, 1, 1, 0],
|
||||||
[0, 0, 1, 0, 0, 1, 0, 0],
|
[0, 0, 1, 0, 0, 1, 0, 0],
|
||||||
[0, 0, 1, 0, 0, 1, 0, 0]
|
[0, 0, 1, 0, 0, 1, 0, 0],
|
||||||
]
|
],
|
||||||
]
|
],
|
||||||
},
|
},
|
||||||
|
|
||||||
// 8x8 Elemental - Floating Pulse
|
[EntityType.ELEMENTAL]: {
|
||||||
elemental: {
|
[AnimationState.IDLE]: [
|
||||||
idle: [
|
|
||||||
[
|
[
|
||||||
[0, 0, 2, 1, 1, 2, 0, 0],
|
[0, 0, 2, 1, 1, 2, 0, 0],
|
||||||
[0, 1, 1, 2, 2, 1, 1, 0],
|
[0, 1, 1, 2, 2, 1, 1, 0],
|
||||||
|
|
@ -141,7 +156,7 @@ export const SpriteLibrary = {
|
||||||
[1, 1, 1, 3, 3, 1, 1, 1],
|
[1, 1, 1, 3, 3, 1, 1, 1],
|
||||||
[0, 1, 1, 1, 1, 1, 1, 0],
|
[0, 1, 1, 1, 1, 1, 1, 0],
|
||||||
[0, 0, 1, 1, 1, 1, 0, 0],
|
[0, 0, 1, 1, 1, 1, 0, 0],
|
||||||
[0, 0, 0, 1, 1, 0, 0, 0]
|
[0, 0, 0, 1, 1, 0, 0, 0],
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
[0, 0, 0, 0, 0, 0, 0, 0],
|
[0, 0, 0, 0, 0, 0, 0, 0],
|
||||||
|
|
@ -151,17 +166,17 @@ export const SpriteLibrary = {
|
||||||
[1, 1, 1, 3, 3, 1, 1, 1],
|
[1, 1, 1, 3, 3, 1, 1, 1],
|
||||||
[0, 1, 1, 1, 1, 1, 1, 0],
|
[0, 1, 1, 1, 1, 1, 1, 0],
|
||||||
[0, 0, 1, 1, 1, 1, 0, 0],
|
[0, 0, 1, 1, 1, 1, 0, 0],
|
||||||
[0, 0, 0, 1, 1, 0, 0, 0]
|
[0, 0, 0, 1, 1, 0, 0, 0],
|
||||||
]
|
],
|
||||||
]
|
],
|
||||||
},
|
},
|
||||||
|
|
||||||
projectile: {
|
[EntityType.PROJECTILE]: {
|
||||||
idle: [
|
[AnimationState.IDLE]: [
|
||||||
[
|
[
|
||||||
[1, 1],
|
[1, 1],
|
||||||
[1, 1]
|
[1, 1],
|
||||||
]
|
],
|
||||||
]
|
],
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
|
|
@ -1,46 +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
64
src/core/System.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,34 +0,0 @@
|
||||||
export class TileMap {
|
|
||||||
constructor(cols, rows, tileSize) {
|
|
||||||
this.cols = cols;
|
|
||||||
this.rows = rows;
|
|
||||||
this.tileSize = tileSize;
|
|
||||||
this.tiles = new Array(cols * rows).fill(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
setTile(col, row, value) {
|
|
||||||
if (this.isValid(col, row)) {
|
|
||||||
this.tiles[row * this.cols + col] = value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
getTile(col, row) {
|
|
||||||
if (this.isValid(col, row)) {
|
|
||||||
return this.tiles[row * this.cols + col];
|
|
||||||
}
|
|
||||||
return 1; // Treat out of bounds as solid wall
|
|
||||||
}
|
|
||||||
|
|
||||||
isValid(col, row) {
|
|
||||||
return col >= 0 && col < this.cols && row >= 0 && row < this.rows;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if a world position collides with a solid tile
|
|
||||||
*/
|
|
||||||
isSolid(x, y) {
|
|
||||||
const col = Math.floor(x / this.tileSize);
|
|
||||||
const row = Math.floor(y / this.tileSize);
|
|
||||||
return this.getTile(col, row) !== 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
68
src/core/TileMap.ts
Normal file
68
src/core/TileMap.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,14 +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
46
src/items/Item.ts
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -1,54 +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
78
src/items/ItemRegistry.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
158
src/main.js
158
src/main.js
|
|
@ -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(160, 120)); // Center of 320x240
|
|
||||||
player.addComponent(new Velocity(0, 0, 100)); // Slower speed for small resolution
|
|
||||||
player.addComponent(new Sprite('#00ff96', 14, 14, 'slime')); // 14x14 pixel sprite
|
|
||||||
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, 50)); // Slower speed
|
|
||||||
|
|
||||||
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, 10, 10, type));
|
|
||||||
creature.addComponent(new Health(15 + Math.random() * 10)); // Adjusted health for smaller enemies
|
|
||||||
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 = 20 + Math.random() * 280; // Fit in 320 width
|
|
||||||
const y = 20 + Math.random() * 200; // Fit in 240 height
|
|
||||||
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 = 20 + Math.random() * 280;
|
|
||||||
const y = 20 + Math.random() * 200;
|
|
||||||
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
236
src/main.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,33 +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
48
src/skills/Skill.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
53
src/skills/SkillRegistry.ts
Normal file
53
src/skills/SkillRegistry.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
110
src/skills/skills/FireBreath.ts
Normal file
110
src/skills/skills/FireBreath.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
161
src/skills/skills/Pounce.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,36 +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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
49
src/skills/skills/StealthMode.ts
Normal file
49
src/skills/skills/StealthMode.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 = 250; // Screen width approx
|
|
||||||
this.speed = 250; // Faster than player but readable
|
|
||||||
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', 4, 4, 'projectile'));
|
|
||||||
|
|
||||||
// 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
101
src/skills/skills/WaterGun.ts
Normal file
101
src/skills/skills/WaterGun.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,77 +1,97 @@
|
||||||
import { System } from '../core/System.js';
|
import { System } from '../core/System.ts';
|
||||||
import { GameConfig } from '../GameConfig.js';
|
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 {
|
export class AISystem extends System {
|
||||||
constructor() {
|
constructor() {
|
||||||
super('AISystem');
|
super(SystemName.AI);
|
||||||
this.requiredComponents = ['Position', 'Velocity', 'AI'];
|
this.requiredComponents = [ComponentType.POSITION, ComponentType.VELOCITY, ComponentType.AI];
|
||||||
this.priority = 15;
|
this.priority = 15;
|
||||||
}
|
}
|
||||||
|
|
||||||
process(deltaTime, entities) {
|
/**
|
||||||
const playerController = this.engine.systems.find(s => s.name === 'PlayerControllerSystem');
|
* Process AI logic for all entities with an AI component.
|
||||||
const player = playerController ? playerController.getPlayerEntity() : null;
|
* @param deltaTime - Time elapsed since last frame in seconds
|
||||||
const playerPos = player?.getComponent('Position');
|
* @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;
|
const config = GameConfig.AI;
|
||||||
|
|
||||||
entities.forEach(entity => {
|
entities.forEach((entity) => {
|
||||||
const health = entity.getComponent('Health');
|
const health = entity.getComponent<Health>(ComponentType.HEALTH);
|
||||||
const ai = entity.getComponent('AI');
|
const ai = entity.getComponent<AI>(ComponentType.AI);
|
||||||
const position = entity.getComponent('Position');
|
const position = entity.getComponent<Position>(ComponentType.POSITION);
|
||||||
const velocity = entity.getComponent('Velocity');
|
const velocity = entity.getComponent<Velocity>(ComponentType.VELOCITY);
|
||||||
|
|
||||||
if (!ai || !position || !velocity) return;
|
if (!ai || !position || !velocity) return;
|
||||||
|
|
||||||
// Stop movement for dead entities
|
|
||||||
if (health && health.isDead() && !health.isProjectile) {
|
if (health && health.isDead() && !health.isProjectile) {
|
||||||
velocity.vx = 0;
|
velocity.vx = 0;
|
||||||
velocity.vy = 0;
|
velocity.vy = 0;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update wander timer
|
|
||||||
ai.wanderChangeTime += deltaTime;
|
ai.wanderChangeTime += deltaTime;
|
||||||
|
|
||||||
// Detect player
|
|
||||||
if (playerPos) {
|
if (playerPos) {
|
||||||
const dx = playerPos.x - position.x;
|
const dx = playerPos.x - position.x;
|
||||||
const dy = playerPos.y - position.y;
|
const dy = playerPos.y - position.y;
|
||||||
const distance = Math.sqrt(dx * dx + dy * dy);
|
const distance = Math.sqrt(dx * dx + dy * dy);
|
||||||
|
|
||||||
// Update awareness based on distance and player stealth
|
const playerStealth = player?.getComponent<Stealth>(ComponentType.STEALTH);
|
||||||
const playerStealth = player?.getComponent('Stealth');
|
|
||||||
const playerVisibility = playerStealth ? playerStealth.visibility : 1.0;
|
const playerVisibility = playerStealth ? playerStealth.visibility : 1.0;
|
||||||
|
|
||||||
if (distance < ai.alertRadius) {
|
if (distance < ai.alertRadius) {
|
||||||
const detectionChance = (1 - distance / ai.alertRadius) * playerVisibility;
|
const detectionChance = (1 - distance / ai.alertRadius) * playerVisibility;
|
||||||
ai.updateAwareness(detectionChance * deltaTime * config.awarenessGainMultiplier);
|
ai.updateAwareness(detectionChance * deltaTime * config.awarenessGainMultiplier);
|
||||||
} else {
|
} else {
|
||||||
ai.updateAwareness(-deltaTime * config.awarenessLossRate); // Lose awareness over time
|
ai.updateAwareness(-deltaTime * config.awarenessLossRate);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Biological Reputation Logic
|
const playerEvolution = player?.getComponent<Evolution>(ComponentType.EVOLUTION);
|
||||||
const playerEvolution = player?.getComponent('Evolution');
|
|
||||||
const playerForm = playerEvolution ? playerEvolution.getDominantForm() : 'slime';
|
const playerForm = playerEvolution ? playerEvolution.getDominantForm() : 'slime';
|
||||||
const entityType = entity.getComponent('Sprite')?.color === '#ffaa00' ? 'beast' :
|
const sprite = entity.getComponent<Sprite>(ComponentType.SPRITE);
|
||||||
entity.getComponent('Sprite')?.color === '#ff5555' ? 'humanoid' : 'other';
|
const entityType =
|
||||||
|
sprite?.color === '#ffaa00'
|
||||||
|
? 'beast'
|
||||||
|
: sprite?.color === '#ff5555'
|
||||||
|
? 'humanoid'
|
||||||
|
: 'other';
|
||||||
|
|
||||||
// Check if player is "one of us" or "too scary"
|
|
||||||
let isPassive = false;
|
let isPassive = false;
|
||||||
let shouldFlee = false;
|
let shouldFlee = false;
|
||||||
|
|
||||||
if (entityType === 'humanoid' && playerForm === 'human') {
|
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;
|
if (ai.awareness < config.passiveAwarenessThreshold) isPassive = true;
|
||||||
} else if (entityType === 'beast' && playerForm === 'beast') {
|
} else if (entityType === 'beast' && playerForm === 'beast') {
|
||||||
// Beasts might flee from a dominant beast player
|
const playerStats = player?.getComponent<Stats>(ComponentType.STATS);
|
||||||
const playerStats = player?.getComponent('Stats');
|
const entityStats = entity.getComponent<Stats>(ComponentType.STATS);
|
||||||
const entityStats = entity.getComponent('Stats');
|
|
||||||
if (playerStats && entityStats && playerStats.level > entityStats.level) {
|
if (playerStats && entityStats && playerStats.level > entityStats.level) {
|
||||||
shouldFlee = true;
|
shouldFlee = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Behavior based on awareness, reputation, and distance
|
|
||||||
if (shouldFlee && ai.awareness > config.fleeAwarenessThreshold) {
|
if (shouldFlee && ai.awareness > config.fleeAwarenessThreshold) {
|
||||||
ai.setBehavior('flee');
|
ai.setBehavior('flee');
|
||||||
ai.state = 'fleeing';
|
ai.state = 'fleeing';
|
||||||
|
|
@ -84,8 +104,7 @@ export class AISystem extends System {
|
||||||
}
|
}
|
||||||
} else if (ai.awareness > config.detectionAwarenessThreshold && distance < ai.chaseRadius) {
|
} else if (ai.awareness > config.detectionAwarenessThreshold && distance < ai.chaseRadius) {
|
||||||
if (ai.behaviorType !== 'flee') {
|
if (ai.behaviorType !== 'flee') {
|
||||||
// Check if in attack range - if so, use combat behavior
|
const combat = entity.getComponent<Combat>(ComponentType.COMBAT);
|
||||||
const combat = entity.getComponent('Combat');
|
|
||||||
if (combat && distance <= combat.attackRange) {
|
if (combat && distance <= combat.attackRange) {
|
||||||
ai.setBehavior('combat');
|
ai.setBehavior('combat');
|
||||||
ai.state = 'combat';
|
ai.state = 'combat';
|
||||||
|
|
@ -102,8 +121,7 @@ export class AISystem extends System {
|
||||||
ai.clearTarget();
|
ai.clearTarget();
|
||||||
}
|
}
|
||||||
} else if (ai.behaviorType === 'chase') {
|
} else if (ai.behaviorType === 'chase') {
|
||||||
// Update from chase to combat if in range
|
const combat = entity.getComponent<Combat>(ComponentType.COMBAT);
|
||||||
const combat = entity.getComponent('Combat');
|
|
||||||
if (combat && distance <= combat.attackRange) {
|
if (combat && distance <= combat.attackRange) {
|
||||||
ai.setBehavior('combat');
|
ai.setBehavior('combat');
|
||||||
ai.state = 'combat';
|
ai.state = 'combat';
|
||||||
|
|
@ -111,7 +129,6 @@ export class AISystem extends System {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Execute behavior
|
|
||||||
switch (ai.behaviorType) {
|
switch (ai.behaviorType) {
|
||||||
case 'wander':
|
case 'wander':
|
||||||
this.wander(entity, ai, velocity, deltaTime);
|
this.wander(entity, ai, velocity, deltaTime);
|
||||||
|
|
@ -129,10 +146,12 @@ export class AISystem extends System {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
wander(entity, ai, velocity, _deltaTime) {
|
/**
|
||||||
|
* Execute wandering behavior, moving in a random direction.
|
||||||
|
*/
|
||||||
|
wander(_entity: Entity, ai: AI, velocity: Velocity, _deltaTime: number): void {
|
||||||
ai.state = 'moving';
|
ai.state = 'moving';
|
||||||
|
|
||||||
// Change direction periodically
|
|
||||||
if (ai.wanderChangeTime >= ai.wanderChangeInterval) {
|
if (ai.wanderChangeTime >= ai.wanderChangeInterval) {
|
||||||
ai.wanderDirection = Math.random() * Math.PI * 2;
|
ai.wanderDirection = Math.random() * Math.PI * 2;
|
||||||
ai.wanderChangeTime = 0;
|
ai.wanderChangeTime = 0;
|
||||||
|
|
@ -143,15 +162,23 @@ export class AISystem extends System {
|
||||||
velocity.vy = Math.sin(ai.wanderDirection) * ai.wanderSpeed;
|
velocity.vy = Math.sin(ai.wanderDirection) * ai.wanderSpeed;
|
||||||
}
|
}
|
||||||
|
|
||||||
chase(entity, ai, velocity, position, targetPos) {
|
/**
|
||||||
|
* Execute chasing behavior, moving toward a target.
|
||||||
|
*/
|
||||||
|
chase(
|
||||||
|
entity: Entity,
|
||||||
|
ai: AI,
|
||||||
|
velocity: Velocity,
|
||||||
|
position: Position,
|
||||||
|
targetPos: Position | undefined
|
||||||
|
): void {
|
||||||
if (!targetPos) return;
|
if (!targetPos) return;
|
||||||
|
|
||||||
const dx = targetPos.x - position.x;
|
const dx = targetPos.x - position.x;
|
||||||
const dy = targetPos.y - position.y;
|
const dy = targetPos.y - position.y;
|
||||||
const distance = Math.sqrt(dx * dx + dy * dy);
|
const distance = Math.sqrt(dx * dx + dy * dy);
|
||||||
|
|
||||||
// Check if we should switch to combat
|
const combat = entity.getComponent<Combat>(ComponentType.COMBAT);
|
||||||
const combat = entity.getComponent('Combat');
|
|
||||||
if (combat && distance <= combat.attackRange) {
|
if (combat && distance <= combat.attackRange) {
|
||||||
ai.setBehavior('combat');
|
ai.setBehavior('combat');
|
||||||
ai.state = 'combat';
|
ai.state = 'combat';
|
||||||
|
|
@ -169,7 +196,16 @@ export class AISystem extends System {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
flee(entity, ai, velocity, position, targetPos) {
|
/**
|
||||||
|
* Execute fleeing behavior, moving away from a target.
|
||||||
|
*/
|
||||||
|
flee(
|
||||||
|
_entity: Entity,
|
||||||
|
ai: AI,
|
||||||
|
velocity: Velocity,
|
||||||
|
position: Position,
|
||||||
|
targetPos: Position | undefined
|
||||||
|
): void {
|
||||||
if (!targetPos) return;
|
if (!targetPos) return;
|
||||||
|
|
||||||
ai.state = 'fleeing';
|
ai.state = 'fleeing';
|
||||||
|
|
@ -184,29 +220,37 @@ export class AISystem extends System {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
combat(entity, ai, velocity, position, targetPos) {
|
/**
|
||||||
|
* 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;
|
if (!targetPos) return;
|
||||||
|
|
||||||
ai.state = 'attacking';
|
ai.state = 'attacking';
|
||||||
// Stop moving when in combat range - let CombatSystem handle attacks
|
|
||||||
const dx = targetPos.x - position.x;
|
const dx = targetPos.x - position.x;
|
||||||
const dy = targetPos.y - position.y;
|
const dy = targetPos.y - position.y;
|
||||||
const distance = Math.sqrt(dx * dx + dy * dy);
|
const distance = Math.sqrt(dx * dx + dy * dy);
|
||||||
|
|
||||||
const combat = entity.getComponent('Combat');
|
const combat = entity.getComponent<Combat>(ComponentType.COMBAT);
|
||||||
if (combat && distance > combat.attackRange) {
|
if (combat && distance > combat.attackRange) {
|
||||||
// Move closer if out of range
|
|
||||||
const speed = ai.wanderSpeed;
|
const speed = ai.wanderSpeed;
|
||||||
velocity.vx = (dx / distance) * speed;
|
velocity.vx = (dx / distance) * speed;
|
||||||
velocity.vy = (dy / distance) * speed;
|
velocity.vy = (dy / distance) * speed;
|
||||||
} else {
|
} else {
|
||||||
// Stop and face target
|
|
||||||
velocity.vx *= 0.5;
|
velocity.vx *= 0.5;
|
||||||
velocity.vy *= 0.5;
|
velocity.vy *= 0.5;
|
||||||
if (position) {
|
|
||||||
position.rotation = Math.atan2(dy, dx);
|
position.rotation = Math.atan2(dy, dx);
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
const intent = entity.getComponent<Intent>(ComponentType.INTENT);
|
||||||
|
if (intent) {
|
||||||
|
intent.setIntent('attack', { targetX: targetPos.x, targetY: targetPos.y });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
172
src/systems/AbsorptionSystem.ts
Normal file
172
src/systems/AbsorptionSystem.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
50
src/systems/CameraSystem.ts
Normal file
50
src/systems/CameraSystem.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
import { System } from '../core/System.ts';
|
||||||
|
import { SystemName, ComponentType } from '../core/Constants.ts';
|
||||||
|
import type { Entity } from '../core/Entity.ts';
|
||||||
|
import type { Camera } from '../components/Camera.ts';
|
||||||
|
import type { Position } from '../components/Position.ts';
|
||||||
|
import type { PlayerControllerSystem } from './PlayerControllerSystem.ts';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* System responsible for camera movement and following the player.
|
||||||
|
*/
|
||||||
|
export class CameraSystem extends System {
|
||||||
|
constructor() {
|
||||||
|
super(SystemName.CAMERA);
|
||||||
|
this.requiredComponents = [ComponentType.CAMERA];
|
||||||
|
this.priority = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update camera position to smoothly follow the player.
|
||||||
|
* @param deltaTime - Time elapsed since last frame in seconds
|
||||||
|
* @param entities - Filtered entities with Camera component
|
||||||
|
*/
|
||||||
|
process(deltaTime: number, entities: Entity[]): void {
|
||||||
|
const playerController = this.engine.systems.find(
|
||||||
|
(s) => s.name === SystemName.PLAYER_CONTROLLER
|
||||||
|
) as PlayerControllerSystem | undefined;
|
||||||
|
const player = playerController ? playerController.getPlayerEntity() : null;
|
||||||
|
|
||||||
|
if (!player) return;
|
||||||
|
|
||||||
|
const playerPos = player.getComponent<Position>(ComponentType.POSITION);
|
||||||
|
if (!playerPos) return;
|
||||||
|
|
||||||
|
entities.forEach((entity) => {
|
||||||
|
const camera = entity.getComponent<Camera>(ComponentType.CAMERA);
|
||||||
|
if (!camera) return;
|
||||||
|
|
||||||
|
camera.targetX = playerPos.x;
|
||||||
|
camera.targetY = playerPos.y;
|
||||||
|
|
||||||
|
const dx = camera.targetX - camera.x;
|
||||||
|
const dy = camera.targetY - camera.y;
|
||||||
|
|
||||||
|
camera.x += dx * camera.smoothness;
|
||||||
|
camera.y += dy * camera.smoothness;
|
||||||
|
|
||||||
|
camera.clampToBounds();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,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
182
src/systems/CombatSystem.ts
Normal 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 {}
|
||||||
|
}
|
||||||
|
|
@ -1,62 +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()) {
|
|
||||||
// Check if player died
|
|
||||||
const evolution = entity.getComponent('Evolution');
|
|
||||||
if (evolution) {
|
|
||||||
const menuSystem = this.engine.systems.find(s => s.name === 'MenuSystem');
|
|
||||||
if (menuSystem) {
|
|
||||||
menuSystem.showGameOver();
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
75
src/systems/DeathSystem.ts
Normal file
75
src/systems/DeathSystem.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,28 +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);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
32
src/systems/HealthRegenerationSystem.ts
Normal file
32
src/systems/HealthRegenerationSystem.ts
Normal 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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,36 +1,59 @@
|
||||||
import { System } from '../core/System.js';
|
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 {
|
export class InputSystem extends System {
|
||||||
|
keys: Record<string, boolean>;
|
||||||
|
keysPrevious: Record<string, boolean>;
|
||||||
|
mouse: MouseState;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super('InputSystem');
|
super(SystemName.INPUT);
|
||||||
this.requiredComponents = []; // No required components - handles input globally
|
this.requiredComponents = [];
|
||||||
this.priority = 0; // Run first
|
this.priority = 0;
|
||||||
|
|
||||||
this.keys = {};
|
this.keys = {};
|
||||||
this.keysPrevious = {}; // Track previous frame key states
|
this.keysPrevious = {};
|
||||||
this.mouse = {
|
this.mouse = {
|
||||||
x: 0,
|
x: 0,
|
||||||
y: 0,
|
y: 0,
|
||||||
buttons: {},
|
buttons: {},
|
||||||
buttonsPrevious: {}
|
buttonsPrevious: {},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
init(engine) {
|
/**
|
||||||
|
* Initialize the system and set up event listeners.
|
||||||
|
* @param engine - The game engine instance
|
||||||
|
*/
|
||||||
|
init(engine: Engine): void {
|
||||||
super.init(engine);
|
super.init(engine);
|
||||||
this.setupEventListeners();
|
this.setupEventListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
setupEventListeners() {
|
/**
|
||||||
|
* Set up browser event listeners for keyboard and mouse.
|
||||||
|
*/
|
||||||
|
setupEventListeners(): void {
|
||||||
window.addEventListener('keydown', (e) => {
|
window.addEventListener('keydown', (e) => {
|
||||||
const key = e.key.toLowerCase();
|
const key = e.key.toLowerCase();
|
||||||
const code = e.code.toLowerCase();
|
const code = e.code.toLowerCase();
|
||||||
|
|
||||||
// Store by key name
|
|
||||||
this.keys[key] = true;
|
this.keys[key] = true;
|
||||||
this.keys[code] = true;
|
this.keys[code] = true;
|
||||||
|
|
||||||
// Handle special keys
|
|
||||||
if (key === ' ') {
|
if (key === ' ') {
|
||||||
this.keys['space'] = true;
|
this.keys['space'] = true;
|
||||||
}
|
}
|
||||||
|
|
@ -38,13 +61,11 @@ export class InputSystem extends System {
|
||||||
this.keys['space'] = true;
|
this.keys['space'] = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Arrow keys
|
|
||||||
if (code === 'arrowup') this.keys['arrowup'] = true;
|
if (code === 'arrowup') this.keys['arrowup'] = true;
|
||||||
if (code === 'arrowdown') this.keys['arrowdown'] = true;
|
if (code === 'arrowdown') this.keys['arrowdown'] = true;
|
||||||
if (code === 'arrowleft') this.keys['arrowleft'] = true;
|
if (code === 'arrowleft') this.keys['arrowleft'] = true;
|
||||||
if (code === 'arrowright') this.keys['arrowright'] = 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)) {
|
if ([' ', 'w', 'a', 's', 'd', '1', '2', '3', '4', '5', '6', '7', '8', '9'].includes(key)) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
}
|
}
|
||||||
|
|
@ -57,7 +78,6 @@ export class InputSystem extends System {
|
||||||
this.keys[key] = false;
|
this.keys[key] = false;
|
||||||
this.keys[code] = false;
|
this.keys[code] = false;
|
||||||
|
|
||||||
// Handle special keys
|
|
||||||
if (key === ' ') {
|
if (key === ' ') {
|
||||||
this.keys['space'] = false;
|
this.keys['space'] = false;
|
||||||
}
|
}
|
||||||
|
|
@ -65,7 +85,6 @@ export class InputSystem extends System {
|
||||||
this.keys['space'] = false;
|
this.keys['space'] = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Arrow keys
|
|
||||||
if (code === 'arrowup') this.keys['arrowup'] = false;
|
if (code === 'arrowup') this.keys['arrowup'] = false;
|
||||||
if (code === 'arrowdown') this.keys['arrowdown'] = false;
|
if (code === 'arrowdown') this.keys['arrowdown'] = false;
|
||||||
if (code === 'arrowleft') this.keys['arrowleft'] = false;
|
if (code === 'arrowleft') this.keys['arrowleft'] = false;
|
||||||
|
|
@ -76,7 +95,6 @@ export class InputSystem extends System {
|
||||||
if (this.engine && this.engine.canvas) {
|
if (this.engine && this.engine.canvas) {
|
||||||
const canvas = this.engine.canvas;
|
const canvas = this.engine.canvas;
|
||||||
const rect = canvas.getBoundingClientRect();
|
const rect = canvas.getBoundingClientRect();
|
||||||
// Calculate scale factors between displayed size and internal resolution
|
|
||||||
const scaleX = canvas.width / rect.width;
|
const scaleX = canvas.width / rect.width;
|
||||||
const scaleY = canvas.height / rect.height;
|
const scaleY = canvas.height / rect.height;
|
||||||
|
|
||||||
|
|
@ -94,16 +112,17 @@ export class InputSystem extends System {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
process(_deltaTime, _entities) {
|
/**
|
||||||
// Don't update previous states here - that happens at end of frame
|
* Process input state (placeholder as processing happens via events).
|
||||||
// This allows other systems to check isKeyJustPressed during the frame
|
* @param _deltaTime - Time elapsed
|
||||||
}
|
* @param _entities - Matching entities
|
||||||
|
*/
|
||||||
|
process(_deltaTime: number, _entities: Entity[]): void {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update previous states - called at end of frame
|
* Update previous frame states. Should be called at the end of each frame.
|
||||||
*/
|
*/
|
||||||
updatePreviousStates() {
|
updatePreviousStates(): void {
|
||||||
// Deep copy current states to previous for next frame
|
|
||||||
this.keysPrevious = {};
|
this.keysPrevious = {};
|
||||||
for (const key in this.keys) {
|
for (const key in this.keys) {
|
||||||
this.keysPrevious[key] = this.keys[key];
|
this.keysPrevious[key] = this.keys[key];
|
||||||
|
|
@ -115,16 +134,20 @@ export class InputSystem extends System {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if a key is currently pressed
|
* 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) {
|
isKeyPressed(key: string): boolean {
|
||||||
return this.keys[key.toLowerCase()] === true;
|
return this.keys[key.toLowerCase()] === true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if a key was just pressed (not held from previous frame)
|
* 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) {
|
isKeyJustPressed(key: string): boolean {
|
||||||
const keyLower = key.toLowerCase();
|
const keyLower = key.toLowerCase();
|
||||||
const isPressed = this.keys[keyLower] === true;
|
const isPressed = this.keys[keyLower] === true;
|
||||||
const wasPressed = this.keysPrevious[keyLower] === true;
|
const wasPressed = this.keysPrevious[keyLower] === true;
|
||||||
|
|
@ -132,26 +155,46 @@ export class InputSystem extends System {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get mouse position
|
* Get the current mouse position in world coordinates.
|
||||||
|
* @returns The mouse coordinates in world space
|
||||||
*/
|
*/
|
||||||
getMousePosition() {
|
getMousePosition(): { x: number; y: number } {
|
||||||
|
if (!this.engine) {
|
||||||
return { x: this.mouse.x, y: this.mouse.y };
|
return { x: this.mouse.x, y: this.mouse.y };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const cameraEntity = this.engine.entities.find((e) => e.hasComponent(ComponentType.CAMERA));
|
||||||
|
if (!cameraEntity) {
|
||||||
|
return { x: this.mouse.x, y: this.mouse.y };
|
||||||
|
}
|
||||||
|
|
||||||
|
const camera = cameraEntity.getComponent<Camera>(ComponentType.CAMERA);
|
||||||
|
if (!camera) {
|
||||||
|
return { x: this.mouse.x, y: this.mouse.y };
|
||||||
|
}
|
||||||
|
|
||||||
|
const worldX = this.mouse.x + camera.x - camera.viewportWidth / 2;
|
||||||
|
const worldY = this.mouse.y + camera.y - camera.viewportHeight / 2;
|
||||||
|
return { x: worldX, y: worldY };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if mouse button is pressed
|
* 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) {
|
isMouseButtonPressed(button = 0): boolean {
|
||||||
return this.mouse.buttons[button] === true;
|
return this.mouse.buttons[button] === true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if mouse button was just pressed
|
* 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) {
|
isMouseButtonJustPressed(button = 0): boolean {
|
||||||
const isPressed = this.mouse.buttons[button] === true;
|
const isPressed = this.mouse.buttons[button] === true;
|
||||||
const wasPressed = this.mouse.buttonsPrevious[button] === true;
|
const wasPressed = this.mouse.buttonsPrevious[button] === true;
|
||||||
return isPressed && !wasPressed;
|
return isPressed && !wasPressed;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1,158 +0,0 @@
|
||||||
import { System } from '../core/System.js';
|
|
||||||
import { PixelFont } from '../core/PixelFont.js';
|
|
||||||
import { Palette } from '../core/Palette.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();
|
|
||||||
} else if (this.gameState === 'gameOver') {
|
|
||||||
this.restartGame();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
showGameOver() {
|
|
||||||
this.gameState = 'gameOver';
|
|
||||||
this.paused = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
restartGame() {
|
|
||||||
window.location.reload(); // Simple and effective for this project
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
|
|
||||||
// Darker overlay matching palette
|
|
||||||
ctx.fillStyle = 'rgba(32, 21, 51, 0.8)'; // Semi-transparent VOID
|
|
||||||
ctx.fillRect(0, 0, width, height);
|
|
||||||
|
|
||||||
if (this.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 === '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);
|
|
||||||
|
|
||||||
// Draw Stats and Knowledge (Moved from HUD)
|
|
||||||
const player = this.engine.getEntities().find(e => e.hasComponent('Evolution'));
|
|
||||||
const uiSystem = this.engine.systems.find(s => s.name === 'UISystem');
|
|
||||||
|
|
||||||
if (player && uiSystem) {
|
|
||||||
// Draw Stats on the left
|
|
||||||
uiSystem.drawStats(player, 20, 80);
|
|
||||||
|
|
||||||
// Draw Learning Progress on the right
|
|
||||||
uiSystem.drawSkillProgress(player, width - 110, 80);
|
|
||||||
}
|
|
||||||
} else if (this.gameState === 'gameOver') {
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
getGameState() {
|
|
||||||
return this.gameState;
|
|
||||||
}
|
|
||||||
|
|
||||||
isPaused() {
|
|
||||||
return this.paused || this.gameState === 'start';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
200
src/systems/MenuSystem.ts
Normal file
200
src/systems/MenuSystem.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,77 +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 with collision detection
|
|
||||||
const tileMap = this.engine.tileMap;
|
|
||||||
|
|
||||||
// X Axis
|
|
||||||
const nextX = position.x + velocity.vx * deltaTime;
|
|
||||||
if (tileMap && tileMap.isSolid(nextX, position.y)) {
|
|
||||||
velocity.vx = 0;
|
|
||||||
} else {
|
|
||||||
position.x = nextX;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Y Axis
|
|
||||||
const nextY = position.y + velocity.vy * deltaTime;
|
|
||||||
if (tileMap && tileMap.isSolid(position.x, nextY)) {
|
|
||||||
velocity.vy = 0;
|
|
||||||
} else {
|
|
||||||
position.y = nextY;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
119
src/systems/MovementSystem.ts
Normal file
119
src/systems/MovementSystem.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue