feat: migrate JavaScript files to TypeScript, enhancing type safety and maintainability across the codebase

This commit is contained in:
Juan Sebastián Montoya 2026-01-06 21:51:00 -05:00
parent 3db2bb9160
commit c582f2004e
107 changed files with 5876 additions and 3588 deletions

View file

@ -1,7 +1,22 @@
import { System } from '../core/System.js';
import { GameConfig } from '../GameConfig.js';
import { SystemName, ComponentType } from '../core/Constants.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(SystemName.AI);
@ -9,70 +24,73 @@ export class AISystem extends System {
this.priority = 15;
}
process(deltaTime, entities) {
const playerController = this.engine.systems.find(s => s.name === SystemName.PLAYER_CONTROLLER);
/**
* 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(ComponentType.POSITION);
const playerPos = player?.getComponent<Position>(ComponentType.POSITION);
const config = GameConfig.AI;
entities.forEach(entity => {
const health = entity.getComponent(ComponentType.HEALTH);
const ai = entity.getComponent(ComponentType.AI);
const position = entity.getComponent(ComponentType.POSITION);
const velocity = entity.getComponent(ComponentType.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(ComponentType.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(ComponentType.EVOLUTION);
const playerEvolution = player?.getComponent<Evolution>(ComponentType.EVOLUTION);
const playerForm = playerEvolution ? playerEvolution.getDominantForm() : 'slime';
const entityType = entity.getComponent(ComponentType.SPRITE)?.color === '#ffaa00' ? 'beast' :
entity.getComponent(ComponentType.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(ComponentType.STATS);
const entityStats = entity.getComponent(ComponentType.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';
@ -85,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(ComponentType.COMBAT);
const combat = entity.getComponent<Combat>(ComponentType.COMBAT);
if (combat && distance <= combat.attackRange) {
ai.setBehavior('combat');
ai.state = 'combat';
@ -103,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(ComponentType.COMBAT);
const combat = entity.getComponent<Combat>(ComponentType.COMBAT);
if (combat && distance <= combat.attackRange) {
ai.setBehavior('combat');
ai.state = 'combat';
@ -112,28 +128,29 @@ export class AISystem extends System {
}
}
// Execute behavior
switch (ai.behaviorType) {
case 'wander':
this.wander(entity, ai, velocity, deltaTime);
break;
case 'chase':
this.chase(entity, ai, velocity, position, playerPos);
break;
case 'flee':
this.flee(entity, ai, velocity, position, playerPos);
break;
case 'combat':
this.combat(entity, ai, velocity, position, playerPos);
break;
case 'wander':
this.wander(entity, ai, velocity, deltaTime);
break;
case 'chase':
this.chase(entity, ai, velocity, position, playerPos);
break;
case 'flee':
this.flee(entity, ai, velocity, position, playerPos);
break;
case 'combat':
this.combat(entity, ai, velocity, position, playerPos);
break;
}
});
}
wander(entity, ai, velocity, _deltaTime) {
/**
* 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;
@ -144,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(ComponentType.COMBAT);
const combat = entity.getComponent<Combat>(ComponentType.COMBAT);
if (combat && distance <= combat.attackRange) {
ai.setBehavior('combat');
ai.state = 'combat';
@ -170,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';
@ -185,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(ComponentType.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);
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,146 +0,0 @@
import { System } from '../core/System.js';
import { GameConfig } from '../GameConfig.js';
import { Events } from '../core/EventBus.js';
import { SystemName, ComponentType } from '../core/Constants.js';
export class AbsorptionSystem extends System {
constructor() {
super(SystemName.ABSORPTION);
this.requiredComponents = [ComponentType.POSITION, ComponentType.ABSORBABLE];
this.priority = 25;
}
process(_deltaTime, _entities) {
const playerController = this.engine.systems.find(s => s.name === SystemName.PLAYER_CONTROLLER);
const player = playerController ? playerController.getPlayerEntity() : null;
if (!player) return;
const playerPos = player.getComponent(ComponentType.POSITION);
const playerEvolution = player.getComponent(ComponentType.EVOLUTION);
const playerSkills = player.getComponent(ComponentType.SKILLS);
const playerStats = player.getComponent(ComponentType.STATS);
const skillProgress = player.getComponent(ComponentType.SKILL_PROGRESS);
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(ComponentType.HEALTH);
const absorbable = entity.getComponent(ComponentType.ABSORBABLE);
// Only process inactive entities if they're dead and not yet absorbed
if (!health || !health.isDead() || !absorbable || absorbable.absorbed) {
return;
}
}
if (!entity.hasComponent(ComponentType.ABSORBABLE)) return;
if (!entity.hasComponent(ComponentType.HEALTH)) return;
const absorbable = entity.getComponent(ComponentType.ABSORBABLE);
const health = entity.getComponent(ComponentType.HEALTH);
const entityPos = entity.getComponent(ComponentType.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);
}
}
});
// NO LOCAL Update visual effects
}
absorbEntity(player, entity, absorbable, evolution, skills, stats, skillProgress) {
if (absorbable.absorbed) return;
absorbable.absorbed = true;
const entityPos = entity.getComponent(ComponentType.POSITION);
const health = player.getComponent(ComponentType.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) {
const vfxSystem = this.engine.systems.find(s => s.name === SystemName.VFX);
if (vfxSystem) {
vfxSystem.createAbsorption(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);
}
}
}

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,186 +0,0 @@
import { System } from '../core/System.js';
import { GameConfig } from '../GameConfig.js';
import { Events } from '../core/EventBus.js';
import { SystemName, ComponentType } from '../core/Constants.js';
export class CombatSystem extends System {
constructor() {
super(SystemName.COMBAT);
this.requiredComponents = [ComponentType.POSITION, ComponentType.COMBAT, ComponentType.HEALTH];
this.priority = 20;
}
process(deltaTime, entities) {
// Update combat cooldowns
entities.forEach(entity => {
const combat = entity.getComponent(ComponentType.COMBAT);
if (combat) {
combat.update(deltaTime);
}
});
// Handle player attacks
const playerController = this.engine.systems.find(s => s.name === SystemName.PLAYER_CONTROLLER);
const player = playerController ? playerController.getPlayerEntity() : null;
if (player && player.hasComponent(ComponentType.COMBAT)) {
this.handlePlayerCombat(player, deltaTime);
}
// Handle creature attacks
const creatures = entities.filter(e =>
e.hasComponent(ComponentType.AI) &&
e.hasComponent(ComponentType.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 === SystemName.INPUT);
const combat = player.getComponent(ComponentType.COMBAT);
const position = player.getComponent(ComponentType.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(ComponentType.AI);
const combat = creature.getComponent(ComponentType.COMBAT);
const position = creature.getComponent(ComponentType.POSITION);
const playerPos = player?.getComponent(ComponentType.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();
entities.forEach(target => {
if (target === attacker) return;
if (!target.hasComponent(ComponentType.HEALTH)) return;
const targetPos = target.getComponent(ComponentType.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(ComponentType.HEALTH);
const config = GameConfig.Combat;
const stats = attacker.getComponent(ComponentType.STATS);
const baseDamage = stats ? (combat.attackDamage + stats.strength * 0.5) : combat.attackDamage;
// Defense bonus from Hardened Shell
let finalDamage = baseDamage;
const targetEvolution = target.getComponent(ComponentType.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(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'
});
}
}
// If target is dead, let DeathSystem handle removal/deactivation
if (health.isDead()) {
this.engine.emit(Events.ENTITY_DIED, { entity: target });
}
// Apply knockback
const velocity = target.getComponent(ComponentType.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,67 +0,0 @@
import { System } from '../core/System.js';
import { SystemName, ComponentType } from '../core/Constants.js';
/**
* System to handle entity death - removes dead entities immediately
*/
export class DeathSystem extends System {
constructor() {
super(SystemName.DEATH);
this.requiredComponents = [ComponentType.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(ComponentType.HEALTH);
if (!health) return;
// Check if entity is dead
if (health.isDead()) {
// Check if player died
const evolution = entity.getComponent(ComponentType.EVOLUTION);
if (evolution) {
const menuSystem = this.engine.systems.find(s => s.name === SystemName.MENU);
if (menuSystem) {
menuSystem.showGameOver();
}
return;
}
// Mark as inactive immediately so it stops being processed by other systems
if (entity.active || !entity.deathTime) {
if (entity.active) {
entity.active = false;
}
if (!entity.deathTime) {
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(ComponentType.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,29 +0,0 @@
import { System } from '../core/System.js';
import { SystemName, ComponentType } from '../core/Constants.js';
/**
* System to handle health regeneration
*/
export class HealthRegenerationSystem extends System {
constructor() {
super(SystemName.HEALTH_REGEN);
this.requiredComponents = [ComponentType.HEALTH];
this.priority = 35;
}
process(deltaTime, entities) {
entities.forEach(entity => {
const health = entity.getComponent(ComponentType.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,37 +1,58 @@
import { System } from '../core/System.js';
import { SystemName } from '../core/Constants.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(SystemName.INPUT);
this.requiredComponents = []; // No required components - handles input globally
this.priority = 0; // Run first
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;
}
@ -39,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();
}
@ -58,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;
}
@ -66,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;
@ -77,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;
@ -95,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
*/
updatePreviousStates() {
// Deep copy current states to previous for next frame
* Update previous frame states. Should be called at the end of each frame.
*/
updatePreviousStates(): void {
this.keysPrevious = {};
for (const key in this.keys) {
this.keysPrevious[key] = this.keys[key];
@ -116,16 +133,20 @@ export class InputSystem extends System {
}
/**
* Check if a key is currently pressed
*/
isKeyPressed(key) {
* Check if a key is currently being held down.
* @param key - The key name or code
* @returns True if the key is pressed
*/
isKeyPressed(key: string): boolean {
return this.keys[key.toLowerCase()] === true;
}
/**
* Check if a key was just pressed (not held from previous frame)
*/
isKeyJustPressed(key) {
* Check if a key was pressed in the current frame.
* @param key - The key name or code
* @returns True if the key was just pressed
*/
isKeyJustPressed(key: string): boolean {
const keyLower = key.toLowerCase();
const isPressed = this.keys[keyLower] === true;
const wasPressed = this.keysPrevious[keyLower] === true;
@ -133,26 +154,30 @@ export class InputSystem extends System {
}
/**
* Get mouse position
*/
getMousePosition() {
* Get the current mouse position in world coordinates.
* @returns The mouse coordinates
*/
getMousePosition(): { x: number; y: number } {
return { x: this.mouse.x, y: this.mouse.y };
}
/**
* Check if mouse button is pressed
*/
isMouseButtonPressed(button = 0) {
* Check if a mouse button is currently being held down.
* @param button - The button index (0=left, 1=middle, 2=right)
* @returns True if the button is pressed
*/
isMouseButtonPressed(button = 0): boolean {
return this.mouse.buttons[button] === true;
}
/**
* Check if mouse button was just pressed
*/
isMouseButtonJustPressed(button = 0) {
* Check if a mouse button was pressed in the current frame.
* @param button - The button index
* @returns True if the button was just pressed
*/
isMouseButtonJustPressed(button = 0): boolean {
const isPressed = this.mouse.buttons[button] === true;
const wasPressed = this.mouse.buttonsPrevious[button] === true;
return isPressed && !wasPressed;
}
}

View file

@ -1,28 +1,45 @@
import { System } from '../core/System.js';
import { PixelFont } from '../core/PixelFont.js';
import { Palette } from '../core/Palette.js';
import { GameState, ComponentType, SystemName } from '../core/Constants.js';
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 to handle game menus (start, pause)
* System responsible for managing game states (start, playing, paused, game over) and rendering menus.
*/
export class MenuSystem extends System {
constructor(engine) {
gameState: GameState;
paused: boolean;
ctx: CanvasRenderingContext2D;
/**
* @param engine - The game engine instance
*/
constructor(engine: Engine) {
super(SystemName.MENU);
this.requiredComponents = []; // No required components
this.priority = 1; // Run early
this.requiredComponents = [];
this.priority = 1;
this.engine = engine;
this.ctx = engine.ctx;
this.gameState = GameState.START;
this.paused = false;
}
init(engine) {
/**
* Initialize the menu system and set up input listeners.
* @param engine - The game engine instance
*/
init(engine: Engine): void {
super.init(engine);
this.setupInput();
}
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) {
@ -41,16 +58,25 @@ export class MenuSystem extends System {
});
}
showGameOver() {
/**
* Transition the game state to Game Over.
*/
showGameOver(): void {
this.gameState = GameState.GAME_OVER;
this.paused = true;
}
restartGame() {
window.location.reload(); // Simple and effective for this project
/**
* Restart the game by reloading the page.
*/
restartGame(): void {
window.location.reload();
}
startGame() {
/**
* Transition the game state from Start menu to Playing.
*/
startGame(): void {
this.gameState = GameState.PLAYING;
this.paused = false;
if (!this.engine.running) {
@ -58,7 +84,10 @@ export class MenuSystem extends System {
}
}
togglePause() {
/**
* Toggle between Paused and Playing states.
*/
togglePause(): void {
if (this.gameState === GameState.PLAYING) {
this.gameState = GameState.PAUSED;
this.paused = true;
@ -67,25 +96,30 @@ export class MenuSystem extends System {
}
}
resumeGame() {
/**
* Transition the game state from Paused to Playing.
*/
resumeGame(): void {
this.gameState = GameState.PLAYING;
this.paused = false;
}
process(_deltaTime, _entities) {
// Don't update game systems if paused or at start menu
if (this.gameState === GameState.PAUSED || this.gameState === GameState.START) {
// Logic for system handling is moved to the update loop in Engine
}
}
/**
* Process menu logic (handled via events).
* @param _deltaTime - Time elapsed
* @param _entities - Filtered entities
*/
process(_deltaTime: number, _entities: Entity[]): void {}
drawMenu() {
/**
* 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;
// Darker overlay matching palette
ctx.fillStyle = 'rgba(32, 21, 51, 0.8)'; // Semi-transparent VOID
ctx.fillStyle = 'rgba(32, 21, 51, 0.8)';
ctx.fillRect(0, 0, width, height);
if (this.gameState === GameState.START) {
@ -100,14 +134,20 @@ export class MenuSystem extends System {
const instructions = [
'WASD: MOVE | CLICK: ATTACK',
'NUMS: SKILLS | ESC: PAUSE',
'COLLECT DNA TO EVOLVE'
'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);
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);
@ -117,16 +157,15 @@ export class MenuSystem extends System {
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(ComponentType.EVOLUTION));
const uiSystem = this.engine.systems.find(s => s.name === SystemName.UI);
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) {
// Draw Stats on the left
uiSystem.drawStats(player, 20, 80);
// Draw Learning Progress on the right
uiSystem.drawSkillProgress(player, width - 110, 80);
uiSystem.drawMutations(player, 20, 185);
}
} else if (this.gameState === GameState.GAME_OVER) {
const dead = 'YOU PERISHED';
@ -143,12 +182,19 @@ export class MenuSystem extends System {
}
}
getGameState() {
/**
* Get the current game state identifier.
* @returns The game state string
*/
getGameState(): GameState {
return this.gameState;
}
isPaused() {
/**
* 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,6 +1,13 @@
import { System } from '../core/System.js';
import { SystemName, ComponentType } from '../core/Constants.js';
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);
@ -8,19 +15,30 @@ export class MovementSystem extends System {
this.priority = 10;
}
process(deltaTime, entities) {
entities.forEach(entity => {
const position = entity.getComponent(ComponentType.POSITION);
const velocity = entity.getComponent(ComponentType.VELOCITY);
const health = entity.getComponent(ComponentType.HEALTH);
/**
* 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;
// Check if this is a projectile
if (velocity.lockTimer > 0) {
velocity.lockTimer -= deltaTime;
if (velocity.lockTimer <= 0) {
velocity.lockTimer = 0;
velocity.isLocked = false;
}
}
const isProjectile = health && health.isProjectile;
// Apply velocity with max speed limit (skip for projectiles)
if (!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;
@ -29,33 +47,36 @@ export class MovementSystem extends System {
}
}
// 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;
if (velocity.isLocked) {
velocity.lockTimer = 0;
velocity.isLocked = false;
}
} else {
position.x = nextX;
}
// Y Axis
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;
}
// Apply friction (skip for projectiles - they should maintain speed)
if (!isProjectile) {
const friction = 0.9;
const friction = velocity.isLocked ? 0.98 : 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;
@ -75,4 +96,3 @@ export class MovementSystem extends System {
});
}
}

View file

@ -1,71 +0,0 @@
import { System } from '../core/System.js';
import { SystemName, ComponentType } from '../core/Constants.js';
export class PlayerControllerSystem extends System {
constructor() {
super(SystemName.PLAYER_CONTROLLER);
this.requiredComponents = [ComponentType.POSITION, ComponentType.VELOCITY];
this.priority = 5;
this.playerEntity = null;
}
process(deltaTime, entities) {
// Find player entity (entity with Evolution component)
if (!this.playerEntity) {
this.playerEntity = entities.find(e => e.hasComponent(ComponentType.EVOLUTION));
}
if (!this.playerEntity) return;
const inputSystem = this.engine.systems.find(s => s.name === SystemName.INPUT);
if (!inputSystem) return;
const velocity = this.playerEntity.getComponent(ComponentType.VELOCITY);
const position = this.playerEntity.getComponent(ComponentType.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,93 +0,0 @@
import { System } from '../core/System.js';
import { Events } from '../core/EventBus.js';
import { Palette } from '../core/Palette.js';
import { SystemName, ComponentType } from '../core/Constants.js';
export class ProjectileSystem extends System {
constructor() {
super(SystemName.PROJECTILE);
this.requiredComponents = [ComponentType.POSITION, ComponentType.VELOCITY];
this.priority = 18;
}
process(deltaTime, entities) {
const playerController = this.engine.systems.find(s => s.name === SystemName.PLAYER_CONTROLLER);
const _player = playerController ? playerController.getPlayerEntity() : null;
entities.forEach(entity => {
const health = entity.getComponent(ComponentType.HEALTH);
if (!health || !health.isProjectile) return;
const position = entity.getComponent(ComponentType.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(ComponentType.HEALTH)) return;
if (target.getComponent(ComponentType.HEALTH).isProjectile) return;
const targetPos = target.getComponent(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) {
// Hit!
const targetHealth = target.getComponent(ComponentType.HEALTH);
const damage = entity.damage || 10;
targetHealth.takeDamage(damage);
// Impact animation
const vfxSystem = this.engine.systems.find(s => s.name === SystemName.VFX);
const velocity = entity.getComponent(ComponentType.VELOCITY);
if (vfxSystem) {
const angle = velocity ? Math.atan2(velocity.vy, velocity.vx) : null;
vfxSystem.createImpact(position.x, position.y, Palette.CYAN, angle);
}
// If target is dead, let DeathSystem handle removal/deactivation
if (targetHealth.isDead()) {
this.engine.emit(Events.ENTITY_DIED, { entity: target });
}
// 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,509 +0,0 @@
import { System } from '../core/System.js';
import { Palette } from '../core/Palette.js';
import { SpriteLibrary } from '../core/SpriteLibrary.js';
import { ComponentType, SystemName, AnimationState, VFXType, EntityType } from '../core/Constants.js';
export class RenderSystem extends System {
constructor(engine) {
super(SystemName.RENDER);
this.requiredComponents = [ComponentType.POSITION, ComponentType.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(ComponentType.HEALTH);
const evolution = entity.getComponent(ComponentType.EVOLUTION);
// Skip inactive entities UNLESS they're dead and absorbable (for absorption window)
if (!entity.active) {
const absorbable = entity.getComponent(ComponentType.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(ComponentType.ABSORBABLE);
if (!absorbable || absorbable.absorbed) {
return; // Skip dead non-absorbable entities
}
}
this.drawEntity(entity, deltaTime);
});
// Draw skill effects
this.drawSkillEffects();
this.drawVFX();
}
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(ComponentType.POSITION);
const sprite = entity.getComponent(ComponentType.SPRITE);
const health = entity.getComponent(ComponentType.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(ComponentType.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 === EntityType.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 === EntityType.SLIME) drawColor = Palette.CYAN;
this.ctx.fillStyle = drawColor;
// Select appropriate animation state based on velocity
const velocity = entity.getComponent(ComponentType.VELOCITY);
if (velocity) {
const isMoving = Math.abs(velocity.vx) > 1 || Math.abs(velocity.vy) > 1;
sprite.animationState = isMoving ? AnimationState.WALK : AnimationState.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[AnimationState.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(ComponentType.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(ComponentType.STEALTH);
if (stealth && stealth.isStealthed) {
this.drawStealthIndicator(stealth, sprite);
}
// Mutation Visual Effects - Simplified for pixel art
const evolution = entity.getComponent(ComponentType.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();
}
drawVFX() {
const vfxSystem = this.engine.systems.find(s => s.name === SystemName.VFX);
if (!vfxSystem) return;
const ctx = this.ctx;
const particles = vfxSystem.getParticles();
particles.forEach(p => {
ctx.fillStyle = p.color;
// Fade based on lifetime for impact, or keep solid/flicker for absorption
ctx.globalAlpha = p.type === VFXType.IMPACT ? Math.min(1, p.lifetime / 0.3) : 0.8;
// Snap to integers for pixel crispness
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;
}
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 === SystemName.SKILL_EFFECT);
// NOTE: SKILL_EFFECT was missing in my constants. I should add it.
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,43 +0,0 @@
import { System } from '../core/System.js';
import { SystemName } from '../core/Constants.js';
/**
* System to track and render skill effects (Fire Breath, Pounce, etc.)
*/
export class SkillEffectSystem extends System {
constructor() {
super(SystemName.SKILL_EFFECT);
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,54 +0,0 @@
import { System } from '../core/System.js';
import { SkillRegistry } from '../skills/SkillRegistry.js';
import { SystemName, ComponentType } from '../core/Constants.js';
export class SkillSystem extends System {
constructor() {
super(SystemName.SKILL);
this.requiredComponents = [ComponentType.SKILLS];
this.priority = 30;
}
process(deltaTime, entities) {
const inputSystem = this.engine.systems.find(s => s.name === SystemName.INPUT);
if (!inputSystem) return;
entities.forEach(entity => {
const skills = entity.getComponent(ComponentType.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(ComponentType.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);
}
}
}
}

View file

@ -1,75 +0,0 @@
import { System } from '../core/System.js';
import { SystemName, ComponentType } from '../core/Constants.js';
export class StealthSystem extends System {
constructor() {
super(SystemName.STEALTH);
this.requiredComponents = [ComponentType.STEALTH];
this.priority = 12;
}
process(deltaTime, entities) {
const inputSystem = this.engine.systems.find(s => s.name === SystemName.INPUT);
const playerController = this.engine.systems.find(s => s.name === SystemName.PLAYER_CONTROLLER);
const player = playerController ? playerController.getPlayerEntity() : null;
entities.forEach(entity => {
const stealth = entity.getComponent(ComponentType.STEALTH);
const velocity = entity.getComponent(ComponentType.VELOCITY);
const combat = entity.getComponent(ComponentType.COMBAT);
const evolution = entity.getComponent(ComponentType.EVOLUTION);
if (!stealth) return;
// Determine stealth type based on evolution
if (evolution) {
const form = evolution.getDominantForm();
stealth.stealthType = form;
}
// Check if player wants to toggle stealth
if (entity === player && inputSystem) {
const shiftPress = inputSystem.isKeyJustPressed('shift');
if (shiftPress) {
if (stealth.isStealthed) {
stealth.exitStealth();
} else {
stealth.enterStealth(stealth.stealthType);
}
}
}
// Update stealth based on movement and combat
const isMoving = velocity && (Math.abs(velocity.vx) > 1 || Math.abs(velocity.vy) > 1);
const isInCombat = combat && combat.isAttacking;
stealth.updateStealth(isMoving, isInCombat);
// Form-specific stealth bonuses
if (stealth.isStealthed) {
switch (stealth.stealthType) {
case 'slime':
// Slime can be very hidden when not moving
if (!isMoving) {
stealth.visibility = Math.max(0.05, stealth.visibility - deltaTime * 0.2);
}
break;
case 'beast':
// Beast stealth is better when moving slowly
if (isMoving && velocity) {
const speed = Math.sqrt(velocity.vx * velocity.vx + velocity.vy * velocity.vy);
if (speed < 50) {
stealth.visibility = Math.max(0.1, stealth.visibility - deltaTime * 0.1);
}
}
break;
case 'human':
// Human stealth is more consistent
stealth.visibility = Math.max(0.2, stealth.visibility - deltaTime * 0.05);
break;
}
}
});
}
}

View file

@ -0,0 +1,86 @@
import { System } from '../core/System.ts';
import { SystemName, ComponentType } from '../core/Constants.ts';
import type { Entity } from '../core/Entity.ts';
import type { Stealth } from '../components/Stealth.ts';
import type { Velocity } from '../components/Velocity.ts';
import type { Combat } from '../components/Combat.ts';
import type { Evolution } from '../components/Evolution.ts';
import type { InputSystem } from './InputSystem.ts';
import type { PlayerControllerSystem } from './PlayerControllerSystem.ts';
/**
* System responsible for managing stealth mechanics, including visibility updates based on form, movement, and combat.
*/
export class StealthSystem extends System {
constructor() {
super(SystemName.STEALTH);
this.requiredComponents = [ComponentType.STEALTH];
this.priority = 12;
}
/**
* Update stealth state for entities, handling toggle input and form-specific visibility changes.
* @param deltaTime - Time elapsed since last frame in seconds
* @param entities - Entities matching active required components
*/
process(deltaTime: number, entities: Entity[]): void {
const inputSystem = this.engine.systems.find((s) => s.name === SystemName.INPUT) as
| InputSystem
| undefined;
const playerController = this.engine.systems.find(
(s) => s.name === SystemName.PLAYER_CONTROLLER
) as PlayerControllerSystem | undefined;
const player = playerController ? playerController.getPlayerEntity() : null;
entities.forEach((entity) => {
const stealth = entity.getComponent<Stealth>(ComponentType.STEALTH);
const velocity = entity.getComponent<Velocity>(ComponentType.VELOCITY);
const combat = entity.getComponent<Combat>(ComponentType.COMBAT);
const evolution = entity.getComponent<Evolution>(ComponentType.EVOLUTION);
if (!stealth) return;
if (evolution) {
const form = evolution.getDominantForm();
stealth.stealthType = form;
}
if (entity === player && inputSystem) {
const shiftPress = inputSystem.isKeyJustPressed('shift');
if (shiftPress) {
if (stealth.isStealthed) {
stealth.exitStealth();
} else {
stealth.enterStealth(stealth.stealthType);
}
}
}
const isMoving = velocity && (Math.abs(velocity.vx) > 1 || Math.abs(velocity.vy) > 1);
const isInCombat = combat && combat.isAttacking;
stealth.updateStealth(isMoving || false, isInCombat || false);
if (stealth.isStealthed) {
switch (stealth.stealthType) {
case 'slime':
if (!isMoving) {
stealth.visibility = Math.max(0.05, stealth.visibility - deltaTime * 0.2);
}
break;
case 'beast':
if (isMoving && velocity) {
const speed = Math.sqrt(velocity.vx * velocity.vx + velocity.vy * velocity.vy);
if (speed < 50) {
stealth.visibility = Math.max(0.1, stealth.visibility - deltaTime * 0.1);
}
}
break;
case 'human':
stealth.visibility = Math.max(0.2, stealth.visibility - deltaTime * 0.05);
break;
}
}
});
}
}

View file

@ -1,229 +0,0 @@
import { System } from '../core/System.js';
import { SkillRegistry } from '../skills/SkillRegistry.js';
import { Events } from '../core/EventBus.js';
import { PixelFont } from '../core/PixelFont.js';
import { Palette } from '../core/Palette.js';
import { GameState, ComponentType, SystemName } from '../core/Constants.js';
export class UISystem extends System {
constructor(engine) {
super(SystemName.UI);
this.requiredComponents = []; // No required components - renders UI
this.priority = 200; // Render after everything else
this.engine = engine;
this.ctx = engine.ctx;
this.damageNumbers = [];
this.notifications = [];
// Subscribe to events
engine.on(Events.DAMAGE_DEALT, (data) => this.addDamageNumber(data));
engine.on(Events.MUTATION_GAINED, (data) => this.addNotification(`Mutation Gained: ${data.name} `));
}
addDamageNumber(data) {
this.damageNumbers.push({
x: data.x,
y: data.y,
value: Math.floor(data.value),
color: data.color || '#ffffff',
lifetime: 1.0,
vy: -50
});
}
addNotification(text) {
this.notifications.push({
text,
lifetime: 3.0,
alpha: 1.0
});
}
process(deltaTime, _entities) {
// Update damage numbers
this.updateDamageNumbers(deltaTime);
this.updateNotifications(deltaTime);
const menuSystem = this.engine.systems.find(s => s.name === SystemName.MENU);
const gameState = menuSystem ? menuSystem.getGameState() : GameState.PLAYING;
// Only draw menu overlay if in start, paused, or gameOver state
if (gameState === GameState.START || gameState === GameState.PAUSED || gameState === GameState.GAME_OVER) {
if (menuSystem) {
menuSystem.drawMenu();
}
// Don't draw game UI when menu is showing
return;
}
const playerController = this.engine.systems.find(s => s.name === SystemName.PLAYER_CONTROLLER);
const player = playerController ? playerController.getPlayerEntity() : null;
if (!player) return;
// Draw UI
this.drawHUD(player);
this.drawSkills(player);
// REMOVED drawStats and drawSkillProgress from active gameplay
this.drawDamageNumbers();
this.drawNotifications();
}
drawHUD(player) {
const health = player.getComponent(ComponentType.HEALTH);
const stats = player.getComponent(ComponentType.STATS);
const evolution = player.getComponent(ComponentType.EVOLUTION);
if (!health || !stats || !evolution) return;
const ctx = this.ctx;
// Health bar
const barWidth = 64;
const barHeight = 6;
const barX = 4;
const barY = 4;
// Outer border
ctx.fillStyle = Palette.DARK_BLUE;
ctx.fillRect(barX - 1, barY - 1, barWidth + 2, barHeight + 2);
// Background
ctx.fillStyle = Palette.VOID;
ctx.fillRect(barX, barY, barWidth, barHeight);
const healthPercent = health.hp / health.maxHp;
ctx.fillStyle = healthPercent > 0.5 ? Palette.CYAN : healthPercent > 0.25 ? Palette.SKY_BLUE : Palette.WHITE;
ctx.fillRect(barX, barY, Math.floor(barWidth * healthPercent), barHeight);
// HP Text
PixelFont.drawText(ctx, `${Math.ceil(health.hp)}/${health.maxHp}`, barX, barY + barHeight + 3, Palette.WHITE, 1);
// Evolution display
const form = evolution.getDominantForm();
const formY = barY + barHeight + 14;
PixelFont.drawText(ctx, form.toUpperCase(), barX, formY, Palette.SKY_BLUE, 1);
}
drawSkills(player) {
const skills = player.getComponent(ComponentType.SKILLS);
if (!skills) return;
const ctx = this.ctx;
const width = this.engine.canvas.width;
const startX = width - 80;
const startY = 4;
PixelFont.drawText(ctx, 'SKILLS', startX, startY, Palette.WHITE, 1);
skills.activeSkills.forEach((skillId, index) => {
const y = startY + 10 + index * 9;
const onCooldown = skills.isOnCooldown(skillId);
const cooldown = skills.getCooldown(skillId);
const skill = SkillRegistry.get(skillId);
let skillName = skill ? skill.name : skillId.replace('_', ' ');
if (skillName.length > 10) skillName = skillName.substring(0, 10);
const color = onCooldown ? Palette.ROYAL_BLUE : Palette.CYAN;
const text = `${index + 1} ${skillName}${onCooldown ? ` ${cooldown.toFixed(0)}` : ''}`;
PixelFont.drawText(ctx, text, startX, y, color, 1);
});
}
drawStats(player, x, y) {
const stats = player.getComponent(ComponentType.STATS);
const evolution = player.getComponent(ComponentType.EVOLUTION);
if (!stats || !evolution) return;
const ctx = this.ctx;
PixelFont.drawText(ctx, 'STATISTICS', x, y, Palette.WHITE, 1);
PixelFont.drawText(ctx, `STR ${stats.strength}`, x, y + 10, Palette.ROYAL_BLUE, 1);
PixelFont.drawText(ctx, `AGI ${stats.agility}`, x, y + 20, Palette.ROYAL_BLUE, 1);
PixelFont.drawText(ctx, `INT ${stats.intelligence}`, x, y + 30, Palette.ROYAL_BLUE, 1);
PixelFont.drawText(ctx, `CON ${stats.constitution}`, x, y + 40, Palette.ROYAL_BLUE, 1);
PixelFont.drawText(ctx, 'EVOLUTION', x, y + 60, Palette.WHITE, 1);
PixelFont.drawText(ctx, `HUMAN: ${Math.floor(evolution.human)}`, x, y + 70, Palette.ROYAL_BLUE, 1);
PixelFont.drawText(ctx, `BEAST: ${Math.floor(evolution.beast)}`, x, y + 80, Palette.ROYAL_BLUE, 1);
PixelFont.drawText(ctx, `SLIME: ${Math.floor(evolution.slime)}`, x, y + 90, Palette.ROYAL_BLUE, 1);
}
drawSkillProgress(player, x, y) {
const skillProgress = player.getComponent(ComponentType.SKILL_PROGRESS);
if (!skillProgress) return;
const ctx = this.ctx;
const progress = skillProgress.getAllProgress();
PixelFont.drawText(ctx, 'KNOWLEDGE', x, y, Palette.CYAN, 1);
if (progress.size === 0) {
PixelFont.drawText(ctx, 'NONE YET', x, y + 10, Palette.DARK_BLUE, 1);
return;
}
let idx = 0;
progress.forEach((count, skillId) => {
const required = skillProgress.requiredAbsorptions;
const skill = SkillRegistry.get(skillId);
let name = skill ? skill.name : skillId;
if (name.length > 10) name = name.substring(0, 10);
const py = y + 10 + idx * 9;
PixelFont.drawText(ctx, `${name}: ${count}/${required}`, x, py, Palette.SKY_BLUE, 1);
idx++;
});
}
updateDamageNumbers(deltaTime) {
for (let i = this.damageNumbers.length - 1; i >= 0; i--) {
const num = this.damageNumbers[i];
num.lifetime -= deltaTime;
num.y += num.vy * deltaTime;
num.vy *= 0.95;
if (num.lifetime <= 0) this.damageNumbers.splice(i, 1);
}
}
updateNotifications(deltaTime) {
for (let i = this.notifications.length - 1; i >= 0; i--) {
const note = this.notifications[i];
note.lifetime -= deltaTime;
if (note.lifetime < 0.5) note.alpha = note.lifetime * 2;
if (note.lifetime <= 0) this.notifications.splice(i, 1);
}
}
drawDamageNumbers() {
const ctx = this.ctx;
this.damageNumbers.forEach(num => {
const color = num.color.startsWith('rgba') ? num.color : Palette.WHITE;
PixelFont.drawText(ctx, num.value.toString(), Math.floor(num.x), Math.floor(num.y), color, 1);
});
}
hexToRgb(hex) {
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);
const b = parseInt(hex.slice(5, 7), 16);
return `${r}, ${g}, ${b}`;
}
drawNotifications() {
const ctx = this.ctx;
const width = this.engine.canvas.width;
this.notifications.forEach((note, index) => {
const textWidth = PixelFont.getTextWidth(note.text, 1);
const x = Math.floor((width - textWidth) / 2);
const y = 40 + index * 10;
PixelFont.drawText(ctx, note.text, x, y, Palette.WHITE, 1);
});
}
}

344
src/systems/UISystem.ts Normal file
View file

@ -0,0 +1,344 @@
import { System } from '../core/System.ts';
import { SkillRegistry } from '../skills/SkillRegistry.ts';
import { Events, DamageDealtEvent, MutationGainedEvent } from '../core/EventBus.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 { Health } from '../components/Health.ts';
import type { Stats } from '../components/Stats.ts';
import type { Evolution } from '../components/Evolution.ts';
import type { Skills } from '../components/Skills.ts';
import type { SkillProgress } from '../components/SkillProgress.ts';
import type { MenuSystem } from './MenuSystem.ts';
import type { PlayerControllerSystem } from './PlayerControllerSystem.ts';
interface DamageNumber {
x: number;
y: number;
value: number;
color: string;
lifetime: number;
vy: number;
}
interface Notification {
text: string;
lifetime: number;
alpha: number;
}
/**
* System responsible for rendering all UI elements, including HUD, skill bars, notifications, and damage numbers.
*/
export class UISystem extends System {
ctx: CanvasRenderingContext2D;
damageNumbers: DamageNumber[];
notifications: Notification[];
/**
* @param engine - The game engine instance
*/
constructor(engine: Engine) {
super(SystemName.UI);
this.requiredComponents = [];
this.priority = 200;
this.engine = engine;
this.ctx = engine.ctx;
this.damageNumbers = [];
this.notifications = [];
engine.on(Events.DAMAGE_DEALT, (data: unknown) => {
const damageData = data as DamageDealtEvent;
this.addDamageNumber(damageData);
});
engine.on(Events.MUTATION_GAINED, (data: unknown) => {
const mutationData = data as MutationGainedEvent;
this.addNotification(`Mutation Gained: ${mutationData.name} `);
});
}
/**
* Add a floating damage number effect.
* @param data - Object containing x, y, value, and optional color
*/
addDamageNumber(data: DamageDealtEvent): void {
this.damageNumbers.push({
x: data.x,
y: data.y,
value: Math.floor(data.value),
color: data.color || '#ffffff',
lifetime: 1.0,
vy: -50,
});
}
/**
* Add a screen notification message.
* @param text - The message text
*/
addNotification(text: string): void {
this.notifications.push({
text,
lifetime: 3.0,
alpha: 1.0,
});
}
/**
* Update UI states and execute rendering of all UI components.
* @param deltaTime - Time elapsed since last frame in seconds
* @param _entities - Filtered entities
*/
process(deltaTime: number, _entities: Entity[]): void {
this.updateDamageNumbers(deltaTime);
this.updateNotifications(deltaTime);
const menuSystem = this.engine.systems.find((s) => s.name === SystemName.MENU) as
| MenuSystem
| undefined;
const gameState = menuSystem ? menuSystem.getGameState() : GameState.PLAYING;
if (
gameState === GameState.START ||
gameState === GameState.PAUSED ||
gameState === GameState.GAME_OVER
) {
if (menuSystem) {
menuSystem.drawMenu();
}
return;
}
const playerController = this.engine.systems.find(
(s) => s.name === SystemName.PLAYER_CONTROLLER
) as PlayerControllerSystem | undefined;
const player = playerController ? playerController.getPlayerEntity() : null;
if (!player) return;
this.drawHUD(player);
this.drawSkills(player);
this.drawDamageNumbers();
this.drawNotifications();
}
/**
* Draw the main player heads-up display (health, form).
*/
drawHUD(player: Entity): void {
const health = player.getComponent<Health>(ComponentType.HEALTH);
const stats = player.getComponent<Stats>(ComponentType.STATS);
const evolution = player.getComponent<Evolution>(ComponentType.EVOLUTION);
if (!health || !stats || !evolution) return;
const ctx = this.ctx;
const barWidth = 64;
const barHeight = 6;
const barX = 4;
const barY = 4;
ctx.fillStyle = Palette.DARK_BLUE;
ctx.fillRect(barX - 1, barY - 1, barWidth + 2, barHeight + 2);
ctx.fillStyle = Palette.VOID;
ctx.fillRect(barX, barY, barWidth, barHeight);
const healthPercent = health.hp / health.maxHp;
ctx.fillStyle =
healthPercent > 0.5 ? Palette.CYAN : healthPercent > 0.25 ? Palette.SKY_BLUE : Palette.WHITE;
ctx.fillRect(barX, barY, Math.floor(barWidth * healthPercent), barHeight);
PixelFont.drawText(
ctx,
`${Math.ceil(health.hp)}/${health.maxHp}`,
barX,
barY + barHeight + 3,
Palette.WHITE,
1
);
const form = evolution.getDominantForm();
const formY = barY + barHeight + 14;
PixelFont.drawText(ctx, form.toUpperCase(), barX, formY, Palette.SKY_BLUE, 1);
}
/**
* Draw the skill slots and their active cooldowns.
*/
drawSkills(player: Entity): void {
const skills = player.getComponent<Skills>(ComponentType.SKILLS);
if (!skills) return;
const ctx = this.ctx;
const width = this.engine.canvas.width;
const startX = width - 80;
const startY = 4;
PixelFont.drawText(ctx, 'SKILLS', startX, startY, Palette.WHITE, 1);
skills.activeSkills.forEach((skillId, index) => {
const y = startY + 10 + index * 9;
const onCooldown = skills.isOnCooldown(skillId);
const cooldown = skills.getCooldown(skillId);
const skill = SkillRegistry.get(skillId);
let skillName = skill ? skill.name : skillId.replace('_', ' ');
if (skillName.length > 10) skillName = skillName.substring(0, 10);
const color = onCooldown ? Palette.ROYAL_BLUE : Palette.CYAN;
const text = `${index + 1} ${skillName}${onCooldown ? ` ${cooldown.toFixed(0)}` : ''}`;
PixelFont.drawText(ctx, text, startX, y, color, 1);
});
}
/**
* Draw detailed player statistics.
*/
drawStats(player: Entity, x: number, y: number): void {
const stats = player.getComponent<Stats>(ComponentType.STATS);
const evolution = player.getComponent<Evolution>(ComponentType.EVOLUTION);
if (!stats || !evolution) return;
const ctx = this.ctx;
PixelFont.drawText(ctx, 'STATISTICS', x, y, Palette.WHITE, 1);
PixelFont.drawText(ctx, `STR ${stats.strength}`, x, y + 10, Palette.ROYAL_BLUE, 1);
PixelFont.drawText(ctx, `AGI ${stats.agility}`, x, y + 20, Palette.ROYAL_BLUE, 1);
PixelFont.drawText(ctx, `INT ${stats.intelligence}`, x, y + 30, Palette.ROYAL_BLUE, 1);
PixelFont.drawText(ctx, `CON ${stats.constitution}`, x, y + 40, Palette.ROYAL_BLUE, 1);
PixelFont.drawText(ctx, 'EVOLUTION', x, y + 60, Palette.WHITE, 1);
PixelFont.drawText(
ctx,
`HUMAN: ${Math.floor(evolution.human)}`,
x,
y + 70,
Palette.ROYAL_BLUE,
1
);
PixelFont.drawText(
ctx,
`BEAST: ${Math.floor(evolution.beast)}`,
x,
y + 80,
Palette.ROYAL_BLUE,
1
);
PixelFont.drawText(
ctx,
`SLIME: ${Math.floor(evolution.slime)}`,
x,
y + 90,
Palette.ROYAL_BLUE,
1
);
}
/**
* Draw the progress towards learning new skills from absorption.
*/
drawSkillProgress(player: Entity, x: number, y: number): void {
const skillProgress = player.getComponent<SkillProgress>(ComponentType.SKILL_PROGRESS);
if (!skillProgress) return;
const ctx = this.ctx;
const progress = skillProgress.getAllProgress();
PixelFont.drawText(ctx, 'KNOWLEDGE', x, y, Palette.CYAN, 1);
if (progress.size === 0) {
PixelFont.drawText(ctx, 'NONE YET', x, y + 10, Palette.DARK_BLUE, 1);
return;
}
let idx = 0;
progress.forEach((count, skillId) => {
const required = skillProgress.requiredAbsorptions;
const skill = SkillRegistry.get(skillId);
let name = skill ? skill.name : skillId;
if (name.length > 10) name = name.substring(0, 10);
const py = y + 10 + idx * 9;
PixelFont.drawText(ctx, `${name}: ${count}/${required}`, x, py, Palette.SKY_BLUE, 1);
idx++;
});
}
/**
* Draw the list of currently active mutations.
*/
drawMutations(player: Entity, x: number, y: number): void {
const evolution = player.getComponent<Evolution>(ComponentType.EVOLUTION);
if (!evolution) return;
const ctx = this.ctx;
PixelFont.drawText(ctx, 'MUTATIONS', x, y, Palette.CYAN, 1);
if (evolution.mutations.size === 0) {
PixelFont.drawText(ctx, 'NONE', x, y + 10, Palette.DARK_BLUE, 1);
return;
}
let idx = 0;
evolution.mutations.forEach((mutation) => {
const py = y + 10 + idx * 9;
PixelFont.drawText(ctx, `> ${mutation.toUpperCase()}`, x, py, Palette.SKY_BLUE, 1);
idx++;
});
}
/**
* Update active damage numbers position and lifetimes.
*/
updateDamageNumbers(deltaTime: number): void {
for (let i = this.damageNumbers.length - 1; i >= 0; i--) {
const num = this.damageNumbers[i];
num.lifetime -= deltaTime;
num.y += num.vy * deltaTime;
num.vy *= 0.95;
if (num.lifetime <= 0) this.damageNumbers.splice(i, 1);
}
}
/**
* Update notification messages lifetimes and transparency.
*/
updateNotifications(deltaTime: number): void {
for (let i = this.notifications.length - 1; i >= 0; i--) {
const note = this.notifications[i];
note.lifetime -= deltaTime;
if (note.lifetime < 0.5) note.alpha = note.lifetime * 2;
if (note.lifetime <= 0) this.notifications.splice(i, 1);
}
}
/**
* Draw floating damage numbers on screen.
*/
drawDamageNumbers(): void {
const ctx = this.ctx;
this.damageNumbers.forEach((num) => {
const color = num.color.startsWith('rgba') ? num.color : Palette.WHITE;
PixelFont.drawText(ctx, num.value.toString(), Math.floor(num.x), Math.floor(num.y), color, 1);
});
}
/**
* Draw active notification messages at the center of the screen.
*/
drawNotifications(): void {
const ctx = this.ctx;
const width = this.engine.canvas.width;
this.notifications.forEach((note, index) => {
const textWidth = PixelFont.getTextWidth(note.text, 1);
const x = Math.floor((width - textWidth) / 2);
const y = 40 + index * 10;
PixelFont.drawText(ctx, note.text, x, y, Palette.WHITE, 1);
});
}
}

View file

@ -1,8 +1,27 @@
import { System } from '../core/System.js';
import { Palette } from '../core/Palette.js';
import { SystemName, ComponentType, VFXType } from '../core/Constants.js';
import { System } from '../core/System.ts';
import { Palette } from '../core/Palette.ts';
import { SystemName, ComponentType, VFXType } from '../core/Constants.ts';
import type { Entity } from '../core/Entity.ts';
import type { Position } from '../components/Position.ts';
import type { PlayerControllerSystem } from './PlayerControllerSystem.ts';
interface Particle {
x: number;
y: number;
vx: number;
vy: number;
lifetime: number;
size: number;
color: string;
type: VFXType;
}
/**
* System responsible for creating and updating visual effects particles.
*/
export class VFXSystem extends System {
particles: Particle[];
constructor() {
super(SystemName.VFX);
this.requiredComponents = [];
@ -10,24 +29,28 @@ export class VFXSystem extends System {
this.particles = [];
}
process(deltaTime, _entities) {
const playerController = this.engine.systems.find(s => s.name === SystemName.PLAYER_CONTROLLER);
/**
* Update all active particles, applying movement, attraction, and drag.
* @param deltaTime - Time elapsed since last frame in seconds
* @param _entities - Filtered 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;
const playerPos = player ? player.getComponent(ComponentType.POSITION) : null;
const playerPos = player ? player.getComponent<Position>(ComponentType.POSITION) : null;
for (let i = this.particles.length - 1; i >= 0; i--) {
const p = this.particles[i];
// Update lifetime
p.lifetime -= deltaTime;
if (p.lifetime <= 0) {
this.particles.splice(i, 1);
continue;
}
// Behavior logic
if (p.type === VFXType.ABSORPTION && playerPos) {
// Attract to player
const dx = playerPos.x - p.x;
const dy = playerPos.y - p.y;
const dist = Math.sqrt(dx * dx + dy * dy);
@ -35,32 +58,34 @@ export class VFXSystem extends System {
if (dist > 5) {
p.vx += (dx / dist) * 800 * deltaTime;
p.vy += (dy / dist) * 800 * deltaTime;
// Add some drag to make it smooth
p.vx *= 0.95;
p.vy *= 0.95;
} else {
// Arrived
this.particles.splice(i, 1);
continue;
}
} else {
// Basic drag for impact particles
p.vx *= 0.9;
p.vy *= 0.9;
}
// Update position
p.x += p.vx * deltaTime;
p.y += p.vy * deltaTime;
}
}
createImpact(x, y, color = Palette.WHITE, angle = null) {
/**
* Create an impact particle explosion at a specific location.
* @param x - Horizontal coordinate
* @param y - Vertical coordinate
* @param color - The color of particles
* @param angle - The direction of the impact splash
*/
createImpact(x: number, y: number, color = Palette.WHITE, angle: number | null = null): void {
const count = 8;
for (let i = 0; i < count; i++) {
let vx, vy;
let vx: number, vy: number;
if (angle !== null) {
// Splash in the direction of hit + some spread
const spread = (Math.random() - 0.5) * 2;
const speed = 50 + Math.random() * 150;
vx = Math.cos(angle + spread) * speed;
@ -78,14 +103,19 @@ export class VFXSystem extends System {
lifetime: 0.2 + Math.random() * 0.3,
size: 1 + Math.random() * 2,
color: color,
type: VFXType.IMPACT
type: VFXType.IMPACT,
});
}
}
createAbsorption(x, y, color = Palette.CYAN) {
/**
* Create absorption particles that fly towards the player.
* @param x - Starting horizontal coordinate
* @param y - Starting vertical coordinate
* @param color - The color of particles
*/
createAbsorption(x: number, y: number, color = Palette.CYAN): void {
for (let i = 0; i < 12; i++) {
// Start with a small explosion then attract
this.particles.push({
x,
y,
@ -94,12 +124,16 @@ export class VFXSystem extends System {
lifetime: 1.5,
size: 2,
color: color,
type: VFXType.ABSORPTION
type: VFXType.ABSORPTION,
});
}
}
getParticles() {
/**
* Get all currently active particles.
* @returns Array of particle data objects
*/
getParticles(): Particle[] {
return this.particles;
}
}