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