feat: Implement pixel-art rendering with new level loading, tile maps, palettes, and pixel fonts, alongside a game over screen.

This commit is contained in:
Juan Sebastián Montoya 2026-01-06 17:21:15 -05:00
parent 5b15e63ac3
commit cf04677511
41 changed files with 793 additions and 331 deletions

View file

@ -15,13 +15,20 @@ export class AISystem extends System {
const config = GameConfig.AI;
entities.forEach(entity => {
const health = entity.getComponent('Health');
const ai = entity.getComponent('AI');
const position = entity.getComponent('Position');
const velocity = entity.getComponent('Velocity');
const _stealth = entity.getComponent('Stealth');
if (!ai || !position || !velocity) return;
// Stop movement for dead entities
if (health && health.isDead() && !health.isProjectile) {
velocity.vx = 0;
velocity.vy = 0;
return;
}
// Update wander timer
ai.wanderChangeTime += deltaTime;

View file

@ -24,9 +24,15 @@ export class DeathSystem extends System {
// Check if entity is dead
if (health.isDead()) {
// Don't remove player
// Check if player died
const evolution = entity.getComponent('Evolution');
if (evolution) return; // Player has Evolution component
if (evolution) {
const menuSystem = this.engine.systems.find(s => s.name === 'MenuSystem');
if (menuSystem) {
menuSystem.showGameOver();
}
return;
}
// Mark as inactive immediately so it stops being processed by other systems
if (entity.active) {

View file

@ -25,3 +25,4 @@ export class HealthRegenerationSystem extends System {
}
}

View file

@ -76,8 +76,12 @@ export class InputSystem extends System {
if (this.engine && this.engine.canvas) {
const canvas = this.engine.canvas;
const rect = canvas.getBoundingClientRect();
this.mouse.x = e.clientX - rect.left;
this.mouse.y = e.clientY - rect.top;
// Calculate scale factors between displayed size and internal resolution
const scaleX = canvas.width / rect.width;
const scaleY = canvas.height / rect.height;
this.mouse.x = (e.clientX - rect.left) * scaleX;
this.mouse.y = (e.clientY - rect.top) * scaleY;
}
});
@ -96,8 +100,8 @@ export class InputSystem extends System {
}
/**
* Update previous states - called at end of frame
*/
* Update previous states - called at end of frame
*/
updatePreviousStates() {
// Deep copy current states to previous for next frame
this.keysPrevious = {};
@ -111,15 +115,15 @@ export class InputSystem extends System {
}
/**
* Check if a key is currently pressed
*/
* Check if a key is currently pressed
*/
isKeyPressed(key) {
return this.keys[key.toLowerCase()] === true;
}
/**
* Check if a key was just pressed (not held from previous frame)
*/
* Check if a key was just pressed (not held from previous frame)
*/
isKeyJustPressed(key) {
const keyLower = key.toLowerCase();
const isPressed = this.keys[keyLower] === true;
@ -128,22 +132,22 @@ export class InputSystem extends System {
}
/**
* Get mouse position
*/
* Get mouse position
*/
getMousePosition() {
return { x: this.mouse.x, y: this.mouse.y };
}
/**
* Check if mouse button is pressed
*/
* Check if mouse button is pressed
*/
isMouseButtonPressed(button = 0) {
return this.mouse.buttons[button] === true;
}
/**
* Check if mouse button was just pressed
*/
* Check if mouse button was just pressed
*/
isMouseButtonJustPressed(button = 0) {
const isPressed = this.mouse.buttons[button] === true;
const wasPressed = this.mouse.buttonsPrevious[button] === true;

View file

@ -1,4 +1,6 @@
import { System } from '../core/System.js';
import { PixelFont } from '../core/PixelFont.js';
import { Palette } from '../core/Palette.js';
/**
* System to handle game menus (start, pause)
@ -31,11 +33,22 @@ export class MenuSystem extends System {
this.startGame();
} else if (this.gameState === 'paused') {
this.resumeGame();
} else if (this.gameState === 'gameOver') {
this.restartGame();
}
}
});
}
showGameOver() {
this.gameState = 'gameOver';
this.paused = true;
}
restartGame() {
window.location.reload(); // Simple and effective for this project
}
startGame() {
this.gameState = 'playing';
this.paused = false;
@ -75,27 +88,50 @@ export class MenuSystem extends System {
const width = this.engine.canvas.width;
const height = this.engine.canvas.height;
// Dark overlay
ctx.fillStyle = 'rgba(0, 0, 0, 0.7)';
// Darker overlay matching palette
ctx.fillStyle = 'rgba(32, 21, 51, 0.8)'; // Semi-transparent VOID
ctx.fillRect(0, 0, width, height);
ctx.fillStyle = '#ffffff';
ctx.font = 'bold 48px Courier New';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
if (this.gameState === 'start') {
ctx.fillText('SLIME GENESIS', width / 2, height / 2 - 100);
ctx.font = '24px Courier New';
ctx.fillText('Press ENTER or SPACE to Start', width / 2, height / 2);
ctx.font = '16px Courier New';
ctx.fillText('WASD: Move | Mouse: Aim | Click/Space: Attack', width / 2, height / 2 + 50);
ctx.fillText('Shift: Stealth | 1-9: Skills | ESC: Pause', width / 2, height / 2 + 80);
const title = 'SLIME GENESIS';
const titleW = PixelFont.getTextWidth(title, 2);
PixelFont.drawText(ctx, title, (width - titleW) / 2, height / 2 - 40, Palette.CYAN, 2);
const start = 'PRESS ENTER TO START';
const startW = PixelFont.getTextWidth(start, 1);
PixelFont.drawText(ctx, start, (width - startW) / 2, height / 2, Palette.WHITE, 1);
const instructions = [
'WASD: MOVE | CLICK: ATTACK',
'NUMS: SKILLS | ESC: PAUSE',
'COLLECT DNA TO EVOLVE'
];
instructions.forEach((line, i) => {
const lineW = PixelFont.getTextWidth(line, 1);
PixelFont.drawText(ctx, line, (width - lineW) / 2, height / 2 + 25 + i * 10, Palette.ROYAL_BLUE, 1);
});
} else if (this.gameState === 'paused') {
ctx.fillText('PAUSED', width / 2, height / 2 - 50);
ctx.font = '24px Courier New';
ctx.fillText('Press ENTER or SPACE to Resume', width / 2, height / 2);
ctx.fillText('Press ESC to Pause/Unpause', width / 2, height / 2 + 40);
const paused = 'PAUSED';
const pausedW = PixelFont.getTextWidth(paused, 2);
PixelFont.drawText(ctx, paused, (width - pausedW) / 2, height / 2 - 20, Palette.SKY_BLUE, 2);
const resume = 'PRESS ENTER TO RESUME';
const resumeW = PixelFont.getTextWidth(resume, 1);
PixelFont.drawText(ctx, resume, (width - resumeW) / 2, height / 2 + 10, Palette.WHITE, 1);
} else if (this.gameState === 'gameOver') {
const dead = 'YOU PERISHED';
const deadW = PixelFont.getTextWidth(dead, 2);
PixelFont.drawText(ctx, dead, (width - deadW) / 2, height / 2 - 30, Palette.WHITE, 2);
const sub = 'YOUR DNA SUSTAINS THE CYCLE';
const subW = PixelFont.getTextWidth(sub, 1);
PixelFont.drawText(ctx, sub, (width - subW) / 2, height / 2 - 5, Palette.ROYAL_BLUE, 1);
const restart = 'PRESS ENTER TO REBORN';
const restartW = PixelFont.getTextWidth(restart, 1);
PixelFont.drawText(ctx, restart, (width - restartW) / 2, height / 2 + 30, Palette.CYAN, 1);
}
}

View file

@ -12,7 +12,7 @@ export class MovementSystem extends System {
const position = entity.getComponent('Position');
const velocity = entity.getComponent('Velocity');
const health = entity.getComponent('Health');
if (!position || !velocity) return;
// Check if this is a projectile
@ -28,9 +28,24 @@ export class MovementSystem extends System {
}
}
// Update position
position.x += velocity.vx * deltaTime;
position.y += velocity.vy * deltaTime;
// Update position with collision detection
const tileMap = this.engine.tileMap;
// X Axis
const nextX = position.x + velocity.vx * deltaTime;
if (tileMap && tileMap.isSolid(nextX, position.y)) {
velocity.vx = 0;
} else {
position.x = nextX;
}
// Y Axis
const nextY = position.y + velocity.vy * deltaTime;
if (tileMap && tileMap.isSolid(position.x, nextY)) {
velocity.vy = 0;
} else {
position.y = nextY;
}
// Apply friction (skip for projectiles - they should maintain speed)
if (!isProjectile) {

View file

@ -26,7 +26,7 @@ export class PlayerControllerSystem extends System {
// Movement input
let moveX = 0;
let moveY = 0;
const moveSpeed = 200;
const moveSpeed = 100; // Scaled down for 320x240
if (inputSystem.isKeyPressed('w') || inputSystem.isKeyPressed('arrowup')) {
moveY -= 1;
@ -67,3 +67,4 @@ export class PlayerControllerSystem extends System {
}
}

View file

@ -1,4 +1,6 @@
import { System } from '../core/System.js';
import { Palette } from '../core/Palette.js';
import { SpriteLibrary } from '../core/SpriteLibrary.js';
export class RenderSystem extends System {
constructor(engine) {
@ -16,6 +18,9 @@ export class RenderSystem extends System {
// Draw background
this.drawBackground();
// Draw map
this.drawMap();
// Draw entities
// Get all entities including inactive ones for rendering dead absorbable entities
const allEntities = this.engine.entities;
@ -54,35 +59,48 @@ export class RenderSystem extends System {
const width = this.engine.canvas.width;
const height = this.engine.canvas.height;
// Cave background with gradient
const gradient = ctx.createLinearGradient(0, 0, 0, height);
gradient.addColorStop(0, '#0f0f1f');
gradient.addColorStop(1, '#1a1a2e');
ctx.fillStyle = gradient;
// Solid background
ctx.fillStyle = Palette.VOID;
ctx.fillRect(0, 0, width, height);
// Add cave features with better visuals
ctx.fillStyle = '#2a2a3e';
// Dithered pattern or simple shapes for cave features
ctx.fillStyle = Palette.DARKER_BLUE;
for (let i = 0; i < 20; i++) {
const x = (i * 70 + Math.sin(i) * 30) % width;
const y = (i * 50 + Math.cos(i) * 40) % height;
const size = 25 + (i % 4) * 15;
// Snap to grid for pixel art look
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);
// Add shadow
ctx.shadowBlur = 20;
ctx.shadowColor = 'rgba(0, 0, 0, 0.5)';
ctx.beginPath();
ctx.arc(x, y, size, 0, Math.PI * 2);
ctx.fill();
ctx.shadowBlur = 0;
// Draw as rectangles or pixelated circles? Let's use Rects for now to match the style better or keep arcs but accept anti-aliasing
// Use integer coordinates strictly.
// Pixel Art style: use small squares instead of circles
ctx.fillRect(x, y, size, size);
}
}
// Add some ambient lighting
const lightGradient = ctx.createRadialGradient(width / 2, height / 2, 0, width / 2, height / 2, 400);
lightGradient.addColorStop(0, 'rgba(100, 150, 200, 0.1)');
lightGradient.addColorStop(1, 'rgba(0, 0, 0, 0)');
ctx.fillStyle = lightGradient;
ctx.fillRect(0, 0, width, height);
drawMap() {
const tileMap = this.engine.tileMap;
if (!tileMap) return;
const ctx = this.ctx;
const tileSize = tileMap.tileSize;
// Draw walls
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) { // 1 is wall
ctx.fillRect(c * tileSize, r * tileSize, tileSize, tileSize);
// Highlight top for 3D feel
ctx.fillStyle = Palette.ROYAL_BLUE;
ctx.fillRect(c * tileSize, r * tileSize, tileSize, 2);
ctx.fillStyle = Palette.DARK_BLUE;
}
}
}
}
drawEntity(entity, deltaTime, isDeadFade = false) {
@ -93,6 +111,11 @@ export class RenderSystem extends System {
if (!position || !sprite) return;
this.ctx.save();
// Pixel snapping
const drawX = Math.floor(position.x);
const drawY = Math.floor(position.y);
// Fade out dead entities
let alpha = sprite.alpha;
if (isDeadFade && health && health.isDead()) {
@ -106,8 +129,8 @@ export class RenderSystem extends System {
}
}
this.ctx.globalAlpha = alpha;
this.ctx.translate(position.x, position.y);
this.ctx.rotate(position.rotation);
this.ctx.translate(drawX, drawY);
// REMOVED GLOBAL ROTATION: this.ctx.rotate(position.rotation);
this.ctx.scale(sprite.scale, sprite.scale);
// Update animation time for slime morphing
@ -116,26 +139,110 @@ export class RenderSystem extends System {
sprite.morphAmount = Math.sin(sprite.animationTime * 3) * 0.2 + 0.8;
}
// Draw based on shape
this.ctx.fillStyle = sprite.color;
// Map legacy colors to new Palette if necessary
let drawColor = sprite.color;
if (sprite.shape === 'slime') drawColor = Palette.CYAN;
// Map other colors? For now keep them if they match, but we should enforce palette eventually.
// The previous code had specific hardcoded colors.
if (sprite.shape === 'circle' || sprite.shape === 'slime') {
this.drawSlime(sprite);
} else if (sprite.shape === 'rect') {
this.ctx.fillRect(-sprite.width / 2, -sprite.height / 2, sprite.width, sprite.height);
this.ctx.fillStyle = drawColor;
// Select appropriate animation state based on velocity
const velocity = entity.getComponent('Velocity');
if (velocity) {
const isMoving = Math.abs(velocity.vx) > 1 || Math.abs(velocity.vy) > 1;
sprite.animationState = isMoving ? 'walk' : 'idle';
}
// Draw health bar if entity has health
if (health && health.maxHp > 0) {
// Lookup animation data
let spriteData = SpriteLibrary[sprite.shape];
if (!spriteData) {
spriteData = SpriteLibrary.slime; // Hard fallback
}
// Get animation frames for the current state
let frames = spriteData[sprite.animationState] || spriteData['idle'];
// If frames is still not an array (fallback for simple grids or missing states)
if (!frames || !Array.isArray(frames)) {
// If it's a 2D array (legacy/simple), wrap it
if (Array.isArray(spriteData) || Array.isArray(spriteData[0])) {
frames = [spriteData];
} else if (spriteData.idle) {
frames = spriteData.idle;
} else {
frames = SpriteLibrary.slime.idle;
}
}
// Update animation timing
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;
// Calculate pixel size to fit the defined sprite dimensions
const pixelW = sprite.width / cols;
const pixelH = sprite.height / rows;
// Draw from center
const offsetX = -sprite.width / 2;
const offsetY = -sprite.height / 2;
// Horizontal Flipping based on rotation (facing left/right)
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;
// Determine color
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;
}
// Draw pixel (snapped to nearest integer for crisp look)
this.ctx.fillRect(
offsetX + c * pixelW,
offsetY + r * pixelH,
Math.ceil(pixelW),
Math.ceil(pixelH)
);
}
}
this.ctx.restore();
// Draw health bar if entity has health (stays horizontal)
if (health && health.maxHp > 0 && !health.isProjectile) {
this.drawHealthBar(health, sprite);
}
// Draw combat indicator if attacking
// Draw combat indicator if attacking (This DOES rotate)
const combat = entity.getComponent('Combat');
if (combat && combat.isAttacking) {
// Draw attack indicator relative to entity's current rotation
// Since we're already rotated, we need to draw relative to 0,0 forward
this.ctx.save();
this.ctx.rotate(position.rotation);
this.drawAttackIndicator(combat, position);
this.ctx.restore();
}
// Draw stealth indicator
@ -144,124 +251,68 @@ export class RenderSystem extends System {
this.drawStealthIndicator(stealth, sprite);
}
// Mutation Visual Effects
// Mutation Visual Effects - Simplified for pixel art
const evolution = entity.getComponent('Evolution');
if (evolution) {
if (evolution.mutationEffects.glowingBody) {
// Draw light aura
const auraGradient = this.ctx.createRadialGradient(0, 0, 0, 0, 0, sprite.width * 2);
auraGradient.addColorStop(0, 'rgba(255, 255, 200, 0.2)');
auraGradient.addColorStop(1, 'rgba(255, 255, 200, 0)');
this.ctx.fillStyle = auraGradient;
this.ctx.beginPath();
this.ctx.arc(0, 0, sprite.width * 2, 0, Math.PI * 2);
this.ctx.fill();
// Simple outline (square)
this.ctx.strokeStyle = Palette.WHITE;
this.ctx.lineWidth = 1;
this.ctx.strokeRect(-sprite.width / 2 - 2, -sprite.height / 2 - 2, sprite.width + 4, sprite.height + 4);
}
if (evolution.mutationEffects.electricSkin) {
// Add tiny sparks
// Sparks
if (Math.random() < 0.2) {
this.ctx.strokeStyle = '#00ffff';
this.ctx.lineWidth = 2;
this.ctx.beginPath();
const sparkX = (Math.random() - 0.5) * sprite.width;
const sparkY = (Math.random() - 0.5) * sprite.height;
this.ctx.moveTo(sparkX, sparkY);
this.ctx.lineTo(sparkX + (Math.random() - 0.5) * 10, sparkY + (Math.random() - 0.5) * 10);
this.ctx.stroke();
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);
}
}
if (evolution.mutationEffects.hardenedShell) {
// Darker, thicker border
this.ctx.strokeStyle = 'rgba(0, 0, 0, 0.5)';
this.ctx.lineWidth = 3;
this.ctx.stroke();
}
}
this.ctx.restore();
}
drawSlime(sprite) {
const ctx = this.ctx;
const baseRadius = Math.min(sprite.width, sprite.height) / 2;
if (sprite.shape === 'slime') {
// Animated slime blob with morphing and better visuals
ctx.shadowBlur = 15;
ctx.shadowColor = sprite.color;
// Main body with morphing
ctx.beginPath();
const points = 16;
for (let i = 0; i < points; i++) {
const angle = (i / points) * Math.PI * 2;
const morph1 = Math.sin(angle * 2 + sprite.animationTime * 2) * 0.15;
const morph2 = Math.cos(angle * 3 + sprite.animationTime * 1.5) * 0.1;
const radius = baseRadius * (sprite.morphAmount + morph1 + morph2);
const x = Math.cos(angle) * radius;
const y = Math.sin(angle) * radius;
if (i === 0) {
ctx.moveTo(x, y);
} else {
ctx.lineTo(x, y);
}
}
ctx.closePath();
ctx.fill();
// Inner glow
const innerGradient = ctx.createRadialGradient(0, 0, 0, 0, 0, baseRadius * 0.8);
innerGradient.addColorStop(0, 'rgba(255, 255, 255, 0.4)');
innerGradient.addColorStop(1, 'rgba(255, 255, 255, 0)');
ctx.fillStyle = innerGradient;
ctx.beginPath();
ctx.arc(0, 0, baseRadius * 0.8, 0, Math.PI * 2);
ctx.fill();
// Highlight
ctx.fillStyle = 'rgba(255, 255, 255, 0.5)';
ctx.beginPath();
ctx.arc(-baseRadius * 0.3, -baseRadius * 0.3, baseRadius * 0.35, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = sprite.color;
ctx.shadowBlur = 0;
} else {
// Simple circle with glow
ctx.shadowBlur = 10;
ctx.shadowColor = sprite.color;
ctx.beginPath();
ctx.arc(0, 0, baseRadius, 0, Math.PI * 2);
ctx.fill();
ctx.shadowBlur = 0;
}
}
drawHealthBar(health, sprite) {
// Pixel art health bar
const ctx = this.ctx;
const barWidth = sprite.width * 1.5;
const barHeight = 4;
const yOffset = sprite.height / 2 + 10;
// Width relative to sprite, snapped to even number
const barWidth = Math.floor(sprite.width * 1.2);
const barHeight = 2;
const yOffset = Math.floor(sprite.height / 2 + 3);
// Background
ctx.fillStyle = 'rgba(0, 0, 0, 0.5)';
ctx.fillRect(-barWidth / 2, -yOffset, barWidth, barHeight);
const startX = -Math.floor(barWidth / 2);
const startY = -yOffset;
// Health fill
const healthPercent = health.hp / health.maxHp;
ctx.fillStyle = healthPercent > 0.5 ? '#00ff00' : healthPercent > 0.25 ? '#ffff00' : '#ff0000';
ctx.fillRect(-barWidth / 2, -yOffset, barWidth * healthPercent, barHeight);
// Background (Dark Blue)
ctx.fillStyle = Palette.DARK_BLUE;
ctx.fillRect(startX, startY, barWidth, barHeight);
// Border
ctx.strokeStyle = '#ffffff';
ctx.lineWidth = 1;
ctx.strokeRect(-barWidth / 2, -yOffset, barWidth, barHeight);
// Fill
const healthPercent = Math.max(0, health.hp / health.maxHp);
const fillWidth = Math.floor(barWidth * healthPercent);
// Color based on Health (Palette only)
// High: CYAN, Mid: SKY_BLUE, Low: WHITE (flashing)
if (healthPercent > 0.5) {
ctx.fillStyle = Palette.CYAN;
} else if (healthPercent > 0.25) {
ctx.fillStyle = Palette.SKY_BLUE;
} else {
// Flash white for low health
ctx.fillStyle = (Math.floor(Date.now() / 200) % 2 === 0) ? Palette.WHITE : Palette.ROYAL_BLUE;
}
ctx.fillRect(startX, startY, fillWidth, barHeight);
}
drawAttackIndicator(combat, _position) {
const ctx = this.ctx;
const length = 50;
const length = 25; // Scaled down
const attackProgress = 1.0 - (combat.attackCooldown / 0.3); // 0 to 1 during attack animation
// Since we're already in entity's rotated coordinate space (ctx.rotate was applied),
@ -272,7 +323,7 @@ export class RenderSystem extends System {
// Draw slime tentacle/extension
ctx.strokeStyle = `rgba(0, 255, 150, ${0.8 * attackProgress})`;
ctx.fillStyle = `rgba(0, 255, 150, ${0.6 * attackProgress})`;
ctx.lineWidth = 8;
ctx.lineWidth = 4; // Scaled down
ctx.lineCap = 'round';
// Tentacle extends outward during attack (forward from entity)
@ -286,15 +337,15 @@ export class RenderSystem extends System {
// Add slight curve to tentacle
const midX = Math.cos(angle) * tentacleLength * 0.5;
const midY = Math.sin(angle) * tentacleLength * 0.5;
const perpX = -Math.sin(angle) * 5 * attackProgress;
const perpY = Math.cos(angle) * 5 * attackProgress;
const perpX = -Math.sin(angle) * 3 * attackProgress;
const perpY = Math.cos(angle) * 3 * attackProgress;
ctx.quadraticCurveTo(midX + perpX, midY + perpY, tentacleEndX, tentacleEndY);
ctx.stroke();
// Draw impact point
if (attackProgress > 0.5) {
ctx.beginPath();
ctx.arc(tentacleEndX, tentacleEndY, 6 * attackProgress, 0, Math.PI * 2);
ctx.arc(tentacleEndX, tentacleEndY, 3 * attackProgress, 0, Math.PI * 2);
ctx.fill();
}
}

View file

@ -39,3 +39,4 @@ export class SkillEffectSystem extends System {
}
}

View file

@ -1,6 +1,8 @@
import { System } from '../core/System.js';
import { SkillRegistry } from '../skills/SkillRegistry.js';
import { Events } from '../core/EventBus.js';
import { PixelFont } from '../core/PixelFont.js';
import { Palette } from '../core/Palette.js';
export class UISystem extends System {
constructor(engine) {
@ -15,7 +17,7 @@ export class UISystem extends System {
// Subscribe to events
engine.on(Events.DAMAGE_DEALT, (data) => this.addDamageNumber(data));
engine.on(Events.MUTATION_GAINED, (data) => this.addNotification(`Mutation Gained: ${data.name}`));
engine.on(Events.MUTATION_GAINED, (data) => this.addNotification(`Mutation Gained: ${data.name} `));
}
addDamageNumber(data) {
@ -45,8 +47,8 @@ export class UISystem extends System {
const menuSystem = this.engine.systems.find(s => s.name === 'MenuSystem');
const gameState = menuSystem ? menuSystem.getGameState() : 'playing';
// Only draw menu overlay if in start or paused state
if (gameState === 'start' || gameState === 'paused') {
// Only draw menu overlay if in start, paused, or gameOver state
if (gameState === 'start' || gameState === 'paused' || gameState === 'gameOver') {
if (menuSystem) {
menuSystem.drawMenu();
}
@ -73,62 +75,43 @@ export class UISystem extends System {
const health = player.getComponent('Health');
const stats = player.getComponent('Stats');
const evolution = player.getComponent('Evolution');
const skills = player.getComponent('Skills');
if (!health || !stats || !evolution) return;
const ctx = this.ctx;
const _width = this.engine.canvas.width;
const _height = this.engine.canvas.height;
// Health bar
const barWidth = 200;
const barHeight = 20;
const barX = 20;
const barY = 20;
const barWidth = 64;
const barHeight = 6;
const barX = 4;
const barY = 4;
ctx.fillStyle = 'rgba(0, 0, 0, 0.7)';
// Outer border
ctx.fillStyle = Palette.DARK_BLUE;
ctx.fillRect(barX - 1, barY - 1, barWidth + 2, barHeight + 2);
// Background
ctx.fillStyle = Palette.VOID;
ctx.fillRect(barX, barY, barWidth, barHeight);
const healthPercent = health.hp / health.maxHp;
ctx.fillStyle = healthPercent > 0.5 ? '#00ff00' : healthPercent > 0.25 ? '#ffff00' : '#ff0000';
ctx.fillRect(barX, barY, barWidth * healthPercent, barHeight);
ctx.fillStyle = healthPercent > 0.5 ? Palette.CYAN : healthPercent > 0.25 ? Palette.SKY_BLUE : Palette.WHITE;
ctx.fillRect(barX, barY, Math.floor(barWidth * healthPercent), barHeight);
ctx.strokeStyle = '#ffffff';
ctx.lineWidth = 2;
ctx.strokeRect(barX, barY, barWidth, barHeight);
ctx.fillStyle = '#ffffff';
ctx.font = '14px Courier New';
ctx.fillText(`HP: ${Math.ceil(health.hp)}/${health.maxHp}`, barX + 5, barY + 15);
// HP Text
PixelFont.drawText(ctx, `${Math.ceil(health.hp)}/${health.maxHp}`, barX, barY + barHeight + 3, Palette.WHITE, 1);
// Evolution display
const form = evolution.getDominantForm();
const formY = barY + barHeight + 10;
ctx.fillStyle = '#ffffff';
ctx.font = '12px Courier New';
ctx.fillText(`Form: ${form.toUpperCase()}`, barX, formY);
ctx.fillText(`Human: ${evolution.human.toFixed(1)} | Beast: ${evolution.beast.toFixed(1)} | Slime: ${evolution.slime.toFixed(1)}`, barX, formY + 15);
const formY = barY + barHeight + 14;
PixelFont.drawText(ctx, form.toUpperCase(), barX, formY, Palette.SKY_BLUE, 1);
// Instructions
const instructionsY = formY + 40;
ctx.fillStyle = 'rgba(255, 255, 255, 0.7)';
ctx.font = '11px Courier New';
ctx.fillText('WASD: Move | Mouse: Aim | Click/Space: Attack', barX, instructionsY);
ctx.fillText('Shift: Stealth | 1-9: Skills (Press 1 for Slime Gun)', barX, instructionsY + 15);
// Tiny evolution details
const evoDetails = `H${Math.floor(evolution.human)} B${Math.floor(evolution.beast)} S${Math.floor(evolution.slime)}`;
PixelFont.drawText(ctx, evoDetails, barX, formY + 9, Palette.ROYAL_BLUE, 1);
// Show skill hint if player has skills
if (skills && skills.activeSkills.length > 0) {
ctx.fillStyle = '#00ff96';
ctx.fillText(`You have ${skills.activeSkills.length} skill(s)! Press 1-${skills.activeSkills.length} to use them.`, barX, instructionsY + 30);
} else {
ctx.fillStyle = '#ffaa00';
ctx.fillText('Defeat and absorb creatures 5 times to learn their skills!', barX, instructionsY + 30);
}
// Health regeneration hint
ctx.fillStyle = '#00aaff';
ctx.fillText('Health regenerates when not in combat', barX, instructionsY + 45);
// Small Instructions
PixelFont.drawText(ctx, 'WASD CLICK', barX, this.engine.canvas.height - 10, Palette.DARK_BLUE, 1);
}
drawSkills(player) {
@ -137,28 +120,23 @@ export class UISystem extends System {
const ctx = this.ctx;
const width = this.engine.canvas.width;
const startX = width - 250;
const startY = 20;
const startX = width - 80;
const startY = 4;
ctx.fillStyle = 'rgba(0, 0, 0, 0.7)';
ctx.fillRect(startX, startY, 230, 30 + skills.activeSkills.length * 30);
ctx.fillStyle = '#ffffff';
ctx.font = '14px Courier New';
ctx.fillText('Skills:', startX + 10, startY + 20);
PixelFont.drawText(ctx, 'SKILLS', startX, startY, Palette.WHITE, 1);
skills.activeSkills.forEach((skillId, index) => {
const y = startY + 40 + index * 30;
const key = (index + 1).toString();
const y = startY + 10 + index * 9;
const onCooldown = skills.isOnCooldown(skillId);
const cooldown = skills.getCooldown(skillId);
// Get skill name from registry for display
const skill = SkillRegistry.get(skillId);
const skillName = skill ? skill.name : skillId.replace('_', ' ');
let skillName = skill ? skill.name : skillId.replace('_', ' ');
if (skillName.length > 10) skillName = skillName.substring(0, 10);
ctx.fillStyle = onCooldown ? '#888888' : '#00ff96';
ctx.fillText(`${key}. ${skillName}${onCooldown ? ` (${cooldown.toFixed(1)}s)` : ''}`, startX + 10, y);
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);
});
}
@ -168,26 +146,12 @@ export class UISystem extends System {
const ctx = this.ctx;
const width = this.engine.canvas.width;
const startX = width - 250;
const startY = 200;
const startX = width - 80;
const startY = 60;
ctx.fillStyle = 'rgba(0, 0, 0, 0.7)';
ctx.fillRect(startX, startY, 230, 150);
ctx.fillStyle = '#ffffff';
ctx.font = '12px Courier New';
let y = startY + 20;
ctx.fillText('Stats:', startX + 10, y);
y += 20;
ctx.fillText(`STR: ${stats.strength}`, startX + 10, y);
y += 15;
ctx.fillText(`AGI: ${stats.agility}`, startX + 10, y);
y += 15;
ctx.fillText(`INT: ${stats.intelligence}`, startX + 10, y);
y += 15;
ctx.fillText(`CON: ${stats.constitution}`, startX + 10, y);
y += 15;
ctx.fillText(`PER: ${stats.perception}`, startX + 10, y);
PixelFont.drawText(ctx, 'STATS', startX, startY, Palette.WHITE, 1);
PixelFont.drawText(ctx, `STR ${stats.strength} AGI ${stats.agility}`, startX, startY + 9, Palette.ROYAL_BLUE, 1);
PixelFont.drawText(ctx, `INT ${stats.intelligence} CON ${stats.constitution}`, startX, startY + 18, Palette.ROYAL_BLUE, 1);
}
drawSkillProgress(player) {
@ -196,29 +160,24 @@ export class UISystem extends System {
const ctx = this.ctx;
const width = this.engine.canvas.width;
const startX = width - 250;
const startY = 360;
const startX = width - 80;
const startY = 100;
const progress = skillProgress.getAllProgress();
if (progress.size === 0) return;
ctx.fillStyle = 'rgba(0, 0, 0, 0.7)';
ctx.fillRect(startX, startY, 230, 30 + progress.size * 25);
PixelFont.drawText(ctx, 'LRN', startX, startY, Palette.CYAN, 1);
ctx.fillStyle = '#ffffff';
ctx.font = '12px Courier New';
ctx.fillText('Skill Progress:', startX + 10, startY + 20);
let y = startY + 35;
let idx = 0;
progress.forEach((count, skillId) => {
const required = skillProgress.requiredAbsorptions;
const _percent = Math.min(100, (count / required) * 100);
const skill = SkillRegistry.get(skillId);
const skillName = skill ? skill.name : skillId.replace('_', ' ');
let name = skill ? skill.name : skillId;
if (name.length > 4) name = name.substring(0, 4);
ctx.fillStyle = count >= required ? '#00ff00' : '#ffff00';
ctx.fillText(`${skillName}: ${count}/${required}`, startX + 10, y);
y += 20;
const y = startY + 9 + idx * 8;
PixelFont.drawText(ctx, `${name} ${count}/${required}`, startX, y, Palette.SKY_BLUE, 1);
idx++;
});
}
@ -244,17 +203,9 @@ export class UISystem extends System {
drawDamageNumbers() {
const ctx = this.ctx;
this.damageNumbers.forEach(num => {
const alpha = Math.min(1, num.lifetime);
const size = 14 + Math.min(num.value / 2, 10);
const color = num.color.startsWith('rgba') ? num.color : Palette.WHITE;
ctx.font = `bold ${size}px Courier New`;
// Shadow
ctx.fillStyle = `rgba(0, 0, 0, ${alpha * 0.5})`;
ctx.fillText(num.value.toString(), num.x + 2, num.y + 2);
// Main text
ctx.fillStyle = num.color.startsWith('rgba') ? num.color : `rgba(${this.hexToRgb(num.color)}, ${alpha})`;
ctx.fillText(num.value.toString(), num.x, num.y);
PixelFont.drawText(ctx, num.value.toString(), Math.floor(num.x), Math.floor(num.y), color, 1);
});
}
@ -270,11 +221,11 @@ export class UISystem extends System {
const width = this.engine.canvas.width;
this.notifications.forEach((note, index) => {
ctx.fillStyle = `rgba(255, 255, 0, ${note.alpha})`;
ctx.font = 'bold 20px Courier New';
ctx.textAlign = 'center';
ctx.fillText(note.text, width / 2, 100 + index * 30);
ctx.textAlign = 'left';
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);
});
}