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(ComponentType.HEALTH); const evolution = entity.getComponent(ComponentType.EVOLUTION); if (!entity.active) { const absorbable = entity.getComponent(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(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(ComponentType.POSITION); const sprite = entity.getComponent(ComponentType.SPRITE); const health = entity.getComponent(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(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(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(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(ComponentType.STEALTH); if (stealth && stealth.isStealthed) { this.drawStealthIndicator(stealth, sprite); } const evolution = entity.getComponent(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(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(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(); } } } }