feat: introduce VFXSystem to centralize visual effect management and rendering, migrating absorption effects from UISystem and AbsorptionSystem.

This commit is contained in:
Juan Sebastián Montoya 2026-01-06 18:56:20 -05:00
parent 691bc3b8da
commit e9db84abd1
8 changed files with 188 additions and 107 deletions

View file

@ -14,6 +14,7 @@ import { DeathSystem } from './systems/DeathSystem.js';
import { MenuSystem } from './systems/MenuSystem.js'; import { MenuSystem } from './systems/MenuSystem.js';
import { RenderSystem } from './systems/RenderSystem.js'; import { RenderSystem } from './systems/RenderSystem.js';
import { UISystem } from './systems/UISystem.js'; import { UISystem } from './systems/UISystem.js';
import { VFXSystem } from './systems/VFXSystem.js';
// Components // Components
import { Position } from './components/Position.js'; import { Position } from './components/Position.js';
@ -51,6 +52,7 @@ if (!canvas) {
engine.addSystem(new SkillEffectSystem()); engine.addSystem(new SkillEffectSystem());
engine.addSystem(new HealthRegenerationSystem()); engine.addSystem(new HealthRegenerationSystem());
engine.addSystem(new DeathSystem()); engine.addSystem(new DeathSystem());
engine.addSystem(new VFXSystem());
engine.addSystem(new RenderSystem(engine)); engine.addSystem(new RenderSystem(engine));
engine.addSystem(new UISystem(engine)); engine.addSystem(new UISystem(engine));
@ -82,25 +84,25 @@ if (!canvas) {
let color, evolutionData, skills; let color, evolutionData, skills;
switch (type) { switch (type) {
case 'humanoid': case 'humanoid':
color = '#ff5555'; // Humanoid red color = '#ff5555'; // Humanoid red
evolutionData = { human: 10, beast: 0, slime: -2 }; evolutionData = { human: 10, beast: 0, slime: -2 };
skills = ['fire_breath']; skills = ['fire_breath'];
break; break;
case 'beast': case 'beast':
color = '#ffaa00'; // Beast orange color = '#ffaa00'; // Beast orange
evolutionData = { human: 0, beast: 10, slime: -2 }; evolutionData = { human: 0, beast: 10, slime: -2 };
skills = ['pounce']; skills = ['pounce'];
break; break;
case 'elemental': case 'elemental':
color = '#00bfff'; color = '#00bfff';
evolutionData = { human: 3, beast: 3, slime: 8 }; evolutionData = { human: 3, beast: 3, slime: 8 };
skills = ['fire_breath']; skills = ['fire_breath'];
break; break;
default: default:
color = '#888888'; color = '#888888';
evolutionData = { human: 2, beast: 2, slime: 2 }; evolutionData = { human: 2, beast: 2, slime: 2 };
skills = []; skills = [];
} }
creature.addComponent(new Sprite(color, 10, 10, type)); creature.addComponent(new Sprite(color, 10, 10, type));

View file

@ -7,10 +7,9 @@ export class AbsorptionSystem extends System {
super('AbsorptionSystem'); super('AbsorptionSystem');
this.requiredComponents = ['Position', 'Absorbable']; this.requiredComponents = ['Position', 'Absorbable'];
this.priority = 25; this.priority = 25;
this.absorptionEffects = []; // Visual effects
} }
process(deltaTime, _entities) { process(_deltaTime, _entities) {
const playerController = this.engine.systems.find(s => s.name === 'PlayerControllerSystem'); const playerController = this.engine.systems.find(s => s.name === 'PlayerControllerSystem');
const player = playerController ? playerController.getPlayerEntity() : null; const player = playerController ? playerController.getPlayerEntity() : null;
@ -62,8 +61,7 @@ export class AbsorptionSystem extends System {
} }
}); });
// Update visual effects // NO LOCAL Update visual effects
this.updateEffects(deltaTime);
} }
absorbEntity(player, entity, absorbable, evolution, skills, stats, skillProgress) { absorbEntity(player, entity, absorbable, evolution, skills, stats, skillProgress) {
@ -113,7 +111,10 @@ export class AbsorptionSystem extends System {
// Visual effect // Visual effect
if (entityPos) { if (entityPos) {
this.addAbsorptionEffect(entityPos.x, entityPos.y); const vfxSystem = this.engine.systems.find(s => s.name === 'VFXSystem');
if (vfxSystem) {
vfxSystem.createAbsorption(entityPos.x, entityPos.y);
}
} }
// Mark as absorbed - DeathSystem will handle removal after absorption window // Mark as absorbed - DeathSystem will handle removal after absorption window
@ -140,37 +141,5 @@ export class AbsorptionSystem extends System {
} }
} }
addAbsorptionEffect(x, y) {
for (let i = 0; i < 20; i++) {
this.absorptionEffects.push({
x,
y,
vx: (Math.random() - 0.5) * 200,
vy: (Math.random() - 0.5) * 200,
lifetime: 0.5 + Math.random() * 0.5,
size: 3 + Math.random() * 5,
color: `hsl(${120 + Math.random() * 60}, 100%, 50%)`
});
}
}
updateEffects(deltaTime) {
for (let i = this.absorptionEffects.length - 1; i >= 0; i--) {
const effect = this.absorptionEffects[i];
effect.x += effect.vx * deltaTime;
effect.y += effect.vy * deltaTime;
effect.lifetime -= deltaTime;
effect.vx *= 0.95;
effect.vy *= 0.95;
if (effect.lifetime <= 0) {
this.absorptionEffects.splice(i, 1);
}
}
}
getEffects() {
return this.absorptionEffects;
}
} }

View file

@ -29,8 +29,8 @@ export class CombatSystem extends System {
// Handle creature attacks // Handle creature attacks
const creatures = entities.filter(e => const creatures = entities.filter(e =>
e.hasComponent('AI') && e.hasComponent('AI') &&
e.hasComponent('Combat') && e.hasComponent('Combat') &&
e !== player e !== player
); );
creatures.forEach(creature => { creatures.forEach(creature => {
@ -163,10 +163,9 @@ export class CombatSystem extends System {
} }
} }
// If target is dead, emit event // If target is dead, let DeathSystem handle removal/deactivation
if (health.isDead()) { if (health.isDead()) {
this.engine.emit(Events.ENTITY_DIED, { entity: target }); this.engine.emit(Events.ENTITY_DIED, { entity: target });
target.active = false;
} }
// Apply knockback // Apply knockback

View file

@ -35,9 +35,13 @@ export class DeathSystem extends System {
} }
// Mark as inactive immediately so it stops being processed by other systems // Mark as inactive immediately so it stops being processed by other systems
if (entity.active) { if (entity.active || !entity.deathTime) {
entity.active = false; if (entity.active) {
entity.deathTime = Date.now(); // Set death time when first marked dead 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 // Check if it's absorbable - if so, give a short window for absorption

View file

@ -1,4 +1,6 @@
import { System } from '../core/System.js'; import { System } from '../core/System.js';
import { Events } from '../core/EventBus.js';
import { Palette } from '../core/Palette.js';
export class ProjectileSystem extends System { export class ProjectileSystem extends System {
constructor() { constructor() {
@ -54,16 +56,23 @@ export class ProjectileSystem extends System {
const dy = targetPos.y - position.y; const dy = targetPos.y - position.y;
const distance = Math.sqrt(dx * dx + dy * dy); const distance = Math.sqrt(dx * dx + dy * dy);
if (distance < 20) { if (distance < 8) {
// Hit! // Hit!
const targetHealth = target.getComponent('Health'); const targetHealth = target.getComponent('Health');
const damage = entity.damage || 10; const damage = entity.damage || 10;
targetHealth.takeDamage(damage); targetHealth.takeDamage(damage);
// If target is dead, mark it for immediate removal // Impact animation
const vfxSystem = this.engine.systems.find(s => s.name === 'VFXSystem');
const velocity = entity.getComponent('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()) { if (targetHealth.isDead()) {
target.active = false; this.engine.emit(Events.ENTITY_DIED, { entity: target });
// DeathSystem will handle removal
} }
// Remove projectile // Remove projectile
@ -74,7 +83,7 @@ export class ProjectileSystem extends System {
// Boundary check // Boundary check
const canvas = this.engine.canvas; const canvas = this.engine.canvas;
if (position.x < 0 || position.x > canvas.width || if (position.x < 0 || position.x > canvas.width ||
position.y < 0 || position.y > canvas.height) { position.y < 0 || position.y > canvas.height) {
this.engine.removeEntity(entity); this.engine.removeEntity(entity);
} }
}); });

View file

@ -52,6 +52,7 @@ export class RenderSystem extends System {
// Draw skill effects // Draw skill effects
this.drawSkillEffects(); this.drawSkillEffects();
this.drawVFX();
} }
drawBackground() { drawBackground() {
@ -276,6 +277,29 @@ export class RenderSystem extends System {
drawVFX() {
const vfxSystem = this.engine.systems.find(s => s.name === 'VFXSystem');
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 === '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) { drawHealthBar(health, sprite) {
// Pixel art health bar // Pixel art health bar
const ctx = this.ctx; const ctx = this.ctx;
@ -381,15 +405,15 @@ export class RenderSystem extends System {
ctx.save(); ctx.save();
switch (effect.type) { switch (effect.type) {
case 'fire_breath': case 'fire_breath':
this.drawFireBreath(ctx, effect); this.drawFireBreath(ctx, effect);
break; break;
case 'pounce': case 'pounce':
this.drawPounce(ctx, effect); this.drawPounce(ctx, effect);
break; break;
case 'pounce_impact': case 'pounce_impact':
this.drawPounceImpact(ctx, effect); this.drawPounceImpact(ctx, effect);
break; break;
} }
ctx.restore(); ctx.restore();

View file

@ -67,7 +67,6 @@ export class UISystem extends System {
// REMOVED drawStats and drawSkillProgress from active gameplay // REMOVED drawStats and drawSkillProgress from active gameplay
this.drawDamageNumbers(); this.drawDamageNumbers();
this.drawNotifications(); this.drawNotifications();
this.drawAbsorptionEffects();
} }
drawHUD(player) { drawHUD(player) {
@ -225,34 +224,5 @@ export class UISystem extends System {
}); });
} }
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;
});
}
} }

104
src/systems/VFXSystem.js Normal file
View file

@ -0,0 +1,104 @@
import { System } from '../core/System.js';
import { Palette } from '../core/Palette.js';
export class VFXSystem extends System {
constructor() {
super('VFXSystem');
this.requiredComponents = [];
this.priority = 40;
this.particles = [];
}
process(deltaTime, _entities) {
const playerController = this.engine.systems.find(s => s.name === 'PlayerControllerSystem');
const player = playerController ? playerController.getPlayerEntity() : null;
const playerPos = player ? player.getComponent('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 === '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);
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) {
const count = 8;
for (let i = 0; i < count; i++) {
let vx, vy;
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;
vy = Math.sin(angle + spread) * speed;
} else {
vx = (Math.random() - 0.5) * 150;
vy = (Math.random() - 0.5) * 150;
}
this.particles.push({
x,
y,
vx,
vy,
lifetime: 0.2 + Math.random() * 0.3,
size: 1 + Math.random() * 2,
color: color,
type: 'impact'
});
}
}
createAbsorption(x, y, color = Palette.CYAN) {
for (let i = 0; i < 12; i++) {
// Start with a small explosion then attract
this.particles.push({
x,
y,
vx: (Math.random() - 0.5) * 100,
vy: (Math.random() - 0.5) * 100,
lifetime: 1.5,
size: 2,
color: color,
type: 'absorption'
});
}
}
getParticles() {
return this.particles;
}
}