feat: add poc

This commit is contained in:
Juan Sebastián Montoya 2026-01-06 14:02:09 -05:00
parent 43d27b04d9
commit 4a4fa05ce4
53 changed files with 6191 additions and 0 deletions

205
src/systems/AISystem.js Normal file
View file

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

View file

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

190
src/systems/CombatSystem.js Normal file
View file

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

View file

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

View file

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

153
src/systems/InputSystem.js Normal file
View file

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

110
src/systems/MenuSystem.js Normal file
View file

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

View file

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

View file

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

437
src/systems/RenderSystem.js Normal file
View file

@ -0,0 +1,437 @@
import { System } from '../core/System.js';
export class RenderSystem extends System {
constructor(engine) {
super('RenderSystem');
this.requiredComponents = ['Position', 'Sprite'];
this.priority = 100; // Render last
this.engine = engine;
this.ctx = engine.ctx;
}
process(deltaTime, _entities) {
// Clear canvas
this.engine.clear();
// Draw background
this.drawBackground();
// Draw entities
// Get all entities including inactive ones for rendering dead absorbable entities
const allEntities = this.engine.entities;
allEntities.forEach(entity => {
const health = entity.getComponent('Health');
const evolution = entity.getComponent('Evolution');
// Skip inactive entities UNLESS they're dead and absorbable (for absorption window)
if (!entity.active) {
const absorbable = entity.getComponent('Absorbable');
if (health && health.isDead() && absorbable && !absorbable.absorbed) {
// Render dead absorbable entities even if inactive (fade them out)
this.drawEntity(entity, deltaTime, true); // Pass fade flag
return;
}
return; // Skip other inactive entities
}
// Don't render dead non-player entities (unless they're absorbable, handled above)
if (health && health.isDead() && !evolution) {
const absorbable = entity.getComponent('Absorbable');
if (!absorbable || absorbable.absorbed) {
return; // Skip dead non-absorbable entities
}
}
this.drawEntity(entity, deltaTime);
});
// Draw skill effects
this.drawSkillEffects();
}
drawBackground() {
const ctx = this.ctx;
const width = this.engine.canvas.width;
const height = this.engine.canvas.height;
// Cave background with gradient
const gradient = ctx.createLinearGradient(0, 0, 0, height);
gradient.addColorStop(0, '#0f0f1f');
gradient.addColorStop(1, '#1a1a2e');
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, width, height);
// Add cave features with better visuals
ctx.fillStyle = '#2a2a3e';
for (let i = 0; i < 20; i++) {
const x = (i * 70 + Math.sin(i) * 30) % width;
const y = (i * 50 + Math.cos(i) * 40) % height;
const size = 25 + (i % 4) * 15;
// Add shadow
ctx.shadowBlur = 20;
ctx.shadowColor = 'rgba(0, 0, 0, 0.5)';
ctx.beginPath();
ctx.arc(x, y, size, 0, Math.PI * 2);
ctx.fill();
ctx.shadowBlur = 0;
}
// Add some ambient lighting
const lightGradient = ctx.createRadialGradient(width / 2, height / 2, 0, width / 2, height / 2, 400);
lightGradient.addColorStop(0, 'rgba(100, 150, 200, 0.1)');
lightGradient.addColorStop(1, 'rgba(0, 0, 0, 0)');
ctx.fillStyle = lightGradient;
ctx.fillRect(0, 0, width, height);
}
drawEntity(entity, deltaTime, isDeadFade = false) {
const position = entity.getComponent('Position');
const sprite = entity.getComponent('Sprite');
const health = entity.getComponent('Health');
if (!position || !sprite) return;
this.ctx.save();
// Fade out dead entities
let alpha = sprite.alpha;
if (isDeadFade && health && health.isDead()) {
const absorbable = entity.getComponent('Absorbable');
if (absorbable && !absorbable.absorbed) {
// Calculate fade based on time since death
const deathTime = entity.deathTime || Date.now();
const timeSinceDeath = (Date.now() - deathTime) / 1000;
const fadeTime = 3.0; // 3 seconds to fade (matches DeathSystem removal time)
alpha = Math.max(0.3, 1.0 - (timeSinceDeath / fadeTime));
}
}
this.ctx.globalAlpha = alpha;
this.ctx.translate(position.x, position.y);
this.ctx.rotate(position.rotation);
this.ctx.scale(sprite.scale, sprite.scale);
// Update animation time for slime morphing
if (sprite.shape === 'slime') {
sprite.animationTime += deltaTime;
sprite.morphAmount = Math.sin(sprite.animationTime * 3) * 0.2 + 0.8;
}
// Draw based on shape
this.ctx.fillStyle = sprite.color;
if (sprite.shape === 'circle' || sprite.shape === 'slime') {
this.drawSlime(sprite);
} else if (sprite.shape === 'rect') {
this.ctx.fillRect(-sprite.width / 2, -sprite.height / 2, sprite.width, sprite.height);
}
// Draw health bar if entity has health
if (health && health.maxHp > 0) {
this.drawHealthBar(health, sprite);
}
// Draw combat indicator if attacking
const combat = entity.getComponent('Combat');
if (combat && combat.isAttacking) {
// Draw attack indicator relative to entity's current rotation
// Since we're already rotated, we need to draw relative to 0,0 forward
this.drawAttackIndicator(combat, position);
}
// Draw stealth indicator
const stealth = entity.getComponent('Stealth');
if (stealth && stealth.isStealthed) {
this.drawStealthIndicator(stealth, sprite);
}
// Mutation Visual Effects
const evolution = entity.getComponent('Evolution');
if (evolution) {
if (evolution.mutationEffects.glowingBody) {
// Draw light aura
const auraGradient = this.ctx.createRadialGradient(0, 0, 0, 0, 0, sprite.width * 2);
auraGradient.addColorStop(0, 'rgba(255, 255, 200, 0.2)');
auraGradient.addColorStop(1, 'rgba(255, 255, 200, 0)');
this.ctx.fillStyle = auraGradient;
this.ctx.beginPath();
this.ctx.arc(0, 0, sprite.width * 2, 0, Math.PI * 2);
this.ctx.fill();
}
if (evolution.mutationEffects.electricSkin) {
// Add tiny sparks
if (Math.random() < 0.2) {
this.ctx.strokeStyle = '#00ffff';
this.ctx.lineWidth = 2;
this.ctx.beginPath();
const sparkX = (Math.random() - 0.5) * sprite.width;
const sparkY = (Math.random() - 0.5) * sprite.height;
this.ctx.moveTo(sparkX, sparkY);
this.ctx.lineTo(sparkX + (Math.random() - 0.5) * 10, sparkY + (Math.random() - 0.5) * 10);
this.ctx.stroke();
}
}
if (evolution.mutationEffects.hardenedShell) {
// Darker, thicker border
this.ctx.strokeStyle = 'rgba(0, 0, 0, 0.5)';
this.ctx.lineWidth = 3;
this.ctx.stroke();
}
}
this.ctx.restore();
}
drawSlime(sprite) {
const ctx = this.ctx;
const baseRadius = Math.min(sprite.width, sprite.height) / 2;
if (sprite.shape === 'slime') {
// Animated slime blob with morphing and better visuals
ctx.shadowBlur = 15;
ctx.shadowColor = sprite.color;
// Main body with morphing
ctx.beginPath();
const points = 16;
for (let i = 0; i < points; i++) {
const angle = (i / points) * Math.PI * 2;
const morph1 = Math.sin(angle * 2 + sprite.animationTime * 2) * 0.15;
const morph2 = Math.cos(angle * 3 + sprite.animationTime * 1.5) * 0.1;
const radius = baseRadius * (sprite.morphAmount + morph1 + morph2);
const x = Math.cos(angle) * radius;
const y = Math.sin(angle) * radius;
if (i === 0) {
ctx.moveTo(x, y);
} else {
ctx.lineTo(x, y);
}
}
ctx.closePath();
ctx.fill();
// Inner glow
const innerGradient = ctx.createRadialGradient(0, 0, 0, 0, 0, baseRadius * 0.8);
innerGradient.addColorStop(0, 'rgba(255, 255, 255, 0.4)');
innerGradient.addColorStop(1, 'rgba(255, 255, 255, 0)');
ctx.fillStyle = innerGradient;
ctx.beginPath();
ctx.arc(0, 0, baseRadius * 0.8, 0, Math.PI * 2);
ctx.fill();
// Highlight
ctx.fillStyle = 'rgba(255, 255, 255, 0.5)';
ctx.beginPath();
ctx.arc(-baseRadius * 0.3, -baseRadius * 0.3, baseRadius * 0.35, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = sprite.color;
ctx.shadowBlur = 0;
} else {
// Simple circle with glow
ctx.shadowBlur = 10;
ctx.shadowColor = sprite.color;
ctx.beginPath();
ctx.arc(0, 0, baseRadius, 0, Math.PI * 2);
ctx.fill();
ctx.shadowBlur = 0;
}
}
drawHealthBar(health, sprite) {
const ctx = this.ctx;
const barWidth = sprite.width * 1.5;
const barHeight = 4;
const yOffset = sprite.height / 2 + 10;
// Background
ctx.fillStyle = 'rgba(0, 0, 0, 0.5)';
ctx.fillRect(-barWidth / 2, -yOffset, barWidth, barHeight);
// Health fill
const healthPercent = health.hp / health.maxHp;
ctx.fillStyle = healthPercent > 0.5 ? '#00ff00' : healthPercent > 0.25 ? '#ffff00' : '#ff0000';
ctx.fillRect(-barWidth / 2, -yOffset, barWidth * healthPercent, barHeight);
// Border
ctx.strokeStyle = '#ffffff';
ctx.lineWidth = 1;
ctx.strokeRect(-barWidth / 2, -yOffset, barWidth, barHeight);
}
drawAttackIndicator(combat, _position) {
const ctx = this.ctx;
const length = 50;
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 = 8;
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) * 5 * attackProgress;
const perpY = Math.cos(angle) * 5 * attackProgress;
ctx.quadraticCurveTo(midX + perpX, midY + perpY, tentacleEndX, tentacleEndY);
ctx.stroke();
// Draw impact point
if (attackProgress > 0.5) {
ctx.beginPath();
ctx.arc(tentacleEndX, tentacleEndY, 6 * attackProgress, 0, Math.PI * 2);
ctx.fill();
}
}
drawStealthIndicator(stealth, sprite) {
const ctx = this.ctx;
const radius = Math.max(sprite.width, sprite.height) / 2 + 5;
// Stealth ring
ctx.strokeStyle = `rgba(0, 255, 150, ${1 - stealth.visibility})`;
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(0, 0, radius, 0, Math.PI * 2);
ctx.stroke();
// Visibility indicator
if (stealth.visibility > 0.3) {
ctx.fillStyle = `rgba(255, 0, 0, ${(stealth.visibility - 0.3) * 2})`;
ctx.beginPath();
ctx.arc(0, -radius - 10, 3, 0, Math.PI * 2);
ctx.fill();
}
}
drawSkillEffects() {
const skillEffectSystem = this.engine.systems.find(s => s.name === 'SkillEffectSystem');
if (!skillEffectSystem) return;
const effects = skillEffectSystem.getEffects();
const ctx = this.ctx;
effects.forEach(effect => {
ctx.save();
switch (effect.type) {
case 'fire_breath':
this.drawFireBreath(ctx, effect);
break;
case 'pounce':
this.drawPounce(ctx, effect);
break;
case 'pounce_impact':
this.drawPounceImpact(ctx, effect);
break;
}
ctx.restore();
});
}
drawFireBreath(ctx, effect) {
const progress = Math.min(1.0, effect.time / effect.lifetime); // Clamp to 0-1
const alpha = Math.max(0, 1.0 - progress); // Ensure non-negative
// Draw fire cone
ctx.translate(effect.x, effect.y);
ctx.rotate(effect.angle);
// Cone gradient
const gradient = ctx.createRadialGradient(0, 0, 0, 0, 0, effect.range);
gradient.addColorStop(0, `rgba(255, 100, 0, ${alpha * 0.8})`);
gradient.addColorStop(0.5, `rgba(255, 200, 0, ${alpha * 0.6})`);
gradient.addColorStop(1, `rgba(255, 50, 0, ${alpha * 0.3})`);
ctx.fillStyle = gradient;
ctx.beginPath();
ctx.moveTo(0, 0);
ctx.arc(0, 0, effect.range, -effect.coneAngle / 2, effect.coneAngle / 2);
ctx.closePath();
ctx.fill();
// Fire particles
for (let i = 0; i < 20; i++) {
const angle = (Math.random() - 0.5) * effect.coneAngle;
const dist = Math.random() * effect.range * progress;
const x = Math.cos(angle) * dist;
const y = Math.sin(angle) * dist;
const size = 3 + Math.random() * 5;
ctx.fillStyle = `rgba(255, ${150 + Math.random() * 100}, 0, ${alpha})`;
ctx.beginPath();
ctx.arc(x, y, size, 0, Math.PI * 2);
ctx.fill();
}
}
drawPounce(ctx, effect) {
const progress = Math.min(1.0, effect.time / effect.lifetime); // Clamp to 0-1
const currentX = effect.startX + Math.cos(effect.angle) * effect.speed * effect.time;
const currentY = effect.startY + Math.sin(effect.angle) * effect.speed * effect.time;
// Draw dash trail
const alpha = Math.max(0, 1.0 - progress); // Ensure non-negative
ctx.strokeStyle = `rgba(255, 200, 0, ${alpha})`;
ctx.lineWidth = 4;
ctx.lineCap = 'round';
ctx.beginPath();
ctx.moveTo(effect.startX, effect.startY);
ctx.lineTo(currentX, currentY);
ctx.stroke();
// Draw impact point
const radius = Math.max(0, 15 * (1 - progress)); // Ensure non-negative radius
if (radius > 0) {
ctx.fillStyle = `rgba(255, 150, 0, ${alpha})`;
ctx.beginPath();
ctx.arc(currentX, currentY, radius, 0, Math.PI * 2);
ctx.fill();
}
}
drawPounceImpact(ctx, effect) {
const progress = Math.min(1.0, effect.time / effect.lifetime); // Clamp to 0-1
const alpha = Math.max(0, 1.0 - progress); // Ensure non-negative
const size = Math.max(0, 30 * (1 - progress)); // Ensure non-negative size
if (size > 0 && alpha > 0) {
// Impact ring
ctx.strokeStyle = `rgba(255, 200, 0, ${alpha})`;
ctx.lineWidth = 3;
ctx.beginPath();
ctx.arc(effect.x, effect.y, size, 0, Math.PI * 2);
ctx.stroke();
// Impact particles
for (let i = 0; i < 8; i++) {
const angle = (i / 8) * Math.PI * 2;
const dist = size * 0.7;
const x = effect.x + Math.cos(angle) * dist;
const y = effect.y + Math.sin(angle) * dist;
ctx.fillStyle = `rgba(255, 150, 0, ${alpha})`;
ctx.beginPath();
ctx.arc(x, y, 4, 0, Math.PI * 2);
ctx.fill();
}
}
}
}

View file

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

View file

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

View file

@ -0,0 +1,74 @@
import { System } from '../core/System.js';
export class StealthSystem extends System {
constructor() {
super('StealthSystem');
this.requiredComponents = ['Stealth'];
this.priority = 12;
}
process(deltaTime, entities) {
const inputSystem = this.engine.systems.find(s => s.name === 'InputSystem');
const playerController = this.engine.systems.find(s => s.name === 'PlayerControllerSystem');
const player = playerController ? playerController.getPlayerEntity() : null;
entities.forEach(entity => {
const stealth = entity.getComponent('Stealth');
const velocity = entity.getComponent('Velocity');
const combat = entity.getComponent('Combat');
const evolution = entity.getComponent('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;
}
}
});
}
}

311
src/systems/UISystem.js Normal file
View file

@ -0,0 +1,311 @@
import { System } from '../core/System.js';
import { SkillRegistry } from '../skills/SkillRegistry.js';
import { Events } from '../core/EventBus.js';
export class UISystem extends System {
constructor(engine) {
super('UISystem');
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 === 'MenuSystem');
const gameState = menuSystem ? menuSystem.getGameState() : 'playing';
// Only draw menu overlay if in start or paused state
if (gameState === 'start' || gameState === 'paused') {
if (menuSystem) {
menuSystem.drawMenu();
}
// Don't draw game UI when menu is showing
return;
}
const playerController = this.engine.systems.find(s => s.name === 'PlayerControllerSystem');
const player = playerController ? playerController.getPlayerEntity() : null;
if (!player) return;
// Draw UI
this.drawHUD(player);
this.drawSkills(player);
this.drawStats(player);
this.drawSkillProgress(player);
this.drawDamageNumbers();
this.drawNotifications();
this.drawAbsorptionEffects();
}
drawHUD(player) {
const health = player.getComponent('Health');
const stats = player.getComponent('Stats');
const evolution = player.getComponent('Evolution');
const skills = player.getComponent('Skills');
if (!health || !stats || !evolution) return;
const ctx = this.ctx;
const _width = this.engine.canvas.width;
const _height = this.engine.canvas.height;
// Health bar
const barWidth = 200;
const barHeight = 20;
const barX = 20;
const barY = 20;
ctx.fillStyle = 'rgba(0, 0, 0, 0.7)';
ctx.fillRect(barX, barY, barWidth, barHeight);
const healthPercent = health.hp / health.maxHp;
ctx.fillStyle = healthPercent > 0.5 ? '#00ff00' : healthPercent > 0.25 ? '#ffff00' : '#ff0000';
ctx.fillRect(barX, barY, barWidth * healthPercent, barHeight);
ctx.strokeStyle = '#ffffff';
ctx.lineWidth = 2;
ctx.strokeRect(barX, barY, barWidth, barHeight);
ctx.fillStyle = '#ffffff';
ctx.font = '14px Courier New';
ctx.fillText(`HP: ${Math.ceil(health.hp)}/${health.maxHp}`, barX + 5, barY + 15);
// Evolution display
const form = evolution.getDominantForm();
const formY = barY + barHeight + 10;
ctx.fillStyle = '#ffffff';
ctx.font = '12px Courier New';
ctx.fillText(`Form: ${form.toUpperCase()}`, barX, formY);
ctx.fillText(`Human: ${evolution.human.toFixed(1)} | Beast: ${evolution.beast.toFixed(1)} | Slime: ${evolution.slime.toFixed(1)}`, barX, formY + 15);
// Instructions
const instructionsY = formY + 40;
ctx.fillStyle = 'rgba(255, 255, 255, 0.7)';
ctx.font = '11px Courier New';
ctx.fillText('WASD: Move | Mouse: Aim | Click/Space: Attack', barX, instructionsY);
ctx.fillText('Shift: Stealth | 1-9: Skills (Press 1 for Slime Gun)', barX, instructionsY + 15);
// Show skill hint if player has skills
if (skills && skills.activeSkills.length > 0) {
ctx.fillStyle = '#00ff96';
ctx.fillText(`You have ${skills.activeSkills.length} skill(s)! Press 1-${skills.activeSkills.length} to use them.`, barX, instructionsY + 30);
} else {
ctx.fillStyle = '#ffaa00';
ctx.fillText('Defeat and absorb creatures 5 times to learn their skills!', barX, instructionsY + 30);
}
// Health regeneration hint
ctx.fillStyle = '#00aaff';
ctx.fillText('Health regenerates when not in combat', barX, instructionsY + 45);
}
drawSkills(player) {
const skills = player.getComponent('Skills');
if (!skills) return;
const ctx = this.ctx;
const width = this.engine.canvas.width;
const startX = width - 250;
const startY = 20;
ctx.fillStyle = 'rgba(0, 0, 0, 0.7)';
ctx.fillRect(startX, startY, 230, 30 + skills.activeSkills.length * 30);
ctx.fillStyle = '#ffffff';
ctx.font = '14px Courier New';
ctx.fillText('Skills:', startX + 10, startY + 20);
skills.activeSkills.forEach((skillId, index) => {
const y = startY + 40 + index * 30;
const key = (index + 1).toString();
const onCooldown = skills.isOnCooldown(skillId);
const cooldown = skills.getCooldown(skillId);
// Get skill name from registry for display
const skill = SkillRegistry.get(skillId);
const skillName = skill ? skill.name : skillId.replace('_', ' ');
ctx.fillStyle = onCooldown ? '#888888' : '#00ff96';
ctx.fillText(`${key}. ${skillName}${onCooldown ? ` (${cooldown.toFixed(1)}s)` : ''}`, startX + 10, y);
});
}
drawStats(player) {
const stats = player.getComponent('Stats');
if (!stats) return;
const ctx = this.ctx;
const width = this.engine.canvas.width;
const startX = width - 250;
const startY = 200;
ctx.fillStyle = 'rgba(0, 0, 0, 0.7)';
ctx.fillRect(startX, startY, 230, 150);
ctx.fillStyle = '#ffffff';
ctx.font = '12px Courier New';
let y = startY + 20;
ctx.fillText('Stats:', startX + 10, y);
y += 20;
ctx.fillText(`STR: ${stats.strength}`, startX + 10, y);
y += 15;
ctx.fillText(`AGI: ${stats.agility}`, startX + 10, y);
y += 15;
ctx.fillText(`INT: ${stats.intelligence}`, startX + 10, y);
y += 15;
ctx.fillText(`CON: ${stats.constitution}`, startX + 10, y);
y += 15;
ctx.fillText(`PER: ${stats.perception}`, startX + 10, y);
}
drawSkillProgress(player) {
const skillProgress = player.getComponent('SkillProgress');
if (!skillProgress) return;
const ctx = this.ctx;
const width = this.engine.canvas.width;
const startX = width - 250;
const startY = 360;
const progress = skillProgress.getAllProgress();
if (progress.size === 0) return;
ctx.fillStyle = 'rgba(0, 0, 0, 0.7)';
ctx.fillRect(startX, startY, 230, 30 + progress.size * 25);
ctx.fillStyle = '#ffffff';
ctx.font = '12px Courier New';
ctx.fillText('Skill Progress:', startX + 10, startY + 20);
let y = startY + 35;
progress.forEach((count, skillId) => {
const required = skillProgress.requiredAbsorptions;
const _percent = Math.min(100, (count / required) * 100);
const skill = SkillRegistry.get(skillId);
const skillName = skill ? skill.name : skillId.replace('_', ' ');
ctx.fillStyle = count >= required ? '#00ff00' : '#ffff00';
ctx.fillText(`${skillName}: ${count}/${required}`, startX + 10, y);
y += 20;
});
}
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 alpha = Math.min(1, num.lifetime);
const size = 14 + Math.min(num.value / 2, 10);
ctx.font = `bold ${size}px Courier New`;
// Shadow
ctx.fillStyle = `rgba(0, 0, 0, ${alpha * 0.5})`;
ctx.fillText(num.value.toString(), num.x + 2, num.y + 2);
// Main text
ctx.fillStyle = num.color.startsWith('rgba') ? num.color : `rgba(${this.hexToRgb(num.color)}, ${alpha})`;
ctx.fillText(num.value.toString(), num.x, num.y);
});
}
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) => {
ctx.fillStyle = `rgba(255, 255, 0, ${note.alpha})`;
ctx.font = 'bold 20px Courier New';
ctx.textAlign = 'center';
ctx.fillText(note.text, width / 2, 100 + index * 30);
ctx.textAlign = 'left';
});
}
drawAbsorptionEffects() {
const absorptionSystem = this.engine.systems.find(s => s.name === 'AbsorptionSystem');
if (!absorptionSystem || !absorptionSystem.getEffects) return;
const effects = absorptionSystem.getEffects();
const ctx = this.ctx;
effects.forEach(effect => {
const alpha = Math.min(1, effect.lifetime * 2);
// Glow effect
ctx.shadowBlur = 10;
ctx.shadowColor = effect.color;
ctx.fillStyle = effect.color;
ctx.globalAlpha = alpha;
ctx.beginPath();
ctx.arc(effect.x, effect.y, effect.size, 0, Math.PI * 2);
ctx.fill();
// Inner bright core
ctx.fillStyle = 'rgba(255, 255, 255, 0.8)';
ctx.beginPath();
ctx.arc(effect.x, effect.y, effect.size * 0.5, 0, Math.PI * 2);
ctx.fill();
ctx.globalAlpha = 1.0;
ctx.shadowBlur = 0;
});
}
}