slime/src/systems/UISystem.ts

344 lines
10 KiB
TypeScript

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<Health>(ComponentType.HEALTH);
const stats = player.getComponent<Stats>(ComponentType.STATS);
const evolution = player.getComponent<Evolution>(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<Skills>(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<Stats>(ComponentType.STATS);
const evolution = player.getComponent<Evolution>(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<SkillProgress>(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<Evolution>(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);
});
}
}