344 lines
10 KiB
TypeScript
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);
|
|
});
|
|
}
|
|
}
|