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

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