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

@ -12,3 +12,4 @@ export class Component {
}
}

View file

@ -1,6 +1,7 @@
import { System } from './System.js';
import { Entity } from './Entity.js';
import { EventBus } from './EventBus.js';
import { LevelLoader } from './LevelLoader.js';
/**
* Main game engine - manages ECS, game loop, and systems
@ -15,17 +16,27 @@ export class Engine {
this.running = false;
this.lastTime = 0;
// Set canvas size
this.canvas.width = 1024;
this.canvas.height = 768;
// Set internal resolution (low-res for pixel art)
this.canvas.width = 320;
this.canvas.height = 240;
// Apply CSS for sharp pixel scaling
this.canvas.style.imageRendering = 'pixelated'; // Standard
// Fallbacks for other browsers if needed (mostly covered by modern standards, but good to be safe)
this.canvas.style.imageRendering = '-moz-crisp-edges';
this.canvas.style.imageRendering = 'crisp-edges';
this.ctx.imageSmoothingEnabled = false;
// Game state
this.deltaTime = 0;
// Initialize standard map (320x240 / 16px tiles = 20x15)
this.tileMap = LevelLoader.loadSimpleLevel(20, 15, 16);
}
/**
* Add a system to the engine
*/
* Add a system to the engine
*/
addSystem(system) {
if (system instanceof System) {
system.init(this);
@ -37,22 +48,22 @@ export class Engine {
}
/**
* Emit an event locally
*/
* Emit an event locally
*/
emit(event, data) {
this.events.emit(event, data);
}
/**
* Subscribe to an event
*/
* Subscribe to an event
*/
on(event, callback) {
return this.events.on(event, callback);
}
/**
* Create and add an entity
*/
* Create and add an entity
*/
createEntity() {
const entity = new Entity();
this.entities.push(entity);
@ -60,8 +71,8 @@ export class Engine {
}
/**
* Remove an entity
*/
* Remove an entity
*/
removeEntity(entity) {
const index = this.entities.indexOf(entity);
if (index > -1) {
@ -70,15 +81,15 @@ export class Engine {
}
/**
* Get all entities
*/
* Get all entities
*/
getEntities() {
return this.entities.filter(e => e.active);
}
/**
* Main game loop
*/
* Main game loop
*/
start() {
if (this.running) return;
this.running = true;
@ -87,15 +98,15 @@ export class Engine {
}
/**
* Stop the game loop
*/
* Stop the game loop
*/
stop() {
this.running = false;
}
/**
* Game loop using requestAnimationFrame
*/
* Game loop using requestAnimationFrame
*/
gameLoop = (currentTime = 0) => {
if (!this.running) return;
@ -109,7 +120,7 @@ export class Engine {
// Update all systems
const menuSystem = this.systems.find(s => s.name === 'MenuSystem');
const gameState = menuSystem ? menuSystem.getGameState() : 'playing';
const isPaused = gameState === 'paused' || gameState === 'start';
const isPaused = gameState === 'paused' || gameState === 'start' || gameState === 'gameOver';
this.systems.forEach(system => {
// Skip game systems if paused/start menu (but allow MenuSystem, UISystem, and RenderSystem)
@ -130,8 +141,8 @@ export class Engine {
}
/**
* Clear the canvas
*/
* Clear the canvas
*/
clear() {
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
}

View file

@ -56,3 +56,4 @@ export class Entity {
}
}

22
src/core/LevelLoader.js Normal file
View file

@ -0,0 +1,22 @@
import { TileMap } from './TileMap.js';
export class LevelLoader {
static loadSimpleLevel(cols, rows, tileSize) {
const map = new TileMap(cols, rows, tileSize);
// Create a box arena for testing
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
if (r === 0 || r === rows - 1 || c === 0 || c === cols - 1) {
map.setTile(c, r, 1); // Wall
} else {
// Random obstacles
if (Math.random() < 0.1) {
map.setTile(c, r, 1);
}
}
}
}
return map;
}
}

27
src/core/Palette.js Normal file
View file

@ -0,0 +1,27 @@
/**
* Limited 7-color palette for the game
*/
export const Palette = {
WHITE: '#ffffff', // Highlights, UI Text
CYAN: '#0ce6f2', // Energy, Slime core
SKY_BLUE: '#0098db', // Water, Friendly elements
ROYAL_BLUE: '#1e579c', // Shadows, Depth
DARK_BLUE: '#203562', // Walls, Obstacles
DARKER_BLUE: '#252446', // Background details
VOID: '#201533', // Void, Deep Background
/**
* Get all colors as an array
*/
getAll() {
return [
this.WHITE,
this.CYAN,
this.SKY_BLUE,
this.ROYAL_BLUE,
this.DARK_BLUE,
this.DARKER_BLUE,
this.VOID
];
}
};

80
src/core/PixelFont.js Normal file
View file

@ -0,0 +1,80 @@
/**
* Simple 5x7 Matrix Pixel Font
* Each character is a 5x7 bitmask
*/
const FONT_DATA = {
'A': [0x0E, 0x11, 0x11, 0x1F, 0x11, 0x11, 0x11],
'B': [0x1E, 0x11, 0x11, 0x1E, 0x11, 0x11, 0x1E],
'C': [0x0E, 0x11, 0x11, 0x10, 0x11, 0x11, 0x0E],
'D': [0x1E, 0x11, 0x11, 0x11, 0x11, 0x11, 0x1E],
'E': [0x1F, 0x10, 0x10, 0x1E, 0x10, 0x10, 0x1F],
'F': [0x1F, 0x10, 0x10, 0x1E, 0x10, 0x10, 0x10],
'G': [0x0F, 0x10, 0x10, 0x17, 0x11, 0x11, 0x0F],
'H': [0x11, 0x11, 0x11, 0x1F, 0x11, 0x11, 0x11],
'I': [0x0E, 0x04, 0x04, 0x04, 0x04, 0x04, 0x0E],
'J': [0x07, 0x02, 0x02, 0x02, 0x02, 0x12, 0x0C],
'K': [0x11, 0x12, 0x14, 0x18, 0x14, 0x12, 0x11],
'L': [0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x1F],
'M': [0x11, 0x1B, 0x15, 0x15, 0x11, 0x11, 0x11],
'N': [0x11, 0x11, 0x19, 0x15, 0x13, 0x11, 0x11],
'O': [0x0E, 0x11, 0x11, 0x11, 0x11, 0x11, 0x0E],
'P': [0x1E, 0x11, 0x11, 0x1E, 0x10, 0x10, 0x10],
'Q': [0x0E, 0x11, 0x11, 0x11, 0x15, 0x12, 0x0D],
'R': [0x1E, 0x11, 0x11, 0x1E, 0x14, 0x12, 0x11],
'S': [0x0E, 0x11, 0x10, 0x0E, 0x01, 0x11, 0x0E],
'T': [0x1F, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04],
'U': [0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x0E],
'V': [0x11, 0x11, 0x11, 0x11, 0x11, 0x0A, 0x04],
'W': [0x11, 0x11, 0x11, 0x15, 0x15, 0x1B, 0x11],
'X': [0x11, 0x11, 0x0A, 0x04, 0x0A, 0x11, 0x11],
'Y': [0x11, 0x11, 0x0A, 0x04, 0x04, 0x04, 0x04],
'Z': [0x1F, 0x01, 0x02, 0x04, 0x08, 0x10, 0x1F],
'0': [0x0E, 0x11, 0x13, 0x15, 0x19, 0x11, 0x0E],
'1': [0x04, 0x0C, 0x04, 0x04, 0x04, 0x04, 0x0E],
'2': [0x0E, 0x11, 0x01, 0x02, 0x04, 0x08, 0x1F],
'3': [0x1F, 0x02, 0x04, 0x02, 0x01, 0x11, 0x0E],
'4': [0x02, 0x06, 0x0A, 0x12, 0x1F, 0x02, 0x02],
'5': [0x1F, 0x10, 0x1E, 0x01, 0x01, 0x11, 0x0E],
'6': [0x06, 0x08, 0x10, 0x1E, 0x11, 0x11, 0x0E],
'7': [0x1F, 0x01, 0x02, 0x04, 0x08, 0x08, 0x08],
'8': [0x0E, 0x11, 0x11, 0x0E, 0x11, 0x11, 0x0E],
'9': [0x0E, 0x11, 0x11, 0x0F, 0x01, 0x02, 0x0C],
':': [0x00, 0x04, 0x00, 0x00, 0x00, 0x04, 0x00],
'.': [0x00, 0x00, 0x00, 0x00, 0x00, 0x04, 0x00],
',': [0x00, 0x00, 0x00, 0x00, 0x00, 0x04, 0x08],
'!': [0x04, 0x04, 0x04, 0x04, 0x04, 0x00, 0x04],
'?': [0x0E, 0x11, 0x01, 0x02, 0x04, 0x00, 0x04],
'+': [0x00, 0x04, 0x04, 0x1F, 0x04, 0x04, 0x00],
'-': [0x00, 0x00, 0x00, 0x1F, 0x00, 0x00, 0x00],
'/': [0x01, 0x02, 0x04, 0x08, 0x10, 0x10, 0x10],
'(': [0x02, 0x04, 0x04, 0x04, 0x04, 0x04, 0x02],
')': [0x08, 0x04, 0x04, 0x04, 0x04, 0x04, 0x08],
' ': [0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00],
'|': [0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04]
};
export const PixelFont = {
drawText(ctx, text, x, y, color = '#ffffff', scale = 1) {
ctx.save();
ctx.fillStyle = color;
let cursorX = x;
const chars = text.toUpperCase().split('');
chars.forEach(char => {
const glyph = FONT_DATA[char] || FONT_DATA['?'];
for (let row = 0; row < 7; row++) {
for (let col = 0; col < 5; col++) {
if ((glyph[row] >> (4 - col)) & 1) {
ctx.fillRect(cursorX + col * scale, y + row * scale, scale, scale);
}
}
}
cursorX += 6 * scale; // 5 width + 1 spacing
});
ctx.restore();
},
getTextWidth(text, scale = 1) {
return text.length * 6 * scale;
}
};

167
src/core/SpriteLibrary.js Normal file
View file

@ -0,0 +1,167 @@
/**
* Sprite Library defining pixel art grids as 2D arrays.
* 0: Transparent
* 1: Primary Color (Entity Color)
* 2: Highlight (White / Shine)
* 3: Detail/Shade (Darker Blue / Eyes)
*/
export const SpriteLibrary = {
// 8x8 Slime - Bottom-heavy blob
slime: {
idle: [
[
[0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 1, 1, 1, 1, 0, 0], // Top
[0, 1, 1, 1, 1, 1, 1, 0],
[1, 1, 2, 1, 1, 2, 1, 1], // Highlights
[1, 1, 3, 1, 1, 3, 1, 1], // Eyes
[1, 1, 1, 1, 1, 1, 1, 1],
[1, 1, 1, 1, 1, 1, 1, 1],
[0, 1, 1, 1, 1, 1, 1, 0] // Flat-ish base
],
[
[0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 1, 1, 1, 1, 0, 0],
[0, 1, 1, 1, 1, 1, 1, 0],
[1, 1, 2, 1, 1, 2, 1, 1],
[1, 1, 3, 1, 1, 3, 1, 1],
[1, 1, 1, 1, 1, 1, 1, 1],
[1, 1, 1, 1, 1, 1, 1, 1] // Squashed base
]
],
walk: [
[
[0, 0, 1, 1, 1, 1, 0, 0],
[0, 1, 1, 1, 1, 1, 1, 0],
[1, 1, 2, 1, 1, 2, 1, 1],
[1, 1, 3, 1, 1, 3, 1, 1],
[1, 1, 1, 1, 1, 1, 1, 1],
[1, 1, 1, 1, 1, 1, 1, 1],
[1, 3, 1, 1, 1, 1, 3, 1],
[0, 1, 1, 1, 1, 1, 1, 0]
],
[
[0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 1, 1, 1, 1, 0, 0],
[0, 1, 1, 1, 1, 1, 1, 0],
[1, 1, 2, 1, 1, 2, 1, 1],
[1, 1, 3, 1, 1, 3, 1, 1],
[1, 1, 1, 1, 1, 1, 1, 1],
[1, 1, 1, 1, 1, 1, 1, 1],
[1, 1, 1, 1, 1, 1, 1, 1]
]
]
},
// 8x8 Humanoid - Simple Walk Cycle
humanoid: {
idle: [
[
[0, 0, 0, 1, 1, 0, 0, 0],
[0, 0, 2, 1, 1, 2, 0, 0],
[0, 0, 0, 1, 1, 0, 0, 0],
[0, 1, 1, 1, 1, 1, 1, 0],
[1, 0, 2, 1, 1, 2, 0, 1],
[1, 0, 1, 1, 1, 1, 0, 1],
[0, 0, 1, 0, 0, 1, 0, 0],
[0, 0, 1, 0, 0, 1, 0, 0]
]
],
walk: [
[
[0, 0, 0, 1, 1, 0, 0, 0],
[0, 0, 2, 1, 1, 2, 0, 0],
[0, 0, 0, 1, 1, 0, 0, 0],
[0, 1, 1, 1, 1, 1, 1, 0],
[0, 0, 2, 1, 1, 2, 0, 1],
[0, 0, 1, 1, 1, 1, 0, 1],
[0, 0, 1, 0, 0, 0, 0, 0],
[0, 1, 1, 0, 0, 0, 0, 0]
],
[
[0, 0, 0, 1, 1, 0, 0, 0],
[0, 0, 2, 1, 1, 2, 0, 0],
[0, 0, 0, 1, 1, 0, 0, 0],
[0, 1, 1, 1, 1, 1, 1, 0],
[1, 0, 2, 1, 1, 2, 0, 0],
[1, 0, 1, 1, 1, 1, 0, 0],
[0, 0, 0, 0, 0, 1, 0, 0],
[0, 0, 0, 0, 0, 1, 1, 0]
]
]
},
// 8x8 Beast - Bounding Cycle
beast: {
idle: [
[
[0, 0, 0, 0, 0, 0, 0, 0],
[1, 0, 0, 0, 0, 0, 0, 1],
[0, 1, 1, 1, 1, 1, 1, 0],
[1, 3, 1, 1, 1, 1, 3, 1],
[1, 1, 1, 1, 1, 1, 1, 1],
[0, 1, 1, 2, 2, 1, 1, 0],
[0, 1, 0, 0, 0, 0, 1, 0],
[0, 1, 0, 0, 0, 0, 1, 0]
]
],
walk: [
[
[1, 0, 0, 0, 0, 0, 0, 1],
[0, 1, 1, 1, 1, 1, 1, 0],
[1, 3, 1, 1, 1, 1, 3, 1],
[1, 1, 1, 1, 1, 1, 1, 1],
[0, 1, 1, 2, 2, 1, 1, 0],
[0, 1, 0, 0, 0, 0, 1, 0],
[1, 1, 0, 0, 0, 0, 1, 1],
[0, 0, 0, 0, 0, 0, 0, 0]
],
[
[0, 0, 0, 0, 0, 0, 0, 0],
[1, 0, 0, 0, 0, 0, 0, 1],
[0, 1, 1, 1, 1, 1, 1, 0],
[1, 3, 1, 1, 1, 1, 3, 1],
[1, 1, 1, 1, 1, 1, 1, 1],
[0, 1, 1, 2, 2, 1, 1, 0],
[0, 0, 1, 0, 0, 1, 0, 0],
[0, 0, 1, 0, 0, 1, 0, 0]
]
]
},
// 8x8 Elemental - Floating Pulse
elemental: {
idle: [
[
[0, 0, 2, 1, 1, 2, 0, 0],
[0, 1, 1, 2, 2, 1, 1, 0],
[1, 2, 1, 1, 1, 1, 2, 1],
[1, 1, 3, 3, 3, 3, 1, 1],
[1, 1, 1, 3, 3, 1, 1, 1],
[0, 1, 1, 1, 1, 1, 1, 0],
[0, 0, 1, 1, 1, 1, 0, 0],
[0, 0, 0, 1, 1, 0, 0, 0]
],
[
[0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 2, 1, 1, 2, 0, 0],
[0, 1, 1, 2, 2, 1, 1, 0],
[1, 2, 3, 3, 3, 3, 2, 1],
[1, 1, 1, 3, 3, 1, 1, 1],
[0, 1, 1, 1, 1, 1, 1, 0],
[0, 0, 1, 1, 1, 1, 0, 0],
[0, 0, 0, 1, 1, 0, 0, 0]
]
]
},
projectile: {
idle: [
[
[1, 1],
[1, 1]
]
]
}
};

View file

@ -43,3 +43,4 @@ export class System {
}
}

34
src/core/TileMap.js Normal file
View file

@ -0,0 +1,34 @@
export class TileMap {
constructor(cols, rows, tileSize) {
this.cols = cols;
this.rows = rows;
this.tileSize = tileSize;
this.tiles = new Array(cols * rows).fill(0);
}
setTile(col, row, value) {
if (this.isValid(col, row)) {
this.tiles[row * this.cols + col] = value;
}
}
getTile(col, row) {
if (this.isValid(col, row)) {
return this.tiles[row * this.cols + col];
}
return 1; // Treat out of bounds as solid wall
}
isValid(col, row) {
return col >= 0 && col < this.cols && row >= 0 && row < this.rows;
}
/**
* Check if a world position collides with a solid tile
*/
isSolid(x, y) {
const col = Math.floor(x / this.tileSize);
const row = Math.floor(y / this.tileSize);
return this.getTile(col, row) !== 0;
}
}