Feature/VFX and animations #5

Merged
jusemon merged 4 commits from Feature/VFX-and-animations into main 2026-01-06 22:00:16 -05:00
108 changed files with 6154 additions and 3716 deletions

2
.gitignore vendored
View file

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

7
.husky/pre-commit Executable file
View file

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

4
.lintstagedrc.json Normal file
View file

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

5
.prettierignore Normal file
View file

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

9
.prettierrc.json Normal file
View file

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

134
README.md
View file

@ -1,21 +1,54 @@
# Slime Genesis - ECS RPG PoC
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
1. **Install dependencies:**
```bash
npm install
```
2. **Run the development server:**
```bash
npm run dev
```
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
- **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
### Core Systems
- ✅ **ECS Architecture**: Entity Component System for flexible game design
- ✅ **Real-time Combat**: Fast-paced action combat with form-specific styles
- ✅ **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
- ✅ **AI System**: Intelligent creature behaviors (wander, chase, combat, flee)
- ✅ **Projectile System**: Skills can create projectiles (Water Gun, etc.)
- ✅ **Mutation System**: Gain mutations like Hardened Shell, Electric Skin, Bioluminescence
### Graphics & Polish
- ✅ **Animated Slime**: Smooth morphing blob with jiggle physics
- ✅ **Combat Effects**: Damage numbers, attack indicators, particle effects
- ✅ **Absorption Visuals**: Swirling particles and color transitions
- ✅ **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
### Skills
- **Water Gun**: Shoot a jet of water at enemies
- **Fire Breath**: Breathe fire in a cone
- **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.)
This architecture makes it easy to:
- Add new skills and abilities
- Create mutations and combinations
- Extend creature behaviors
@ -67,32 +106,65 @@ This architecture makes it easy to:
```
src/
├── core/ # ECS framework
│ ├── Engine.js # Main game loop
│ ├── Entity.js # Entity manager
│ ├── Component.js # Base component
│ └── System.js # Base system
├── core/ # ECS framework and utilities
│ ├── Engine.ts # Main game loop
│ ├── Entity.ts # Entity manager
│ ├── Component.ts # Base component
│ ├── 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
│ ├── Position.js
│ ├── Health.js
│ ├── Stats.js
│ ├── Evolution.js
│ └── ...
│ ├── Position.ts # Position and rotation
│ ├── Velocity.ts # Movement velocity
│ ├── Health.ts # Health and regeneration
│ ├── 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
│ ├── RenderSystem.js
│ ├── CombatSystem.js
│ ├── AbsorptionSystem.js
│ └── ...
│ ├── RenderSystem.ts # Rendering
│ ├── InputSystem.ts # Input handling
│ ├── 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
│ ├── Skill.js
│ ├── SkillRegistry.js
│ ├── Skill.ts # Base skill class
│ ├── SkillRegistry.ts # Skill registry
│ └── skills/ # Individual skills
│ ├── FireBreath.ts
│ ├── Pounce.ts
│ ├── StealthMode.ts
│ └── WaterGun.ts
├── items/ # Item system
│ ├── Item.js
│ └── ItemRegistry.js
│ ├── Item.ts # Base item class
│ └── ItemRegistry.ts # Item registry
├── world/ # World management
│ └── World.js
└── main.js # Entry point
│ └── World.ts # World setup
├── GameConfig.ts # Game configuration
└── main.ts # Entry point
```
## Gameplay Loop
@ -103,6 +175,7 @@ src/
4. **Evolve**: Your form changes based on what you absorb
5. **Level Up**: Gain XP, increase stats, unlock new possibilities
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
@ -110,13 +183,30 @@ src/
- **Beast Path**: Absorb beasts to become a predator, gain physical power
- **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
- **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
- **ESLint**: Code linting with TypeScript support
- **Prettier**: Code formatting
- **Husky**: Pre-commit hooks (skips in CI environments)
- **Modular Design**: Easy to extend and modify
- **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
- More skills and mutations

View file

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

View file

@ -51,7 +51,7 @@
<div id="game-container">
<canvas id="game-canvas" tabindex="0"></canvas>
</div>
<script type="module" src="/src/main.js"></script>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

867
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -7,14 +7,24 @@
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"lint": "eslint src",
"lint:fix": "eslint src --fix"
"lint": "eslint src --max-warnings 0",
"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": {
"@eslint/js": "^9.39.2",
"@typescript-eslint/eslint-plugin": "^8.52.0",
"@typescript-eslint/parser": "^8.52.0",
"eslint": "^9.39.2",
"globals": "^17.0.0",
"husky": "^9.1.7",
"lint-staged": "^16.2.7",
"prettier": "^3.7.4",
"terser": "^5.44.1",
"typescript": "^5.9.3",
"typescript-eslint": "^8.52.0",
"vite": "^7.3.0"
}
}

View file

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

View file

@ -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
View file

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

View file

@ -1,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;
}
}

View file

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

View file

@ -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
View file

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

View file

@ -1,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
View file

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

View file

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

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

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

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

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

View file

@ -1,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;
}
}

View file

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

View file

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

View file

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

View file

@ -1,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;
}
}

View file

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

View file

@ -1,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
View file

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

View file

@ -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
View file

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

View file

@ -1,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
View file

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

View file

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

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

@ -0,0 +1,60 @@
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;
constructor() {
super(ComponentType.STEALTH);
this.visibility = 1.0;
this.stealthType = 'slime';
this.isStealthed = false;
this.stealthLevel = 0;
this.detectionRadius = 100;
}
/**
* Enter stealth mode.
* @param type - The type of stealth (e.g., 'slime', 'human')
*/
enterStealth(type: string): void {
this.stealthType = type;
this.isStealthed = true;
this.visibility = 0.3;
}
/**
* Exit stealth mode and restore full visibility.
*/
exitStealth(): void {
this.isStealthed = false;
this.visibility = 1.0;
}
/**
* Update visibility levels based on movement and combat state.
* @param isMoving - Whether the entity is currently moving
* @param isInCombat - Whether the entity is currently in combat
*/
updateStealth(isMoving: boolean, isInCombat: boolean): void {
if (isInCombat) {
this.exitStealth();
return;
}
if (this.isStealthed) {
if (isMoving) {
this.visibility = Math.min(1.0, this.visibility + 0.1);
} else {
this.visibility = Math.max(0.1, this.visibility - 0.05);
}
}
}
}

View file

@ -1,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;
}
}

View file

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

View file

@ -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
View file

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

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

@ -0,0 +1,82 @@
/**
* 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',
}
/**
* 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',
}

View file

@ -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
View file

@ -0,0 +1,171 @@
import { System } from './System.ts';
import { Entity } from './Entity.ts';
import { EventBus } from './EventBus.ts';
import { LevelLoader } from './LevelLoader.ts';
import { GameState, SystemName } from './Constants.ts';
import type { TileMap } from './TileMap.ts';
import type { MenuSystem } from '../systems/MenuSystem.ts';
import type { InputSystem } from '../systems/InputSystem.ts';
/**
* Main game engine responsible for managing the ECS lifecycle, game loop, and system execution.
*/
export class Engine {
canvas: HTMLCanvasElement;
ctx: CanvasRenderingContext2D;
entities: Entity[];
systems: System[];
events: EventBus;
running: boolean;
lastTime: number;
deltaTime: number;
tileMap: TileMap | null;
/**
* @param canvas - The canvas element to render to
*/
constructor(canvas: HTMLCanvasElement) {
this.canvas = canvas;
const ctx = canvas.getContext('2d');
if (!ctx) {
throw new Error('Failed to get 2d rendering context from canvas');
}
this.ctx = ctx;
this.entities = [];
this.systems = [];
this.events = new EventBus();
this.running = false;
this.lastTime = 0;
this.canvas.width = 320;
this.canvas.height = 240;
this.canvas.style.imageRendering = 'pixelated';
this.ctx.imageSmoothingEnabled = false;
this.deltaTime = 0;
this.tileMap = LevelLoader.loadSimpleLevel(20, 15, 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];
this.systems.forEach((system) => {
if (isPaused && !unskippedSystems.includes(system.name as SystemName)) {
return;
}
system.update(this.deltaTime, this.entities);
});
const inputSystem = this.systems.find((s) => s.name === SystemName.INPUT) as
| InputSystem
| undefined;
if (inputSystem && inputSystem.updatePreviousStates) {
inputSystem.updatePreviousStates();
}
requestAnimationFrame(this.gameLoop);
};
/**
* Clear the rendering surface.
*/
clear(): void {
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
}
}

View file

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

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

@ -0,0 +1,83 @@
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;
// Optional dynamic properties for specific entity types
owner?: number;
startX?: number;
startY?: number;
maxRange?: number;
lifetime?: number;
damage?: number;
deathTime?: number;
constructor() {
this.id = Entity.nextId++;
this.components = new Map();
this.active = true;
}
/**
* Add a component to this entity.
* @param component - The component to add
* @returns This entity for chaining
*/
addComponent(component: Component): Entity {
this.components.set(component.type, component);
return this;
}
/**
* Get a component by type.
* @param type - The component type name
* @returns The component instance if found
*/
getComponent<T extends Component>(type: string): T | undefined {
return this.components.get(type) as T | undefined;
}
/**
* Check if entity has a component.
* @param type - The component type name
* @returns True if the component exists
*/
hasComponent(type: string): boolean {
return this.components.has(type);
}
/**
* Check if entity has all specified components.
* @param types - List of component type names
* @returns True if all components exist
*/
hasComponents(...types: string[]): boolean {
return types.every((type) => this.components.has(type));
}
/**
* Remove a component.
* @param type - The component type name
* @returns This entity for chaining
*/
removeComponent(type: string): Entity {
this.components.delete(type);
return this;
}
/**
* Get all components attached to this entity.
* @returns Array of components
*/
getAllComponents(): Component[] {
return Array.from(this.components.values());
}
}

View file

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

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

@ -0,0 +1,101 @@
/**
* 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',
}
/**
* 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));
}
}
}

View file

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

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

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

View file

@ -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
View file

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

View file

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

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

@ -0,0 +1,105 @@
/**
* Simple 5x7 Matrix Pixel Font data.
* Each character is represented by an array of 7 integers, where each integer is a 5-bit mask.
*/
const FONT_DATA: Record<string, number[]> = {
A: [0x0e, 0x11, 0x11, 0x1f, 0x11, 0x11, 0x11],
B: [0x1e, 0x11, 0x11, 0x1e, 0x11, 0x11, 0x1e],
C: [0x0e, 0x11, 0x11, 0x10, 0x11, 0x11, 0x0e],
D: [0x1e, 0x11, 0x11, 0x11, 0x11, 0x11, 0x1e],
E: [0x1f, 0x10, 0x10, 0x1e, 0x10, 0x10, 0x1f],
F: [0x1f, 0x10, 0x10, 0x1e, 0x10, 0x10, 0x10],
G: [0x0f, 0x10, 0x10, 0x17, 0x11, 0x11, 0x0f],
H: [0x11, 0x11, 0x11, 0x1f, 0x11, 0x11, 0x11],
I: [0x0e, 0x04, 0x04, 0x04, 0x04, 0x04, 0x0e],
J: [0x07, 0x02, 0x02, 0x02, 0x02, 0x12, 0x0c],
K: [0x11, 0x12, 0x14, 0x18, 0x14, 0x12, 0x11],
L: [0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x1f],
M: [0x11, 0x1b, 0x15, 0x15, 0x11, 0x11, 0x11],
N: [0x11, 0x11, 0x19, 0x15, 0x13, 0x11, 0x11],
O: [0x0e, 0x11, 0x11, 0x11, 0x11, 0x11, 0x0e],
P: [0x1e, 0x11, 0x11, 0x1e, 0x10, 0x10, 0x10],
Q: [0x0e, 0x11, 0x11, 0x11, 0x15, 0x12, 0x0d],
R: [0x1e, 0x11, 0x11, 0x1e, 0x14, 0x12, 0x11],
S: [0x0e, 0x11, 0x10, 0x0e, 0x01, 0x11, 0x0e],
T: [0x1f, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04],
U: [0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x0e],
V: [0x11, 0x11, 0x11, 0x11, 0x11, 0x0a, 0x04],
W: [0x11, 0x11, 0x11, 0x15, 0x15, 0x1b, 0x11],
X: [0x11, 0x11, 0x0a, 0x04, 0x0a, 0x11, 0x11],
Y: [0x11, 0x11, 0x0a, 0x04, 0x04, 0x04, 0x04],
Z: [0x1f, 0x01, 0x02, 0x04, 0x08, 0x10, 0x1f],
'0': [0x0e, 0x11, 0x13, 0x15, 0x19, 0x11, 0x0e],
'1': [0x04, 0x0c, 0x04, 0x04, 0x04, 0x04, 0x0e],
'2': [0x0e, 0x11, 0x01, 0x02, 0x04, 0x08, 0x1f],
'3': [0x1f, 0x02, 0x04, 0x02, 0x01, 0x11, 0x0e],
'4': [0x02, 0x06, 0x0a, 0x12, 0x1f, 0x02, 0x02],
'5': [0x1f, 0x10, 0x1e, 0x01, 0x01, 0x11, 0x0e],
'6': [0x06, 0x08, 0x10, 0x1e, 0x11, 0x11, 0x0e],
'7': [0x1f, 0x01, 0x02, 0x04, 0x08, 0x08, 0x08],
'8': [0x0e, 0x11, 0x11, 0x0e, 0x11, 0x11, 0x0e],
'9': [0x0e, 0x11, 0x11, 0x0f, 0x01, 0x02, 0x0c],
':': [0x00, 0x04, 0x00, 0x00, 0x00, 0x04, 0x00],
'.': [0x00, 0x00, 0x00, 0x00, 0x00, 0x04, 0x00],
',': [0x00, 0x00, 0x00, 0x00, 0x00, 0x04, 0x08],
'!': [0x04, 0x04, 0x04, 0x04, 0x04, 0x00, 0x04],
'?': [0x0e, 0x11, 0x01, 0x02, 0x04, 0x00, 0x04],
'+': [0x00, 0x04, 0x04, 0x1f, 0x04, 0x04, 0x00],
'-': [0x00, 0x00, 0x00, 0x1f, 0x00, 0x00, 0x00],
'/': [0x01, 0x02, 0x04, 0x08, 0x10, 0x10, 0x10],
'(': [0x02, 0x04, 0x04, 0x04, 0x04, 0x04, 0x02],
')': [0x08, 0x04, 0x04, 0x04, 0x04, 0x04, 0x08],
' ': [0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00],
'|': [0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04],
};
/**
* Utility class for rendering text using a custom pixel font.
*/
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[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;
});
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;
},
};

View file

@ -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.
*
* Pixel Values:
* 0: Transparent
* 1: Primary Color (Entity Color)
* 2: Highlight (White / Shine)
* 3: Detail/Shade (Darker Blue / Eyes)
*/
export const SpriteLibrary = {
// 8x8 Slime - Bottom-heavy blob
slime: {
idle: [
export const SpriteLibrary: Record<string, EntitySpriteData> = {
[EntityType.SLIME]: {
[AnimationState.IDLE]: [
[
[0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 1, 1, 1, 1, 0, 0], // Top
[0, 0, 1, 1, 1, 1, 0, 0],
[0, 1, 1, 1, 1, 1, 1, 0],
[1, 1, 2, 1, 1, 2, 1, 1], // Highlights
[1, 1, 3, 1, 1, 3, 1, 1], // Eyes
[1, 1, 2, 1, 1, 2, 1, 1],
[1, 1, 3, 1, 1, 3, 1, 1],
[1, 1, 1, 1, 1, 1, 1, 1],
[1, 1, 1, 1, 1, 1, 1, 1],
[0, 1, 1, 1, 1, 1, 1, 0] // Flat-ish base
[0, 1, 1, 1, 1, 1, 1, 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, 3, 1, 1, 3, 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, 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, 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],
@ -49,14 +67,13 @@ export const SpriteLibrary = {
[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],
],
],
},
// 8x8 Humanoid - Simple Walk Cycle
humanoid: {
idle: [
[EntityType.HUMANOID]: {
[AnimationState.IDLE]: [
[
[0, 0, 0, 1, 1, 0, 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, 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],
],
walk: [
],
[AnimationState.WALK]: [
[
[0, 0, 0, 1, 1, 0, 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, 1, 1, 1, 1, 0, 1],
[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],
@ -87,14 +104,13 @@ export const SpriteLibrary = {
[1, 0, 2, 1, 1, 2, 0, 0],
[1, 0, 1, 1, 1, 1, 0, 0],
[0, 0, 0, 0, 0, 1, 0, 0],
[0, 0, 0, 0, 0, 1, 1, 0]
]
]
[0, 0, 0, 0, 0, 1, 1, 0],
],
],
},
// 8x8 Beast - Bounding Cycle
beast: {
idle: [
[EntityType.BEAST]: {
[AnimationState.IDLE]: [
[
[0, 0, 0, 0, 0, 0, 0, 0],
[1, 0, 0, 0, 0, 0, 0, 1],
@ -103,10 +119,10 @@ export const SpriteLibrary = {
[1, 1, 1, 1, 1, 1, 1, 1],
[0, 1, 1, 2, 2, 1, 1, 0],
[0, 1, 0, 0, 0, 0, 1, 0],
[0, 1, 0, 0, 0, 0, 1, 0]
]
[0, 1, 0, 0, 0, 0, 1, 0],
],
walk: [
],
[AnimationState.WALK]: [
[
[1, 0, 0, 0, 0, 0, 0, 1],
[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, 0, 0, 0, 0, 1, 0],
[1, 1, 0, 0, 0, 0, 1, 1],
[0, 0, 0, 0, 0, 0, 0, 0]
[0, 0, 0, 0, 0, 0, 0, 0],
],
[
[0, 0, 0, 0, 0, 0, 0, 0],
@ -125,14 +141,13 @@ export const SpriteLibrary = {
[1, 1, 1, 1, 1, 1, 1, 1],
[0, 1, 1, 2, 2, 1, 1, 0],
[0, 0, 1, 0, 0, 1, 0, 0],
[0, 0, 1, 0, 0, 1, 0, 0]
]
]
[0, 0, 1, 0, 0, 1, 0, 0],
],
],
},
// 8x8 Elemental - Floating Pulse
elemental: {
idle: [
[EntityType.ELEMENTAL]: {
[AnimationState.IDLE]: [
[
[0, 0, 2, 1, 1, 2, 0, 0],
[0, 1, 1, 2, 2, 1, 1, 0],
@ -141,7 +156,7 @@ export const SpriteLibrary = {
[1, 1, 1, 3, 3, 1, 1, 1],
[0, 1, 1, 1, 1, 1, 1, 0],
[0, 0, 1, 1, 1, 1, 0, 0],
[0, 0, 0, 1, 1, 0, 0, 0]
[0, 0, 0, 1, 1, 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],
[0, 1, 1, 1, 1, 1, 1, 0],
[0, 0, 1, 1, 1, 1, 0, 0],
[0, 0, 0, 1, 1, 0, 0, 0]
]
]
[0, 0, 0, 1, 1, 0, 0, 0],
],
],
},
projectile: {
idle: [
[EntityType.PROJECTILE]: {
[AnimationState.IDLE]: [
[
[1, 1],
[1, 1]
]
]
}
[1, 1],
],
],
},
};

View file

@ -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
View file

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

View file

@ -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
View file

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

View file

@ -1,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
View file

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

View file

@ -1,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
View file

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

View file

@ -1,158 +0,0 @@
import { Engine } from './core/Engine.js';
import { InputSystem } from './systems/InputSystem.js';
import { MovementSystem } from './systems/MovementSystem.js';
import { PlayerControllerSystem } from './systems/PlayerControllerSystem.js';
import { CombatSystem } from './systems/CombatSystem.js';
import { AISystem } from './systems/AISystem.js';
import { AbsorptionSystem } from './systems/AbsorptionSystem.js';
import { SkillSystem } from './systems/SkillSystem.js';
import { StealthSystem } from './systems/StealthSystem.js';
import { ProjectileSystem } from './systems/ProjectileSystem.js';
import { SkillEffectSystem } from './systems/SkillEffectSystem.js';
import { HealthRegenerationSystem } from './systems/HealthRegenerationSystem.js';
import { DeathSystem } from './systems/DeathSystem.js';
import { MenuSystem } from './systems/MenuSystem.js';
import { RenderSystem } from './systems/RenderSystem.js';
import { UISystem } from './systems/UISystem.js';
// Components
import { Position } from './components/Position.js';
import { Velocity } from './components/Velocity.js';
import { Sprite } from './components/Sprite.js';
import { Health } from './components/Health.js';
import { Stats } from './components/Stats.js';
import { Evolution } from './components/Evolution.js';
import { Skills } from './components/Skills.js';
import { Inventory } from './components/Inventory.js';
import { Combat } from './components/Combat.js';
import { Stealth } from './components/Stealth.js';
import { AI } from './components/AI.js';
import { Absorbable } from './components/Absorbable.js';
import { SkillProgress } from './components/SkillProgress.js';
// Initialize game
const canvas = document.getElementById('game-canvas');
if (!canvas) {
console.error('Canvas element not found!');
} else {
const engine = new Engine(canvas);
// Add systems in order
engine.addSystem(new MenuSystem(engine));
engine.addSystem(new InputSystem());
engine.addSystem(new PlayerControllerSystem());
engine.addSystem(new StealthSystem());
engine.addSystem(new AISystem());
engine.addSystem(new MovementSystem());
engine.addSystem(new CombatSystem());
engine.addSystem(new ProjectileSystem());
engine.addSystem(new AbsorptionSystem());
engine.addSystem(new SkillSystem());
engine.addSystem(new SkillEffectSystem());
engine.addSystem(new HealthRegenerationSystem());
engine.addSystem(new DeathSystem());
engine.addSystem(new RenderSystem(engine));
engine.addSystem(new UISystem(engine));
// Create player entity
const player = engine.createEntity();
player.addComponent(new Position(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();
});
}

159
src/main.ts Normal file
View file

@ -0,0 +1,159 @@
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 { 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 { EntityType, ComponentType } from './core/Constants.ts';
import type { Entity } from './core/Entity.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 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();
player.addComponent(new Position(160, 120));
player.addComponent(new Velocity(0, 0));
player.addComponent(new Sprite('#00ff96', 14, 14, EntityType.SLIME));
player.addComponent(new Health(100));
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());
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;
}
for (let i = 0; i < 8; i++) {
const x = 20 + Math.random() * 280;
const y = 20 + Math.random() * 200;
const types = [EntityType.HUMANOID, EntityType.BEAST, EntityType.ELEMENTAL];
const type = types[Math.floor(Math.random() * types.length)];
createCreature(engine, x, y, type);
}
setInterval(() => {
const existingCreatures = engine
.getEntities()
.filter((e) => e.hasComponent(ComponentType.AI) && e !== player);
if (existingCreatures.length < 10) {
const x = 20 + Math.random() * 280;
const y = 20 + Math.random() * 200;
const types = [EntityType.HUMANOID, EntityType.BEAST, EntityType.ELEMENTAL];
const type = types[Math.floor(Math.random() * types.length)];
createCreature(engine, x, y, type);
}
}, 5000);
canvas.focus();
engine.start();
interface WindowWithGame {
gameEngine?: Engine;
player?: Entity;
}
(window as WindowWithGame).gameEngine = engine;
(window as WindowWithGame).player = player;
canvas.addEventListener('click', () => {
canvas.focus();
});
}

View file

@ -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
View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

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

View file

@ -1,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;
}
}

View file

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

View file

@ -1,84 +0,0 @@
import { Skill } from '../Skill.js';
import { Position } from '../../components/Position.js';
import { Velocity } from '../../components/Velocity.js';
import { Sprite } from '../../components/Sprite.js';
import { Health } from '../../components/Health.js';
export class SlimeGun extends Skill {
constructor() {
super('slime_gun', 'Slime Gun', 1.0);
this.description = 'Shoot a blob of slime at enemies (costs 1 HP)';
this.damage = 15;
this.range = 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;
}
}

View file

@ -0,0 +1,94 @@
import { Skill } from '../Skill.ts';
import { ComponentType, SystemName, EntityType } from '../../core/Constants.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;
return true;
}
}

View file

@ -1,77 +1,96 @@
import { System } from '../core/System.js';
import { GameConfig } from '../GameConfig.js';
import { System } from '../core/System.ts';
import { GameConfig } from '../GameConfig.ts';
import { SystemName, ComponentType } from '../core/Constants.ts';
import type { Entity } from '../core/Entity.ts';
import type { Health } from '../components/Health.ts';
import type { AI } from '../components/AI.ts';
import type { Position } from '../components/Position.ts';
import type { Velocity } from '../components/Velocity.ts';
import type { Stealth } from '../components/Stealth.ts';
import type { Evolution } from '../components/Evolution.ts';
import type { Sprite } from '../components/Sprite.ts';
import type { Stats } from '../components/Stats.ts';
import type { Combat } from '../components/Combat.ts';
import type { Intent } from '../components/Intent.ts';
import type { PlayerControllerSystem } from './PlayerControllerSystem.ts';
/**
* System responsible for managing AI behaviors (wandering, chasing, fleeing, combat).
*/
export class AISystem extends System {
constructor() {
super('AISystem');
this.requiredComponents = ['Position', 'Velocity', 'AI'];
super(SystemName.AI);
this.requiredComponents = [ComponentType.POSITION, ComponentType.VELOCITY, ComponentType.AI];
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.
* @param deltaTime - Time elapsed since last frame in seconds
* @param entities - Entities matching system requirements
*/
process(deltaTime: number, entities: Entity[]): void {
const playerController = this.engine.systems.find(
(s) => s.name === SystemName.PLAYER_CONTROLLER
) as PlayerControllerSystem | undefined;
const player = playerController ? playerController.getPlayerEntity() : null;
const playerPos = player?.getComponent('Position');
const playerPos = player?.getComponent<Position>(ComponentType.POSITION);
const config = GameConfig.AI;
entities.forEach(entity => {
const health = entity.getComponent('Health');
const ai = entity.getComponent('AI');
const position = entity.getComponent('Position');
const velocity = entity.getComponent('Velocity');
entities.forEach((entity) => {
const health = entity.getComponent<Health>(ComponentType.HEALTH);
const ai = entity.getComponent<AI>(ComponentType.AI);
const position = entity.getComponent<Position>(ComponentType.POSITION);
const velocity = entity.getComponent<Velocity>(ComponentType.VELOCITY);
if (!ai || !position || !velocity) return;
// Stop movement for dead entities
if (health && health.isDead() && !health.isProjectile) {
velocity.vx = 0;
velocity.vy = 0;
return;
}
// Update wander timer
ai.wanderChangeTime += deltaTime;
// Detect player
if (playerPos) {
const dx = playerPos.x - position.x;
const dy = playerPos.y - position.y;
const distance = Math.sqrt(dx * dx + dy * dy);
// Update awareness based on distance and player stealth
const playerStealth = player?.getComponent('Stealth');
const playerStealth = player?.getComponent<Stealth>(ComponentType.STEALTH);
const playerVisibility = playerStealth ? playerStealth.visibility : 1.0;
if (distance < ai.alertRadius) {
const detectionChance = (1 - distance / ai.alertRadius) * playerVisibility;
ai.updateAwareness(detectionChance * deltaTime * config.awarenessGainMultiplier);
} else {
ai.updateAwareness(-deltaTime * config.awarenessLossRate); // Lose awareness over time
ai.updateAwareness(-deltaTime * config.awarenessLossRate);
}
// Biological Reputation Logic
const playerEvolution = player?.getComponent('Evolution');
const playerEvolution = player?.getComponent<Evolution>(ComponentType.EVOLUTION);
const playerForm = playerEvolution ? playerEvolution.getDominantForm() : 'slime';
const entityType = entity.getComponent('Sprite')?.color === '#ffaa00' ? 'beast' :
entity.getComponent('Sprite')?.color === '#ff5555' ? 'humanoid' : 'other';
const sprite = entity.getComponent<Sprite>(ComponentType.SPRITE);
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 shouldFlee = false;
if (entityType === 'humanoid' && playerForm === 'human') {
// Humanoids are passive to human-form slime unless awareness is maxed (hostile action taken)
if (ai.awareness < config.passiveAwarenessThreshold) isPassive = true;
} else if (entityType === 'beast' && playerForm === 'beast') {
// Beasts might flee from a dominant beast player
const playerStats = player?.getComponent('Stats');
const entityStats = entity.getComponent('Stats');
const playerStats = player?.getComponent<Stats>(ComponentType.STATS);
const entityStats = entity.getComponent<Stats>(ComponentType.STATS);
if (playerStats && entityStats && playerStats.level > entityStats.level) {
shouldFlee = true;
}
}
// Behavior based on awareness, reputation, and distance
if (shouldFlee && ai.awareness > config.fleeAwarenessThreshold) {
ai.setBehavior('flee');
ai.state = 'fleeing';
@ -84,8 +103,7 @@ export class AISystem extends System {
}
} else if (ai.awareness > config.detectionAwarenessThreshold && distance < ai.chaseRadius) {
if (ai.behaviorType !== 'flee') {
// Check if in attack range - if so, use combat behavior
const combat = entity.getComponent('Combat');
const combat = entity.getComponent<Combat>(ComponentType.COMBAT);
if (combat && distance <= combat.attackRange) {
ai.setBehavior('combat');
ai.state = 'combat';
@ -102,8 +120,7 @@ export class AISystem extends System {
ai.clearTarget();
}
} else if (ai.behaviorType === 'chase') {
// Update from chase to combat if in range
const combat = entity.getComponent('Combat');
const combat = entity.getComponent<Combat>(ComponentType.COMBAT);
if (combat && distance <= combat.attackRange) {
ai.setBehavior('combat');
ai.state = 'combat';
@ -111,7 +128,6 @@ export class AISystem extends System {
}
}
// Execute behavior
switch (ai.behaviorType) {
case 'wander':
this.wander(entity, ai, velocity, deltaTime);
@ -129,10 +145,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';
// Change direction periodically
if (ai.wanderChangeTime >= ai.wanderChangeInterval) {
ai.wanderDirection = Math.random() * Math.PI * 2;
ai.wanderChangeTime = 0;
@ -143,15 +161,23 @@ export class AISystem extends System {
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;
const dx = targetPos.x - position.x;
const dy = targetPos.y - position.y;
const distance = Math.sqrt(dx * dx + dy * dy);
// Check if we should switch to combat
const combat = entity.getComponent('Combat');
const combat = entity.getComponent<Combat>(ComponentType.COMBAT);
if (combat && distance <= combat.attackRange) {
ai.setBehavior('combat');
ai.state = 'combat';
@ -169,7 +195,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;
ai.state = 'fleeing';
@ -184,29 +219,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;
ai.state = 'attacking';
// Stop moving when in combat range - let CombatSystem handle attacks
const dx = targetPos.x - position.x;
const dy = targetPos.y - position.y;
const distance = Math.sqrt(dx * dx + dy * dy);
const combat = entity.getComponent('Combat');
const combat = entity.getComponent<Combat>(ComponentType.COMBAT);
if (combat && distance > combat.attackRange) {
// Move closer if out of range
const speed = ai.wanderSpeed;
velocity.vx = (dx / distance) * speed;
velocity.vy = (dy / distance) * speed;
} else {
// Stop and face target
velocity.vx *= 0.5;
velocity.vy *= 0.5;
if (position) {
position.rotation = Math.atan2(dy, dx);
}
}
}
}
const intent = entity.getComponent<Intent>(ComponentType.INTENT);
if (intent) {
intent.setIntent('attack', { targetX: targetPos.x, targetY: targetPos.y });
}
}
}
}

View file

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

View file

@ -0,0 +1,170 @@
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);
}
}
}
/**
* Apply a random stat mutation (positive or negative) to an entity's stats.
* @param stats - The stats component to mutate
*/
applyMutation(stats: Stats): void {
type StatName = 'strength' | 'agility' | 'intelligence' | 'constitution' | 'perception';
const mutations: Array<{ stat: StatName; amount: number }> = [
{ stat: 'strength', amount: 5 },
{ stat: 'agility', amount: 5 },
{ stat: 'intelligence', amount: 5 },
{ stat: 'constitution', amount: 5 },
{ stat: 'perception', amount: 5 },
];
const mutation = mutations[Math.floor(Math.random() * mutations.length)];
stats[mutation.stat] += mutation.amount;
if (Math.random() < 0.3) {
const negativeStat = mutations[Math.floor(Math.random() * mutations.length)];
stats[negativeStat.stat] = Math.max(1, stats[negativeStat.stat] - 2);
}
}
}

View file

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

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

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,36 +1,58 @@
import { System } from '../core/System.js';
import { System } from '../core/System.ts';
import { SystemName } from '../core/Constants.ts';
import type { Engine } from '../core/Engine.ts';
import type { Entity } from '../core/Entity.ts';
interface MouseState {
x: number;
y: number;
buttons: Record<number, boolean>;
buttonsPrevious: Record<number, boolean>;
}
/**
* System responsible for capturing and managing keyboard and mouse input.
*/
export class InputSystem extends System {
keys: Record<string, boolean>;
keysPrevious: Record<string, boolean>;
mouse: MouseState;
constructor() {
super('InputSystem');
this.requiredComponents = []; // No required components - handles input globally
this.priority = 0; // Run first
super(SystemName.INPUT);
this.requiredComponents = [];
this.priority = 0;
this.keys = {};
this.keysPrevious = {}; // Track previous frame key states
this.keysPrevious = {};
this.mouse = {
x: 0,
y: 0,
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);
this.setupEventListeners();
}
setupEventListeners() {
/**
* Set up browser event listeners for keyboard and mouse.
*/
setupEventListeners(): void {
window.addEventListener('keydown', (e) => {
const key = e.key.toLowerCase();
const code = e.code.toLowerCase();
// Store by key name
this.keys[key] = true;
this.keys[code] = true;
// Handle special keys
if (key === ' ') {
this.keys['space'] = true;
}
@ -38,13 +60,11 @@ export class InputSystem extends System {
this.keys['space'] = true;
}
// Arrow keys
if (code === 'arrowup') this.keys['arrowup'] = true;
if (code === 'arrowdown') this.keys['arrowdown'] = true;
if (code === 'arrowleft') this.keys['arrowleft'] = true;
if (code === 'arrowright') this.keys['arrowright'] = true;
// Prevent default for game keys
if ([' ', 'w', 'a', 's', 'd', '1', '2', '3', '4', '5', '6', '7', '8', '9'].includes(key)) {
e.preventDefault();
}
@ -57,7 +77,6 @@ export class InputSystem extends System {
this.keys[key] = false;
this.keys[code] = false;
// Handle special keys
if (key === ' ') {
this.keys['space'] = false;
}
@ -65,7 +84,6 @@ export class InputSystem extends System {
this.keys['space'] = false;
}
// Arrow keys
if (code === 'arrowup') this.keys['arrowup'] = false;
if (code === 'arrowdown') this.keys['arrowdown'] = false;
if (code === 'arrowleft') this.keys['arrowleft'] = false;
@ -76,7 +94,6 @@ export class InputSystem extends System {
if (this.engine && this.engine.canvas) {
const canvas = this.engine.canvas;
const rect = canvas.getBoundingClientRect();
// Calculate scale factors between displayed size and internal resolution
const scaleX = canvas.width / rect.width;
const scaleY = canvas.height / rect.height;
@ -94,16 +111,17 @@ export class InputSystem extends System {
});
}
process(_deltaTime, _entities) {
// Don't update previous states here - that happens at end of frame
// This allows other systems to check isKeyJustPressed during the frame
}
/**
* Process input state (placeholder as processing happens via events).
* @param _deltaTime - Time elapsed
* @param _entities - Matching entities
*/
process(_deltaTime: number, _entities: Entity[]): void {}
/**
* Update previous states - called at end of frame
* Update previous frame states. Should be called at the end of each frame.
*/
updatePreviousStates() {
// Deep copy current states to previous for next frame
updatePreviousStates(): void {
this.keysPrevious = {};
for (const key in this.keys) {
this.keysPrevious[key] = this.keys[key];
@ -115,16 +133,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;
}
/**
* 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 isPressed = this.keys[keyLower] === true;
const wasPressed = this.keysPrevious[keyLower] === true;
@ -132,26 +154,30 @@ export class InputSystem extends System {
}
/**
* Get mouse position
* Get the current mouse position in world coordinates.
* @returns The mouse coordinates
*/
getMousePosition() {
getMousePosition(): { x: number; y: number } {
return { x: this.mouse.x, y: this.mouse.y };
}
/**
* 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;
}
/**
* 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 wasPressed = this.mouse.buttonsPrevious[button] === true;
return isPressed && !wasPressed;
}
}

View file

@ -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
View file

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

View file

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

View file

@ -0,0 +1,98 @@
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);
}
const canvas = this.engine.canvas;
if (position.x < 0) {
position.x = 0;
velocity.vx = 0;
} else if (position.x > canvas.width) {
position.x = canvas.width;
velocity.vx = 0;
}
if (position.y < 0) {
position.y = 0;
velocity.vy = 0;
} else if (position.y > canvas.height) {
position.y = canvas.height;
velocity.vy = 0;
}
});
}
}

View file

@ -1,70 +0,0 @@
import { System } from '../core/System.js';
export class PlayerControllerSystem extends System {
constructor() {
super('PlayerControllerSystem');
this.requiredComponents = ['Position', 'Velocity'];
this.priority = 5;
this.playerEntity = null;
}
process(deltaTime, entities) {
// Find player entity (first entity with player tag or specific component)
if (!this.playerEntity) {
this.playerEntity = entities.find(e => e.hasComponent('Evolution'));
}
if (!this.playerEntity) return;
const inputSystem = this.engine.systems.find(s => s.name === 'InputSystem');
if (!inputSystem) return;
const velocity = this.playerEntity.getComponent('Velocity');
const position = this.playerEntity.getComponent('Position');
if (!velocity || !position) return;
// Movement input
let moveX = 0;
let moveY = 0;
const moveSpeed = 100; // Scaled down for 320x240
if (inputSystem.isKeyPressed('w') || inputSystem.isKeyPressed('arrowup')) {
moveY -= 1;
}
if (inputSystem.isKeyPressed('s') || inputSystem.isKeyPressed('arrowdown')) {
moveY += 1;
}
if (inputSystem.isKeyPressed('a') || inputSystem.isKeyPressed('arrowleft')) {
moveX -= 1;
}
if (inputSystem.isKeyPressed('d') || inputSystem.isKeyPressed('arrowright')) {
moveX += 1;
}
// Normalize diagonal movement
if (moveX !== 0 && moveY !== 0) {
moveX *= 0.707;
moveY *= 0.707;
}
// Apply movement
velocity.vx = moveX * moveSpeed;
velocity.vy = moveY * moveSpeed;
// Face mouse or movement direction
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) {
position.rotation = Math.atan2(dy, dx);
} else if (moveX !== 0 || moveY !== 0) {
position.rotation = Math.atan2(moveY, moveX);
}
}
getPlayerEntity() {
return this.playerEntity;
}
}

View file

@ -0,0 +1,113 @@
import { System } from '../core/System.ts';
import { SystemName, ComponentType } from '../core/Constants.ts';
import type { Entity } from '../core/Entity.ts';
import type { Velocity } from '../components/Velocity.ts';
import type { Position } from '../components/Position.ts';
import type { Intent } from '../components/Intent.ts';
import type { Skills } from '../components/Skills.ts';
import type { InputSystem } from './InputSystem.ts';
/**
* System responsible for translating player input into movement and action intents.
*/
export class PlayerControllerSystem extends System {
playerEntity: Entity | null;
constructor() {
super(SystemName.PLAYER_CONTROLLER);
this.requiredComponents = [ComponentType.POSITION, ComponentType.VELOCITY];
this.priority = 5;
this.playerEntity = null;
}
/**
* Process player input and update the player entity's velocity and intent.
* @param deltaTime - Time elapsed since last frame in seconds
* @param entities - Entities matching system requirements
*/
process(deltaTime: number, entities: Entity[]): void {
if (!this.playerEntity) {
this.playerEntity = entities.find((e) => e.hasComponent(ComponentType.EVOLUTION)) || null;
}
if (!this.playerEntity) return;
const inputSystem = this.engine.systems.find((s) => s.name === SystemName.INPUT) as
| InputSystem
| undefined;
if (!inputSystem) return;
const velocity = this.playerEntity.getComponent<Velocity>(ComponentType.VELOCITY);
const position = this.playerEntity.getComponent<Position>(ComponentType.POSITION);
if (!velocity || !position) return;
let moveX = 0;
let moveY = 0;
const moveSpeed = 100;
if (!velocity.isLocked) {
if (inputSystem.isKeyPressed('w') || inputSystem.isKeyPressed('arrowup')) {
moveY -= 1;
}
if (inputSystem.isKeyPressed('s') || inputSystem.isKeyPressed('arrowdown')) {
moveY += 1;
}
if (inputSystem.isKeyPressed('a') || inputSystem.isKeyPressed('arrowleft')) {
moveX -= 1;
}
if (inputSystem.isKeyPressed('d') || inputSystem.isKeyPressed('arrowright')) {
moveX += 1;
}
if (moveX !== 0 && moveY !== 0) {
moveX *= 0.707;
moveY *= 0.707;
}
velocity.vx = moveX * moveSpeed;
velocity.vy = moveY * moveSpeed;
}
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) {
position.rotation = Math.atan2(dy, dx);
} else if (moveX !== 0 || moveY !== 0) {
position.rotation = Math.atan2(moveY, moveX);
}
const intent = this.playerEntity.getComponent<Intent>(ComponentType.INTENT);
const skills = this.playerEntity.getComponent<Skills>(ComponentType.SKILLS);
if (intent && skills) {
for (let i = 1; i <= 9; i++) {
const key = i.toString();
if (inputSystem.isKeyJustPressed(key)) {
const skillIndex = i - 1;
if (skillIndex < skills.activeSkills.length) {
const skillId = skills.activeSkills[skillIndex];
intent.setIntent('skill_use', { skillId });
break;
}
}
}
if (
inputSystem.isMouseButtonJustPressed(0) ||
inputSystem.isKeyJustPressed(' ') ||
inputSystem.isKeyJustPressed('space')
) {
intent.setIntent('attack', { targetX: mouse.x, targetY: mouse.y });
}
}
}
/**
* Get the current player entity.
* @returns The player entity
*/
getPlayerEntity(): Entity | null {
return this.playerEntity;
}
}

View file

@ -1,83 +0,0 @@
import { System } from '../core/System.js';
export class ProjectileSystem extends System {
constructor() {
super('ProjectileSystem');
this.requiredComponents = ['Position', 'Velocity'];
this.priority = 18;
}
process(deltaTime, entities) {
const playerController = this.engine.systems.find(s => s.name === 'PlayerControllerSystem');
const _player = playerController ? playerController.getPlayerEntity() : null;
entities.forEach(entity => {
const health = entity.getComponent('Health');
if (!health || !health.isProjectile) return;
const position = entity.getComponent('Position');
if (!position) return;
// Check range - remove if traveled beyond max range
if (entity.startX !== undefined && entity.startY !== undefined && entity.maxRange !== undefined) {
const dx = position.x - entity.startX;
const dy = position.y - entity.startY;
const distanceTraveled = Math.sqrt(dx * dx + dy * dy);
if (distanceTraveled >= entity.maxRange) {
this.engine.removeEntity(entity);
return;
}
}
// Check lifetime as backup
if (entity.lifetime !== undefined) {
entity.lifetime -= deltaTime;
if (entity.lifetime <= 0) {
this.engine.removeEntity(entity);
return;
}
}
// Check collisions with enemies
const allEntities = this.engine.getEntities();
allEntities.forEach(target => {
if (target.id === entity.owner) return;
if (target.id === entity.id) return;
if (!target.hasComponent('Health')) return;
if (target.getComponent('Health').isProjectile) return;
const targetPos = target.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 < 20) {
// Hit!
const targetHealth = target.getComponent('Health');
const damage = entity.damage || 10;
targetHealth.takeDamage(damage);
// If target is dead, mark it for immediate removal
if (targetHealth.isDead()) {
target.active = false;
// DeathSystem will handle removal
}
// Remove projectile
this.engine.removeEntity(entity);
}
});
// Boundary check
const canvas = this.engine.canvas;
if (position.x < 0 || position.x > canvas.width ||
position.y < 0 || position.y > canvas.height) {
this.engine.removeEntity(entity);
}
});
}
}

View file

@ -0,0 +1,107 @@
import { System } from '../core/System.ts';
import { Events } from '../core/EventBus.ts';
import { Palette } from '../core/Palette.ts';
import { SystemName, ComponentType } from '../core/Constants.ts';
import type { Entity } from '../core/Entity.ts';
import type { Health } from '../components/Health.ts';
import type { Position } from '../components/Position.ts';
import type { Velocity } from '../components/Velocity.ts';
import type { VFXSystem } from './VFXSystem.ts';
/**
* System responsible for managing projectile movement, range limits, lifetimes, and collisions.
*/
export class ProjectileSystem extends System {
constructor() {
super(SystemName.PROJECTILE);
this.requiredComponents = [ComponentType.POSITION, ComponentType.VELOCITY];
this.priority = 18;
}
/**
* Process logic for all projectiles, checking for range, lifetime, and target collisions.
* @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.isProjectile) return;
const position = entity.getComponent<Position>(ComponentType.POSITION);
if (!position) return;
if (
entity.startX !== undefined &&
entity.startY !== undefined &&
entity.maxRange !== undefined
) {
const dx = position.x - entity.startX;
const dy = position.y - entity.startY;
const distanceTraveled = Math.sqrt(dx * dx + dy * dy);
if (distanceTraveled >= entity.maxRange) {
this.engine.removeEntity(entity);
return;
}
}
if (entity.lifetime !== undefined) {
entity.lifetime -= deltaTime;
if (entity.lifetime <= 0) {
this.engine.removeEntity(entity);
return;
}
}
const allEntities = this.engine.getEntities();
allEntities.forEach((target) => {
if (target.id === entity.owner) return;
if (target.id === entity.id) return;
if (!target.hasComponent(ComponentType.HEALTH)) return;
const targetHealth = target.getComponent<Health>(ComponentType.HEALTH);
if (targetHealth && targetHealth.isProjectile) return;
const targetPos = target.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 < 8) {
const targetHealthComp = target.getComponent<Health>(ComponentType.HEALTH);
const damage = entity.damage || 10;
if (targetHealthComp) {
targetHealthComp.takeDamage(damage);
const vfxSystem = this.engine.systems.find((s) => s.name === SystemName.VFX) as
| VFXSystem
| undefined;
const velocity = entity.getComponent<Velocity>(ComponentType.VELOCITY);
if (vfxSystem) {
const angle = velocity ? Math.atan2(velocity.vy, velocity.vx) : null;
vfxSystem.createImpact(position.x, position.y, Palette.CYAN, angle);
}
if (targetHealthComp.isDead()) {
this.engine.emit(Events.ENTITY_DIED, { entity: target });
}
}
this.engine.removeEntity(entity);
}
});
const canvas = this.engine.canvas;
if (
position.x < 0 ||
position.x > canvas.width ||
position.y < 0 ||
position.y > canvas.height
) {
this.engine.removeEntity(entity);
}
});
}
}

View file

@ -1,488 +0,0 @@
import { System } from '../core/System.js';
import { Palette } from '../core/Palette.js';
import { SpriteLibrary } from '../core/SpriteLibrary.js';
export class RenderSystem extends System {
constructor(engine) {
super('RenderSystem');
this.requiredComponents = ['Position', 'Sprite'];
this.priority = 100; // Render last
this.engine = engine;
this.ctx = engine.ctx;
}
process(deltaTime, _entities) {
// Clear canvas
this.engine.clear();
// Draw background
this.drawBackground();
// Draw map
this.drawMap();
// Draw entities
// Get all entities including inactive ones for rendering dead absorbable entities
const allEntities = this.engine.entities;
allEntities.forEach(entity => {
const health = entity.getComponent('Health');
const evolution = entity.getComponent('Evolution');
// Skip inactive entities UNLESS they're dead and absorbable (for absorption window)
if (!entity.active) {
const absorbable = entity.getComponent('Absorbable');
if (health && health.isDead() && absorbable && !absorbable.absorbed) {
// Render dead absorbable entities even if inactive (fade them out)
this.drawEntity(entity, deltaTime, true); // Pass fade flag
return;
}
return; // Skip other inactive entities
}
// Don't render dead non-player entities (unless they're absorbable, handled above)
if (health && health.isDead() && !evolution) {
const absorbable = entity.getComponent('Absorbable');
if (!absorbable || absorbable.absorbed) {
return; // Skip dead non-absorbable entities
}
}
this.drawEntity(entity, deltaTime);
});
// Draw skill effects
this.drawSkillEffects();
}
drawBackground() {
const ctx = this.ctx;
const width = this.engine.canvas.width;
const height = this.engine.canvas.height;
// Solid background
ctx.fillStyle = Palette.VOID;
ctx.fillRect(0, 0, width, height);
// Dithered pattern or simple shapes for cave features
ctx.fillStyle = Palette.DARKER_BLUE;
for (let i = 0; i < 20; i++) {
// Snap to grid for pixel art look
const x = Math.floor((i * 70 + Math.sin(i) * 30) % width);
const y = Math.floor((i * 50 + Math.cos(i) * 40) % height);
const size = Math.floor(25 + (i % 4) * 15);
ctx.beginPath();
// Draw as rectangles or pixelated circles? Let's use Rects for now to match the style better or keep arcs but accept anti-aliasing
// Use integer coordinates strictly.
// Pixel Art style: use small squares instead of circles
ctx.fillRect(x, y, size, size);
}
}
drawMap() {
const tileMap = this.engine.tileMap;
if (!tileMap) return;
const ctx = this.ctx;
const tileSize = tileMap.tileSize;
// Draw walls
ctx.fillStyle = Palette.DARK_BLUE;
for (let r = 0; r < tileMap.rows; r++) {
for (let c = 0; c < tileMap.cols; c++) {
if (tileMap.getTile(c, r) === 1) { // 1 is wall
ctx.fillRect(c * tileSize, r * tileSize, tileSize, tileSize);
// Highlight top for 3D feel
ctx.fillStyle = Palette.ROYAL_BLUE;
ctx.fillRect(c * tileSize, r * tileSize, tileSize, 2);
ctx.fillStyle = Palette.DARK_BLUE;
}
}
}
}
drawEntity(entity, deltaTime, isDeadFade = false) {
const position = entity.getComponent('Position');
const sprite = entity.getComponent('Sprite');
const health = entity.getComponent('Health');
if (!position || !sprite) return;
this.ctx.save();
// Pixel snapping
const drawX = Math.floor(position.x);
const drawY = Math.floor(position.y);
// Fade out dead entities
let alpha = sprite.alpha;
if (isDeadFade && health && health.isDead()) {
const absorbable = entity.getComponent('Absorbable');
if (absorbable && !absorbable.absorbed) {
// Calculate fade based on time since death
const deathTime = entity.deathTime || Date.now();
const timeSinceDeath = (Date.now() - deathTime) / 1000;
const fadeTime = 3.0; // 3 seconds to fade (matches DeathSystem removal time)
alpha = Math.max(0.3, 1.0 - (timeSinceDeath / fadeTime));
}
}
this.ctx.globalAlpha = alpha;
this.ctx.translate(drawX, drawY);
// REMOVED GLOBAL ROTATION: this.ctx.rotate(position.rotation);
this.ctx.scale(sprite.scale, sprite.scale);
// Update animation time for slime morphing
if (sprite.shape === 'slime') {
sprite.animationTime += deltaTime;
sprite.morphAmount = Math.sin(sprite.animationTime * 3) * 0.2 + 0.8;
}
// Map legacy colors to new Palette if necessary
let drawColor = sprite.color;
if (sprite.shape === 'slime') drawColor = Palette.CYAN;
// Map other colors? For now keep them if they match, but we should enforce palette eventually.
// The previous code had specific hardcoded colors.
this.ctx.fillStyle = drawColor;
// Select appropriate animation state based on velocity
const velocity = entity.getComponent('Velocity');
if (velocity) {
const isMoving = Math.abs(velocity.vx) > 1 || Math.abs(velocity.vy) > 1;
sprite.animationState = isMoving ? 'walk' : 'idle';
}
// Lookup animation data
let spriteData = SpriteLibrary[sprite.shape];
if (!spriteData) {
spriteData = SpriteLibrary.slime; // Hard fallback
}
// Get animation frames for the current state
let frames = spriteData[sprite.animationState] || spriteData['idle'];
// If frames is still not an array (fallback for simple grids or missing states)
if (!frames || !Array.isArray(frames)) {
// If it's a 2D array (legacy/simple), wrap it
if (Array.isArray(spriteData) || Array.isArray(spriteData[0])) {
frames = [spriteData];
} else if (spriteData.idle) {
frames = spriteData.idle;
} else {
frames = SpriteLibrary.slime.idle;
}
}
// Update animation timing
if (!health || !health.isDead()) {
sprite.animationTime += deltaTime;
}
const currentFrameIdx = Math.floor(sprite.animationTime * sprite.animationSpeed) % frames.length;
const grid = frames[currentFrameIdx];
if (!grid || !grid.length) {
this.ctx.restore();
return;
}
const rows = grid.length;
const cols = grid[0].length;
// Calculate pixel size to fit the defined sprite dimensions
const pixelW = sprite.width / cols;
const pixelH = sprite.height / rows;
// Draw from center
const offsetX = -sprite.width / 2;
const offsetY = -sprite.height / 2;
// Horizontal Flipping based on rotation (facing left/right)
const isFlipped = Math.cos(position.rotation) < 0;
this.ctx.save();
if (isFlipped) {
this.ctx.scale(-1, 1);
}
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
const value = grid[r][c];
if (value === 0) continue;
// Determine color
if (value === 1) {
this.ctx.fillStyle = drawColor;
} else if (value === 2) {
this.ctx.fillStyle = Palette.WHITE;
} else if (value === 3) {
this.ctx.fillStyle = Palette.DARKER_BLUE;
}
// Draw pixel (snapped to nearest integer for crisp look)
this.ctx.fillRect(
offsetX + c * pixelW,
offsetY + r * pixelH,
Math.ceil(pixelW),
Math.ceil(pixelH)
);
}
}
this.ctx.restore();
// Draw health bar if entity has health (stays horizontal)
if (health && health.maxHp > 0 && !health.isProjectile) {
this.drawHealthBar(health, sprite);
}
// Draw combat indicator if attacking (This DOES rotate)
const combat = entity.getComponent('Combat');
if (combat && combat.isAttacking) {
this.ctx.save();
this.ctx.rotate(position.rotation);
this.drawAttackIndicator(combat, position);
this.ctx.restore();
}
// Draw stealth indicator
const stealth = entity.getComponent('Stealth');
if (stealth && stealth.isStealthed) {
this.drawStealthIndicator(stealth, sprite);
}
// Mutation Visual Effects - Simplified for pixel art
const evolution = entity.getComponent('Evolution');
if (evolution) {
if (evolution.mutationEffects.glowingBody) {
// Simple outline (square)
this.ctx.strokeStyle = Palette.WHITE;
this.ctx.lineWidth = 1;
this.ctx.strokeRect(-sprite.width / 2 - 2, -sprite.height / 2 - 2, sprite.width + 4, sprite.height + 4);
}
if (evolution.mutationEffects.electricSkin) {
// Sparks
if (Math.random() < 0.2) {
this.ctx.fillStyle = Palette.CYAN;
const sparkX = Math.floor((Math.random() - 0.5) * sprite.width);
const sparkY = Math.floor((Math.random() - 0.5) * sprite.height);
this.ctx.fillRect(sparkX, sparkY, 2, 2);
}
}
}
this.ctx.restore();
}
drawHealthBar(health, sprite) {
// Pixel art health bar
const ctx = this.ctx;
// Width relative to sprite, snapped to even number
const barWidth = Math.floor(sprite.width * 1.2);
const barHeight = 2;
const yOffset = Math.floor(sprite.height / 2 + 3);
const startX = -Math.floor(barWidth / 2);
const startY = -yOffset;
// Background (Dark Blue)
ctx.fillStyle = Palette.DARK_BLUE;
ctx.fillRect(startX, startY, barWidth, barHeight);
// Fill
const healthPercent = Math.max(0, health.hp / health.maxHp);
const fillWidth = Math.floor(barWidth * healthPercent);
// Color based on Health (Palette only)
// High: CYAN, Mid: SKY_BLUE, Low: WHITE (flashing)
if (healthPercent > 0.5) {
ctx.fillStyle = Palette.CYAN;
} else if (healthPercent > 0.25) {
ctx.fillStyle = Palette.SKY_BLUE;
} else {
// Flash white for low health
ctx.fillStyle = (Math.floor(Date.now() / 200) % 2 === 0) ? Palette.WHITE : Palette.ROYAL_BLUE;
}
ctx.fillRect(startX, startY, fillWidth, barHeight);
}
drawAttackIndicator(combat, _position) {
const ctx = this.ctx;
const length = 25; // Scaled down
const attackProgress = 1.0 - (combat.attackCooldown / 0.3); // 0 to 1 during attack animation
// Since we're already in entity's rotated coordinate space (ctx.rotate was applied),
// and position.rotation should match combat.attackDirection (set in CombatSystem),
// we just draw forward (angle 0) in local space
const angle = 0; // Forward in local rotated space
// Draw slime tentacle/extension
ctx.strokeStyle = `rgba(0, 255, 150, ${0.8 * attackProgress})`;
ctx.fillStyle = `rgba(0, 255, 150, ${0.6 * attackProgress})`;
ctx.lineWidth = 4; // Scaled down
ctx.lineCap = 'round';
// Tentacle extends outward during attack (forward from entity)
const tentacleLength = length * attackProgress;
const tentacleEndX = Math.cos(angle) * tentacleLength;
const tentacleEndY = Math.sin(angle) * tentacleLength;
// Draw curved tentacle
ctx.beginPath();
ctx.moveTo(0, 0);
// Add slight curve to tentacle
const midX = Math.cos(angle) * tentacleLength * 0.5;
const midY = Math.sin(angle) * tentacleLength * 0.5;
const perpX = -Math.sin(angle) * 3 * attackProgress;
const perpY = Math.cos(angle) * 3 * attackProgress;
ctx.quadraticCurveTo(midX + perpX, midY + perpY, tentacleEndX, tentacleEndY);
ctx.stroke();
// Draw impact point
if (attackProgress > 0.5) {
ctx.beginPath();
ctx.arc(tentacleEndX, tentacleEndY, 3 * attackProgress, 0, Math.PI * 2);
ctx.fill();
}
}
drawStealthIndicator(stealth, sprite) {
const ctx = this.ctx;
const radius = Math.max(sprite.width, sprite.height) / 2 + 5;
// Stealth ring
ctx.strokeStyle = `rgba(0, 255, 150, ${1 - stealth.visibility})`;
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(0, 0, radius, 0, Math.PI * 2);
ctx.stroke();
// Visibility indicator
if (stealth.visibility > 0.3) {
ctx.fillStyle = `rgba(255, 0, 0, ${(stealth.visibility - 0.3) * 2})`;
ctx.beginPath();
ctx.arc(0, -radius - 10, 3, 0, Math.PI * 2);
ctx.fill();
}
}
drawSkillEffects() {
const skillEffectSystem = this.engine.systems.find(s => s.name === 'SkillEffectSystem');
if (!skillEffectSystem) return;
const effects = skillEffectSystem.getEffects();
const ctx = this.ctx;
effects.forEach(effect => {
ctx.save();
switch (effect.type) {
case 'fire_breath':
this.drawFireBreath(ctx, effect);
break;
case 'pounce':
this.drawPounce(ctx, effect);
break;
case 'pounce_impact':
this.drawPounceImpact(ctx, effect);
break;
}
ctx.restore();
});
}
drawFireBreath(ctx, effect) {
const progress = Math.min(1.0, effect.time / effect.lifetime); // Clamp to 0-1
const alpha = Math.max(0, 1.0 - progress); // Ensure non-negative
// Draw fire cone
ctx.translate(effect.x, effect.y);
ctx.rotate(effect.angle);
// Cone gradient
const gradient = ctx.createRadialGradient(0, 0, 0, 0, 0, effect.range);
gradient.addColorStop(0, `rgba(255, 100, 0, ${alpha * 0.8})`);
gradient.addColorStop(0.5, `rgba(255, 200, 0, ${alpha * 0.6})`);
gradient.addColorStop(1, `rgba(255, 50, 0, ${alpha * 0.3})`);
ctx.fillStyle = gradient;
ctx.beginPath();
ctx.moveTo(0, 0);
ctx.arc(0, 0, effect.range, -effect.coneAngle / 2, effect.coneAngle / 2);
ctx.closePath();
ctx.fill();
// Fire particles
for (let i = 0; i < 20; i++) {
const angle = (Math.random() - 0.5) * effect.coneAngle;
const dist = Math.random() * effect.range * progress;
const x = Math.cos(angle) * dist;
const y = Math.sin(angle) * dist;
const size = 3 + Math.random() * 5;
ctx.fillStyle = `rgba(255, ${150 + Math.random() * 100}, 0, ${alpha})`;
ctx.beginPath();
ctx.arc(x, y, size, 0, Math.PI * 2);
ctx.fill();
}
}
drawPounce(ctx, effect) {
const progress = Math.min(1.0, effect.time / effect.lifetime); // Clamp to 0-1
const currentX = effect.startX + Math.cos(effect.angle) * effect.speed * effect.time;
const currentY = effect.startY + Math.sin(effect.angle) * effect.speed * effect.time;
// Draw dash trail
const alpha = Math.max(0, 1.0 - progress); // Ensure non-negative
ctx.strokeStyle = `rgba(255, 200, 0, ${alpha})`;
ctx.lineWidth = 4;
ctx.lineCap = 'round';
ctx.beginPath();
ctx.moveTo(effect.startX, effect.startY);
ctx.lineTo(currentX, currentY);
ctx.stroke();
// Draw impact point
const radius = Math.max(0, 15 * (1 - progress)); // Ensure non-negative radius
if (radius > 0) {
ctx.fillStyle = `rgba(255, 150, 0, ${alpha})`;
ctx.beginPath();
ctx.arc(currentX, currentY, radius, 0, Math.PI * 2);
ctx.fill();
}
}
drawPounceImpact(ctx, effect) {
const progress = Math.min(1.0, effect.time / effect.lifetime); // Clamp to 0-1
const alpha = Math.max(0, 1.0 - progress); // Ensure non-negative
const size = Math.max(0, 30 * (1 - progress)); // Ensure non-negative size
if (size > 0 && alpha > 0) {
// Impact ring
ctx.strokeStyle = `rgba(255, 200, 0, ${alpha})`;
ctx.lineWidth = 3;
ctx.beginPath();
ctx.arc(effect.x, effect.y, size, 0, Math.PI * 2);
ctx.stroke();
// Impact particles
for (let i = 0; i < 8; i++) {
const angle = (i / 8) * Math.PI * 2;
const dist = size * 0.7;
const x = effect.x + Math.cos(angle) * dist;
const y = effect.y + Math.sin(angle) * dist;
ctx.fillStyle = `rgba(255, 150, 0, ${alpha})`;
ctx.beginPath();
ctx.arc(x, y, 4, 0, Math.PI * 2);
ctx.fill();
}
}
}
}

591
src/systems/RenderSystem.ts Normal file
View file

@ -0,0 +1,591 @@
import { System } from '../core/System.ts';
import { Palette } from '../core/Palette.ts';
import { SpriteLibrary } from '../core/SpriteLibrary.ts';
import {
ComponentType,
SystemName,
AnimationState,
VFXType,
EntityType,
} 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 { Sprite } from '../components/Sprite.ts';
import type { Health } from '../components/Health.ts';
import type { Velocity } from '../components/Velocity.ts';
import type { Combat } from '../components/Combat.ts';
import type { Stealth } from '../components/Stealth.ts';
import type { Evolution } from '../components/Evolution.ts';
import type { Absorbable } from '../components/Absorbable.ts';
import type { VFXSystem } from './VFXSystem.ts';
import type { SkillEffectSystem, SkillEffect } from './SkillEffectSystem.ts';
/**
* System responsible for rendering all game elements, including background, map, entities, and VFX.
*/
export class RenderSystem extends System {
ctx: CanvasRenderingContext2D;
/**
* @param engine - The game engine instance
*/
constructor(engine: Engine) {
super(SystemName.RENDER);
this.requiredComponents = [ComponentType.POSITION, ComponentType.SPRITE];
this.priority = 100;
this.engine = engine;
this.ctx = engine.ctx;
}
/**
* Execute the rendering pipeline: clear, draw background, map, entities, and effects.
* @param deltaTime - Time elapsed since last frame in seconds
* @param _entities - Filtered active entities
*/
process(deltaTime: number, _entities: Entity[]): void {
this.engine.clear();
this.drawBackground();
this.drawMap();
const allEntities = this.engine.entities;
allEntities.forEach((entity) => {
const health = entity.getComponent<Health>(ComponentType.HEALTH);
const evolution = entity.getComponent<Evolution>(ComponentType.EVOLUTION);
if (!entity.active) {
const absorbable = entity.getComponent<Absorbable>(ComponentType.ABSORBABLE);
if (health && health.isDead() && absorbable && !absorbable.absorbed) {
this.drawEntity(entity, deltaTime, true);
return;
}
return;
}
if (health && health.isDead() && !evolution) {
const absorbable = entity.getComponent<Absorbable>(ComponentType.ABSORBABLE);
if (!absorbable || absorbable.absorbed) {
return;
}
}
this.drawEntity(entity, deltaTime);
});
this.drawSkillEffects();
this.drawVFX();
}
/**
* Draw the cave background with dithered patterns.
*/
drawBackground(): void {
const ctx = this.ctx;
const width = this.engine.canvas.width;
const height = this.engine.canvas.height;
ctx.fillStyle = Palette.VOID;
ctx.fillRect(0, 0, width, height);
ctx.fillStyle = Palette.DARKER_BLUE;
for (let i = 0; i < 20; i++) {
const x = Math.floor((i * 70 + Math.sin(i) * 30) % width);
const y = Math.floor((i * 50 + Math.cos(i) * 40) % height);
const size = Math.floor(25 + (i % 4) * 15);
ctx.fillRect(x, y, size, size);
}
}
/**
* Draw the static tile-based map walls and highlights.
*/
drawMap(): void {
const tileMap = this.engine.tileMap;
if (!tileMap) return;
const ctx = this.ctx;
const tileSize = tileMap.tileSize;
ctx.fillStyle = Palette.DARK_BLUE;
for (let r = 0; r < tileMap.rows; r++) {
for (let c = 0; c < tileMap.cols; c++) {
if (tileMap.getTile(c, r) === 1) {
ctx.fillRect(c * tileSize, r * tileSize, tileSize, tileSize);
ctx.fillStyle = Palette.ROYAL_BLUE;
ctx.fillRect(c * tileSize, r * tileSize, tileSize, 2);
ctx.fillStyle = Palette.DARK_BLUE;
}
}
}
}
/**
* Draw an individual entity, including its pixel-art sprite, health bar, and indicators.
* @param entity - The entity to render
* @param deltaTime - Time elapsed
* @param isDeadFade - Whether to apply death fade effect
*/
drawEntity(entity: Entity, deltaTime: number, isDeadFade = false): void {
const position = entity.getComponent<Position>(ComponentType.POSITION);
const sprite = entity.getComponent<Sprite>(ComponentType.SPRITE);
const health = entity.getComponent<Health>(ComponentType.HEALTH);
if (!position || !sprite) return;
this.ctx.save();
const drawX = Math.floor(position.x);
const drawY = Math.floor(position.y);
let alpha = sprite.alpha;
if (isDeadFade && health && health.isDead()) {
const absorbable = entity.getComponent<Absorbable>(ComponentType.ABSORBABLE);
if (absorbable && !absorbable.absorbed) {
const deathTime = entity.deathTime || Date.now();
const timeSinceDeath = (Date.now() - deathTime) / 1000;
const fadeTime = 3.0;
alpha = Math.max(0.3, 1.0 - timeSinceDeath / fadeTime);
}
}
this.ctx.globalAlpha = alpha;
this.ctx.translate(drawX, drawY + (sprite.yOffset || 0));
this.ctx.scale(sprite.scale, sprite.scale);
if (sprite.shape === EntityType.SLIME) {
sprite.animationTime += deltaTime;
sprite.morphAmount = Math.sin(sprite.animationTime * 3) * 0.2 + 0.8;
}
let drawColor = sprite.color;
if (sprite.shape === EntityType.SLIME) drawColor = Palette.CYAN;
this.ctx.fillStyle = drawColor;
const velocity = entity.getComponent<Velocity>(ComponentType.VELOCITY);
if (velocity) {
const isMoving = Math.abs(velocity.vx) > 1 || Math.abs(velocity.vy) > 1;
sprite.animationState = isMoving ? AnimationState.WALK : AnimationState.IDLE;
}
let spriteData = SpriteLibrary[sprite.shape as string];
if (!spriteData) {
spriteData = SpriteLibrary[EntityType.SLIME];
}
let frames = spriteData[sprite.animationState as string] || spriteData[AnimationState.IDLE];
if (!frames || !Array.isArray(frames)) {
// Fallback to default slime animation if data structure is unexpected
frames = SpriteLibrary[EntityType.SLIME][AnimationState.IDLE];
}
if (!health || !health.isDead()) {
sprite.animationTime += deltaTime;
}
const currentFrameIdx =
Math.floor(sprite.animationTime * sprite.animationSpeed) % frames.length;
const grid = frames[currentFrameIdx];
if (!grid || !grid.length) {
this.ctx.restore();
return;
}
const rows = grid.length;
const cols = grid[0].length;
const pixelW = sprite.width / cols;
const pixelH = sprite.height / rows;
const offsetX = -sprite.width / 2;
const offsetY = -sprite.height / 2;
const isFlipped = Math.cos(position.rotation) < 0;
this.ctx.save();
if (isFlipped) {
this.ctx.scale(-1, 1);
}
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
const value = grid[r][c];
if (value === 0) continue;
if (value === 1) {
this.ctx.fillStyle = drawColor;
} else if (value === 2) {
this.ctx.fillStyle = Palette.WHITE;
} else if (value === 3) {
this.ctx.fillStyle = Palette.DARKER_BLUE;
}
this.ctx.fillRect(
offsetX + c * pixelW,
offsetY + r * pixelH,
Math.ceil(pixelW),
Math.ceil(pixelH)
);
}
}
this.ctx.restore();
if (health && health.maxHp > 0 && !health.isProjectile) {
this.drawHealthBar(health, sprite);
}
const combat = entity.getComponent<Combat>(ComponentType.COMBAT);
if (combat && combat.isAttacking) {
this.ctx.save();
this.ctx.rotate(position.rotation);
this.drawAttackIndicator(combat, entity);
this.ctx.restore();
}
const stealth = entity.getComponent<Stealth>(ComponentType.STEALTH);
if (stealth && stealth.isStealthed) {
this.drawStealthIndicator(stealth, sprite);
}
const evolution = entity.getComponent<Evolution>(ComponentType.EVOLUTION);
if (evolution) {
if (evolution.mutationEffects.glowingBody) {
this.drawGlowEffect(sprite);
}
if (evolution.mutationEffects.electricSkin) {
if (Math.random() < 0.2) {
this.ctx.fillStyle = Palette.CYAN;
const sparkX = Math.floor((Math.random() - 0.5) * sprite.width);
const sparkY = Math.floor((Math.random() - 0.5) * sprite.height);
this.ctx.fillRect(sparkX, sparkY, 2, 2);
}
}
}
this.ctx.restore();
}
/**
* Draw all active visual effects particles.
*/
drawVFX(): void {
const vfxSystem = this.engine.systems.find((s) => s.name === SystemName.VFX) as
| VFXSystem
| undefined;
if (!vfxSystem) return;
const ctx = this.ctx;
const particles = vfxSystem.getParticles();
particles.forEach((p) => {
ctx.fillStyle = p.color;
ctx.globalAlpha = p.type === VFXType.IMPACT ? Math.min(1, p.lifetime / 0.3) : 0.8;
const x = Math.floor(p.x);
const y = Math.floor(p.y);
const size = Math.floor(p.size);
ctx.fillRect(x - size / 2, y - size / 2, size, size);
});
ctx.globalAlpha = 1.0;
}
/**
* Draw a health bar above an entity.
*/
drawHealthBar(health: Health, sprite: Sprite): void {
const ctx = this.ctx;
const barWidth = Math.floor(sprite.width * 1.2);
const barHeight = 2;
const yOffset = Math.floor(sprite.height / 2 + 3);
const startX = -Math.floor(barWidth / 2);
const startY = -yOffset;
ctx.fillStyle = Palette.DARK_BLUE;
ctx.fillRect(startX, startY, barWidth, barHeight);
const healthPercent = Math.max(0, health.hp / health.maxHp);
const fillWidth = Math.floor(barWidth * healthPercent);
if (healthPercent > 0.5) {
ctx.fillStyle = Palette.CYAN;
} else if (healthPercent > 0.25) {
ctx.fillStyle = Palette.SKY_BLUE;
} else {
ctx.fillStyle = Math.floor(Date.now() / 200) % 2 === 0 ? Palette.WHITE : Palette.ROYAL_BLUE;
}
ctx.fillRect(startX, startY, fillWidth, barHeight);
}
/**
* Draw an animation indicating a melee attack.
*/
drawAttackIndicator(combat: Combat, entity: Entity): void {
const ctx = this.ctx;
const sprite = entity.getComponent<Sprite>(ComponentType.SPRITE);
if (!sprite) return;
const t = 1.0 - combat.attackCooldown / 0.3;
const alpha = Math.sin(Math.PI * t);
const range = combat.attackRange;
ctx.save();
if (sprite.shape === EntityType.SLIME) {
ctx.strokeStyle = Palette.CYAN;
ctx.lineWidth = 3;
ctx.lineCap = 'round';
ctx.globalAlpha = alpha;
const length = range * Math.sin(Math.PI * t);
ctx.beginPath();
ctx.moveTo(0, 0);
ctx.lineTo(length, 0);
ctx.stroke();
ctx.fillStyle = Palette.WHITE;
ctx.beginPath();
ctx.arc(length, 0, 2, 0, Math.PI * 2);
ctx.fill();
} else if (sprite.shape === EntityType.BEAST) {
ctx.strokeStyle = Palette.WHITE;
ctx.lineWidth = 2;
ctx.globalAlpha = alpha;
const radius = range;
const angleRange = Math.PI * 0.6;
const start = -angleRange / 2 + t * angleRange;
ctx.beginPath();
ctx.arc(0, 0, radius, start - 0.5, start + 0.5);
ctx.stroke();
} else if (sprite.shape === EntityType.HUMANOID) {
ctx.strokeStyle = `rgba(255, 255, 255, ${alpha})`;
ctx.lineWidth = 4;
const radius = range;
const sweep = Math.PI * 0.8;
const startAngle = -sweep / 2;
ctx.beginPath();
ctx.arc(0, 0, radius, startAngle, startAngle + sweep * t);
ctx.stroke();
} else {
ctx.fillStyle = `rgba(255, 255, 255, ${alpha})`;
const size = 15 * t;
ctx.beginPath();
ctx.arc(10, 0, size, 0, Math.PI * 2);
ctx.fill();
}
ctx.restore();
}
/**
* Draw an indicator circle around a stealthed entity.
*/
drawStealthIndicator(stealth: Stealth, sprite: Sprite): void {
const ctx = this.ctx;
const radius = Math.max(sprite.width, sprite.height) / 2 + 5;
ctx.strokeStyle = `rgba(0, 255, 150, ${1 - stealth.visibility})`;
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(0, 0, radius, 0, Math.PI * 2);
ctx.stroke();
if (stealth.visibility > 0.3) {
ctx.fillStyle = `rgba(255, 0, 0, ${(stealth.visibility - 0.3) * 2})`;
ctx.beginPath();
ctx.arc(0, -radius - 10, 3, 0, Math.PI * 2);
ctx.fill();
}
}
/**
* Draw a glowing effect around an entity with bioluminescence.
*/
drawGlowEffect(sprite: Sprite): void {
const ctx = this.ctx;
const time = Date.now() * 0.001; // Time in seconds for pulsing
const pulse = 0.5 + Math.sin(time * 3) * 0.3; // Pulsing between 0.2 and 0.8
const baseRadius = Math.max(sprite.width, sprite.height) / 2;
const glowRadius = baseRadius + 4 + pulse * 2;
// Create radial gradient for soft glow
const gradient = ctx.createRadialGradient(0, 0, baseRadius, 0, 0, glowRadius);
gradient.addColorStop(0, `rgba(255, 255, 255, ${0.4 * pulse})`);
gradient.addColorStop(0.5, `rgba(0, 230, 255, ${0.3 * pulse})`);
gradient.addColorStop(1, 'rgba(0, 230, 255, 0)');
// Draw multiple layers for a softer glow effect
ctx.save();
ctx.globalCompositeOperation = 'screen';
// Outer glow layer
ctx.fillStyle = gradient;
ctx.beginPath();
ctx.arc(0, 0, glowRadius, 0, Math.PI * 2);
ctx.fill();
// Inner bright core
const innerGradient = ctx.createRadialGradient(0, 0, 0, 0, 0, baseRadius * 0.6);
innerGradient.addColorStop(0, `rgba(255, 255, 255, ${0.6 * pulse})`);
innerGradient.addColorStop(1, 'rgba(0, 230, 255, 0)');
ctx.fillStyle = innerGradient;
ctx.beginPath();
ctx.arc(0, 0, baseRadius * 0.6, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
}
/**
* Draw active skill effects (cones, impacts, etc.).
*/
drawSkillEffects(): void {
const skillEffectSystem = this.engine.systems.find(
(s) => s.name === SystemName.SKILL_EFFECT
) as SkillEffectSystem | undefined;
if (!skillEffectSystem) return;
const effects = skillEffectSystem.getEffects();
const ctx = this.ctx;
effects.forEach((effect) => {
ctx.save();
switch (effect.type) {
case 'fire_breath':
this.drawFireBreath(ctx, effect);
break;
case 'pounce':
this.drawPounce(ctx, effect);
break;
case 'pounce_impact':
this.drawPounceImpact(ctx, effect);
break;
}
ctx.restore();
});
}
/**
* Draw a fire breath cone effect.
*/
drawFireBreath(ctx: CanvasRenderingContext2D, effect: SkillEffect): void {
if (!effect.x || !effect.y || !effect.angle || !effect.range || !effect.coneAngle) return;
const progress = Math.min(1.0, effect.time / effect.lifetime);
const alpha = Math.max(0, 1.0 - progress);
ctx.translate(effect.x, effect.y);
ctx.rotate(effect.angle);
const gradient = ctx.createRadialGradient(0, 0, 0, 0, 0, effect.range);
gradient.addColorStop(0, `rgba(255, 100, 0, ${alpha * 0.8})`);
gradient.addColorStop(0.5, `rgba(255, 200, 0, ${alpha * 0.6})`);
gradient.addColorStop(1, `rgba(255, 50, 0, ${alpha * 0.3})`);
ctx.fillStyle = gradient;
ctx.beginPath();
ctx.moveTo(0, 0);
ctx.arc(0, 0, effect.range, -effect.coneAngle / 2, effect.coneAngle / 2);
ctx.closePath();
ctx.fill();
for (let i = 0; i < 20; i++) {
const angle = (Math.random() - 0.5) * effect.coneAngle;
const dist = Math.random() * effect.range * progress;
const x = Math.cos(angle) * dist;
const y = Math.sin(angle) * dist;
const size = 3 + Math.random() * 5;
ctx.fillStyle = `rgba(255, ${150 + Math.random() * 100}, 0, ${alpha})`;
ctx.beginPath();
ctx.arc(x, y, size, 0, Math.PI * 2);
ctx.fill();
}
}
/**
* Draw a pounce dash effect with trails.
*/
drawPounce(ctx: CanvasRenderingContext2D, effect: SkillEffect): void {
if (!effect.startX || !effect.startY || !effect.angle) return;
const progress = Math.min(1.0, effect.time / effect.lifetime);
let currentX: number, currentY: number;
if (effect.caster) {
const pos = effect.caster.getComponent<Position>(ComponentType.POSITION);
if (pos) {
currentX = pos.x;
currentY = pos.y;
} else {
return;
}
} else {
currentX = effect.startX + Math.cos(effect.angle) * (effect.speed || 400) * effect.time;
currentY = effect.startY + Math.sin(effect.angle) * (effect.speed || 400) * effect.time;
}
ctx.globalAlpha = Math.max(0, 0.3 * (1 - progress));
ctx.fillStyle = Palette.VOID;
ctx.beginPath();
ctx.ellipse(effect.startX, effect.startY, 10, 5, 0, 0, Math.PI * 2);
ctx.fill();
const alpha = Math.max(0, 0.8 * (1.0 - progress));
ctx.strokeStyle = `rgba(255, 200, 0, ${alpha})`;
ctx.lineWidth = 2;
ctx.lineCap = 'round';
ctx.beginPath();
ctx.moveTo(effect.startX, effect.startY);
ctx.lineTo(currentX, currentY);
ctx.stroke();
const ringSize = progress * 40;
ctx.strokeStyle = `rgba(255, 255, 255, ${0.4 * (1 - progress)})`;
ctx.lineWidth = 1;
ctx.beginPath();
ctx.arc(effect.startX, effect.startY, ringSize, 0, Math.PI * 2);
ctx.stroke();
}
/**
* Draw an impact ring and particles from a pounce landing.
*/
drawPounceImpact(ctx: CanvasRenderingContext2D, effect: SkillEffect): void {
if (!effect.x || !effect.y) return;
const progress = Math.min(1.0, effect.time / effect.lifetime);
const alpha = Math.max(0, 1.0 - progress);
const size = Math.max(0, 30 * (1 - progress));
if (size > 0 && alpha > 0) {
ctx.strokeStyle = `rgba(255, 200, 0, ${alpha})`;
ctx.lineWidth = 3;
ctx.beginPath();
ctx.arc(effect.x, effect.y, size, 0, Math.PI * 2);
ctx.stroke();
for (let i = 0; i < 8; i++) {
const angle = (i / 8) * Math.PI * 2;
const dist = size * 0.7;
const x = effect.x + Math.cos(angle) * dist;
const y = effect.y + Math.sin(angle) * dist;
ctx.fillStyle = `rgba(255, 150, 0, ${alpha})`;
ctx.beginPath();
ctx.arc(x, y, 4, 0, Math.PI * 2);
ctx.fill();
}
}
}
}

View file

@ -1,42 +0,0 @@
import { System } from '../core/System.js';
/**
* System to track and render skill effects (Fire Breath, Pounce, etc.)
*/
export class SkillEffectSystem extends System {
constructor() {
super('SkillEffectSystem');
this.requiredComponents = []; // No required components
this.priority = 50; // Run after skills but before rendering
this.activeEffects = [];
}
process(deltaTime, _entities) {
// Update all active effects
for (let i = this.activeEffects.length - 1; i >= 0; i--) {
const effect = this.activeEffects[i];
effect.lifetime -= deltaTime;
effect.time += deltaTime;
if (effect.lifetime <= 0) {
this.activeEffects.splice(i, 1);
}
}
}
/**
* Add a skill effect
*/
addEffect(effect) {
this.activeEffects.push(effect);
}
/**
* Get all active effects
*/
getEffects() {
return this.activeEffects;
}
}

View file

@ -0,0 +1,74 @@
import { System } from '../core/System.ts';
import { SystemName } from '../core/Constants.ts';
import type { Entity } from '../core/Entity.ts';
export interface SkillEffect {
lifetime: number;
time: number;
type?: string;
x?: number;
y?: number;
angle?: number;
range?: number;
coneAngle?: number;
caster?: Entity;
startX?: number;
startY?: number;
speed?: number;
onUpdate?: (deltaTime: number) => void;
onComplete?: () => void;
}
/**
* System responsible for tracking and updating temporary active skill effects like fire breath or pounce trails.
*/
export class SkillEffectSystem extends System {
activeEffects: SkillEffect[];
constructor() {
super(SystemName.SKILL_EFFECT);
this.requiredComponents = [];
this.priority = 50;
this.activeEffects = [];
}
/**
* Update all active effects, removing them when their lifetime expires.
* @param deltaTime - Time elapsed since last frame in seconds
* @param _entities - Filtered entities
*/
process(deltaTime: number, _entities: Entity[]): void {
for (let i = this.activeEffects.length - 1; i >= 0; i--) {
const effect = this.activeEffects[i];
effect.lifetime -= deltaTime;
effect.time += deltaTime;
if (effect.onUpdate) {
effect.onUpdate(deltaTime);
}
if (effect.lifetime <= 0) {
if (effect.onComplete) {
effect.onComplete();
}
this.activeEffects.splice(i, 1);
}
}
}
/**
* Add a new visual skill effect to the system.
* @param effect - The effect data object
*/
addEffect(effect: SkillEffect): void {
this.activeEffects.push(effect);
}
/**
* Get the list of currently active skill effects.
* @returns Array of active effects
*/
getEffects(): SkillEffect[] {
return this.activeEffects;
}
}

View file

@ -1,53 +0,0 @@
import { System } from '../core/System.js';
import { SkillRegistry } from '../skills/SkillRegistry.js';
export class SkillSystem extends System {
constructor() {
super('SkillSystem');
this.requiredComponents = ['Skills'];
this.priority = 30;
}
process(deltaTime, entities) {
const inputSystem = this.engine.systems.find(s => s.name === 'InputSystem');
if (!inputSystem) return;
entities.forEach(entity => {
const skills = entity.getComponent('Skills');
if (!skills) return;
// Update cooldowns
skills.updateCooldowns(deltaTime);
// Check for skill activation (number keys 1-9)
for (let i = 1; i <= 9; i++) {
const key = i.toString();
if (inputSystem.isKeyJustPressed(key)) {
const skillIndex = i - 1;
if (skillIndex < skills.activeSkills.length) {
const skillId = skills.activeSkills[skillIndex];
if (!skills.isOnCooldown(skillId)) {
this.activateSkill(entity, skillId);
}
}
}
}
});
}
activateSkill(entity, skillId) {
const skill = SkillRegistry.get(skillId);
if (!skill) {
console.warn(`Skill not found: ${skillId}`);
return;
}
if (skill.activate(entity, this.engine)) {
const skills = entity.getComponent('Skills');
if (skills) {
skills.setCooldown(skillId, skill.cooldown);
}
}
}
}

View file

@ -0,0 +1,60 @@
import { System } from '../core/System.ts';
import { SkillRegistry } from '../skills/SkillRegistry.ts';
import { SystemName, ComponentType } from '../core/Constants.ts';
import type { Entity } from '../core/Entity.ts';
import type { Skills } from '../components/Skills.ts';
import type { Intent } from '../components/Intent.ts';
/**
* System responsible for managing skill cooldowns and activating skills based on entity intent.
*/
export class SkillSystem extends System {
constructor() {
super(SystemName.SKILL);
this.requiredComponents = [ComponentType.SKILLS];
this.priority = 30;
}
/**
* Process all entities with skills, updating cooldowns and activating skills if intended.
* @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 skills = entity.getComponent<Skills>(ComponentType.SKILLS);
const intent = entity.getComponent<Intent>(ComponentType.INTENT);
if (!skills) return;
skills.updateCooldowns(deltaTime);
if (intent && intent.action === 'skill_use') {
const skillId = intent.data.skillId;
if (skillId && !skills.isOnCooldown(skillId)) {
this.activateSkill(entity, skillId);
}
intent.clear();
}
});
}
/**
* Activate a specific skill for an entity.
* @param entity - The entity performing the skill
* @param skillId - The ID of the skill to activate
*/
activateSkill(entity: Entity, skillId: string): void {
const skill = SkillRegistry.get(skillId);
if (!skill) {
console.warn(`Skill not found: ${skillId}`);
return;
}
if (skill.activate(entity, this.engine)) {
const skills = entity.getComponent<Skills>(ComponentType.SKILLS);
if (skills) {
skills.setCooldown(skillId, skill.cooldown);
}
}
}
}

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