feat: migrate JavaScript files to TypeScript, enhancing type safety and maintainability across the codebase

This commit is contained in:
Juan Sebastián Montoya 2026-01-06 21:51:00 -05:00
parent 3db2bb9160
commit c582f2004e
107 changed files with 5876 additions and 3588 deletions

View file

@ -1,15 +0,0 @@
/**
* Base Component class for ECS architecture
* Components are pure data containers
*/
export class Component {
constructor(type) {
this.type = type;
}
static getType() {
return this.name;
}
}

25
src/core/Component.ts Normal file
View file

@ -0,0 +1,25 @@
/**
* Base Component class for ECS architecture.
* Components are pure data containers.
*/
export class Component {
/**
* The unique type identifier for this component
*/
readonly type: string;
/**
* @param type - The unique type identifier for this component
*/
constructor(type: string) {
this.type = type;
}
/**
* Get the type name of the component class.
* @returns The name of the class
*/
static getType(): string {
return this.name;
}
}

View file

@ -1,62 +0,0 @@
/**
* Centralized Constants and Enums
*/
export const GameState = {
START: 'start',
PLAYING: 'playing',
PAUSED: 'paused',
GAME_OVER: 'gameOver'
};
export const ComponentType = {
POSITION: 'Position',
VELOCITY: 'Velocity',
SPRITE: 'Sprite',
HEALTH: 'Health',
COMBAT: 'Combat',
AI: 'AI',
EVOLUTION: 'Evolution',
STATS: 'Stats',
SKILLS: 'Skills',
SKILL_PROGRESS: 'SkillProgress',
ABSORBABLE: 'Absorbable',
STEALTH: 'Stealth'
};
export const EntityType = {
SLIME: 'slime',
HUMANOID: 'humanoid',
BEAST: 'beast',
ELEMENTAL: 'elemental',
PROJECTILE: 'projectile'
};
export const AnimationState = {
IDLE: 'idle',
WALK: 'walk'
};
export const VFXType = {
IMPACT: 'impact',
ABSORPTION: 'absorption'
};
export const SystemName = {
MENU: 'MenuSystem',
UI: 'UISystem',
PLAYER_CONTROLLER: 'PlayerControllerSystem',
ABSORPTION: 'AbsorptionSystem',
COMBAT: 'CombatSystem',
PROJECTILE: 'ProjectileSystem',
VFX: 'VFXSystem',
MOVEMENT: 'MovementSystem',
AI: 'AISystem',
DEATH: 'DeathSystem',
RENDER: 'RenderSystem',
INPUT: 'InputSystem',
SKILL_EFFECT: 'SkillEffectSystem',
SKILL: 'SkillSystem',
STEALTH: 'StealthSystem',
HEALTH_REGEN: 'HealthRegenerationSystem'
};

82
src/core/Constants.ts Normal file
View file

@ -0,0 +1,82 @@
/**
* Game state enumeration.
*/
export enum GameState {
/** Initial start screen */
START = 'start',
/** Active gameplay */
PLAYING = 'playing',
/** Game paused */
PAUSED = 'paused',
/** Player death screen */
GAME_OVER = 'gameOver',
}
/**
* Component type identifiers.
*/
export enum ComponentType {
POSITION = 'Position',
VELOCITY = 'Velocity',
SPRITE = 'Sprite',
HEALTH = 'Health',
COMBAT = 'Combat',
AI = 'AI',
EVOLUTION = 'Evolution',
STATS = 'Stats',
SKILLS = 'Skills',
SKILL_PROGRESS = 'SkillProgress',
ABSORBABLE = 'Absorbable',
STEALTH = 'Stealth',
INTENT = 'Intent',
INVENTORY = 'Inventory',
}
/**
* Entity type identifiers for sprites and behaviors.
*/
export enum EntityType {
SLIME = 'slime',
HUMANOID = 'humanoid',
BEAST = 'beast',
ELEMENTAL = 'elemental',
PROJECTILE = 'projectile',
}
/**
* Animation states for sprites.
*/
export enum AnimationState {
IDLE = 'idle',
WALK = 'walk',
}
/**
* Visual effect types.
*/
export enum VFXType {
IMPACT = 'impact',
ABSORPTION = 'absorption',
}
/**
* System name identifiers.
*/
export enum SystemName {
MENU = 'MenuSystem',
UI = 'UISystem',
PLAYER_CONTROLLER = 'PlayerControllerSystem',
ABSORPTION = 'AbsorptionSystem',
COMBAT = 'CombatSystem',
PROJECTILE = 'ProjectileSystem',
VFX = 'VFXSystem',
MOVEMENT = 'MovementSystem',
AI = 'AISystem',
DEATH = 'DeathSystem',
RENDER = 'RenderSystem',
INPUT = 'InputSystem',
SKILL_EFFECT = 'SkillEffectSystem',
SKILL = 'SkillSystem',
STEALTH = 'StealthSystem',
HEALTH_REGEN = 'HealthRegenerationSystem',
}

View file

@ -1,152 +0,0 @@
import { System } from './System.js';
import { Entity } from './Entity.js';
import { EventBus } from './EventBus.js';
import { LevelLoader } from './LevelLoader.js';
import { GameState, SystemName } from './Constants.js';
/**
* Main game engine - manages ECS, game loop, and systems
*/
export class Engine {
constructor(canvas) {
this.canvas = canvas;
this.ctx = canvas.getContext('2d');
this.entities = [];
this.systems = [];
this.events = new EventBus();
this.running = false;
this.lastTime = 0;
// 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
*/
addSystem(system) {
if (system instanceof System) {
system.init(this);
this.systems.push(system);
// Sort by priority (lower priority runs first)
this.systems.sort((a, b) => a.priority - b.priority);
}
return this;
}
/**
* Emit an event locally
*/
emit(event, data) {
this.events.emit(event, data);
}
/**
* Subscribe to an event
*/
on(event, callback) {
return this.events.on(event, callback);
}
/**
* Create and add an entity
*/
createEntity() {
const entity = new Entity();
this.entities.push(entity);
return entity;
}
/**
* Remove an entity
*/
removeEntity(entity) {
const index = this.entities.indexOf(entity);
if (index > -1) {
this.entities.splice(index, 1);
}
}
/**
* Get all entities
*/
getEntities() {
return this.entities.filter(e => e.active);
}
/**
* Main game loop
*/
start() {
if (this.running) return;
this.running = true;
this.lastTime = performance.now();
this.gameLoop();
}
/**
* Stop the game loop
*/
stop() {
this.running = false;
}
/**
* Game loop using requestAnimationFrame
*/
gameLoop = (currentTime = 0) => {
if (!this.running) return;
// Calculate delta time
this.deltaTime = (currentTime - this.lastTime) / 1000; // Convert to seconds
this.lastTime = currentTime;
// Clamp delta time to prevent large jumps
this.deltaTime = Math.min(this.deltaTime, 0.1);
// Update all systems
const menuSystem = this.systems.find(s => s.name === SystemName.MENU);
const gameState = menuSystem ? menuSystem.getGameState() : GameState.PLAYING;
const isPaused = [GameState.PAUSED, GameState.START, GameState.GAME_OVER].includes(gameState);
const unskippedSystems = [SystemName.MENU, SystemName.UI, SystemName.RENDER];
this.systems.forEach(system => {
// Skip game systems if paused/start menu (but allow MenuSystem, UISystem, and RenderSystem)
if (isPaused && !unskippedSystems.includes(system.name)) {
return;
}
system.update(this.deltaTime, this.entities);
});
// Update input system's previous states at end of frame
const inputSystem = this.systems.find(s => s.name === SystemName.INPUT);
if (inputSystem && inputSystem.updatePreviousStates) {
inputSystem.updatePreviousStates();
}
// Continue loop
requestAnimationFrame(this.gameLoop);
}
/**
* Clear the canvas
*/
clear() {
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
}
}

171
src/core/Engine.ts Normal file
View file

@ -0,0 +1,171 @@
import { System } from './System.ts';
import { Entity } from './Entity.ts';
import { EventBus } from './EventBus.ts';
import { LevelLoader } from './LevelLoader.ts';
import { GameState, SystemName } from './Constants.ts';
import type { TileMap } from './TileMap.ts';
import type { MenuSystem } from '../systems/MenuSystem.ts';
import type { InputSystem } from '../systems/InputSystem.ts';
/**
* Main game engine responsible for managing the ECS lifecycle, game loop, and system execution.
*/
export class Engine {
canvas: HTMLCanvasElement;
ctx: CanvasRenderingContext2D;
entities: Entity[];
systems: System[];
events: EventBus;
running: boolean;
lastTime: number;
deltaTime: number;
tileMap: TileMap | null;
/**
* @param canvas - The canvas element to render to
*/
constructor(canvas: HTMLCanvasElement) {
this.canvas = canvas;
const ctx = canvas.getContext('2d');
if (!ctx) {
throw new Error('Failed to get 2d rendering context from canvas');
}
this.ctx = ctx;
this.entities = [];
this.systems = [];
this.events = new EventBus();
this.running = false;
this.lastTime = 0;
this.canvas.width = 320;
this.canvas.height = 240;
this.canvas.style.imageRendering = 'pixelated';
this.ctx.imageSmoothingEnabled = false;
this.deltaTime = 0;
this.tileMap = LevelLoader.loadSimpleLevel(20, 15, 16);
}
/**
* Add a system and sort systems by priority.
* @param system - The system to add
* @returns This engine instance
*/
addSystem(system: System): Engine {
if (system instanceof System) {
system.init(this);
this.systems.push(system);
this.systems.sort((a, b) => a.priority - b.priority);
}
return this;
}
/**
* Emit a global event.
* @param event - Unique event identifier
* @param data - Optional event payload
*/
emit(event: string, data?: unknown): void {
this.events.emit(event, data);
}
/**
* Subscribe to a global event.
* @param event - Unique event identifier
* @param callback - Function to execute when event is emitted
* @returns Unsubscribe function
*/
on(event: string, callback: (data?: unknown) => void): () => void {
return this.events.on(event, callback);
}
/**
* Create a new entity and track it.
* @returns The newly created entity
*/
createEntity(): Entity {
const entity = new Entity();
this.entities.push(entity);
return entity;
}
/**
* Permanently remove an entity from the engine.
* @param entity - The entity to remove
*/
removeEntity(entity: Entity): void {
const index = this.entities.indexOf(entity);
if (index > -1) {
this.entities.splice(index, 1);
}
}
/**
* Retrieve all currently active entities.
* @returns List of active entities
*/
getEntities(): Entity[] {
return this.entities.filter((e) => e.active);
}
/**
* Start the game loop.
*/
start(): void {
if (this.running) return;
this.running = true;
this.lastTime = performance.now();
this.gameLoop();
}
/**
* Terminate the game loop.
*/
stop(): void {
this.running = false;
}
/**
* Core game loop executing system updates.
* @param currentTime - High-resolution timestamp
*/
gameLoop = (currentTime = 0): void => {
if (!this.running) return;
this.deltaTime = (currentTime - this.lastTime) / 1000;
this.lastTime = currentTime;
this.deltaTime = Math.min(this.deltaTime, 0.1);
const menuSystem = this.systems.find((s) => s.name === SystemName.MENU) as
| MenuSystem
| undefined;
const gameState = menuSystem ? menuSystem.getGameState() : GameState.PLAYING;
const isPaused = [GameState.PAUSED, GameState.START, GameState.GAME_OVER].includes(gameState);
const unskippedSystems = [SystemName.MENU, SystemName.UI, SystemName.RENDER];
this.systems.forEach((system) => {
if (isPaused && !unskippedSystems.includes(system.name as SystemName)) {
return;
}
system.update(this.deltaTime, this.entities);
});
const inputSystem = this.systems.find((s) => s.name === SystemName.INPUT) as
| InputSystem
| undefined;
if (inputSystem && inputSystem.updatePreviousStates) {
inputSystem.updatePreviousStates();
}
requestAnimationFrame(this.gameLoop);
};
/**
* Clear the rendering surface.
*/
clear(): void {
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
}
}

View file

@ -1,59 +0,0 @@
/**
* Entity class - represents a game object with a unique ID
* Entities are just containers for components
*/
export class Entity {
static nextId = 0;
constructor() {
this.id = Entity.nextId++;
this.components = new Map();
this.active = true;
}
/**
* Add a component to this entity
*/
addComponent(component) {
this.components.set(component.type, component);
return this;
}
/**
* Get a component by type
*/
getComponent(type) {
return this.components.get(type);
}
/**
* Check if entity has a component
*/
hasComponent(type) {
return this.components.has(type);
}
/**
* Check if entity has all specified components
*/
hasComponents(...types) {
return types.every(type => this.components.has(type));
}
/**
* Remove a component
*/
removeComponent(type) {
this.components.delete(type);
return this;
}
/**
* Get all components
*/
getAllComponents() {
return Array.from(this.components.values());
}
}

83
src/core/Entity.ts Normal file
View file

@ -0,0 +1,83 @@
import { Component } from './Component.ts';
/**
* Entity class - represents a game object with a unique ID.
* Entities are containers for components.
*/
export class Entity {
private static nextId = 0;
readonly id: number;
private components: Map<string, Component>;
active: boolean;
// Optional dynamic properties for specific entity types
owner?: number;
startX?: number;
startY?: number;
maxRange?: number;
lifetime?: number;
damage?: number;
deathTime?: number;
constructor() {
this.id = Entity.nextId++;
this.components = new Map();
this.active = true;
}
/**
* Add a component to this entity.
* @param component - The component to add
* @returns This entity for chaining
*/
addComponent(component: Component): Entity {
this.components.set(component.type, component);
return this;
}
/**
* Get a component by type.
* @param type - The component type name
* @returns The component instance if found
*/
getComponent<T extends Component>(type: string): T | undefined {
return this.components.get(type) as T | undefined;
}
/**
* Check if entity has a component.
* @param type - The component type name
* @returns True if the component exists
*/
hasComponent(type: string): boolean {
return this.components.has(type);
}
/**
* Check if entity has all specified components.
* @param types - List of component type names
* @returns True if all components exist
*/
hasComponents(...types: string[]): boolean {
return types.every((type) => this.components.has(type));
}
/**
* Remove a component.
* @param type - The component type name
* @returns This entity for chaining
*/
removeComponent(type: string): Entity {
this.components.delete(type);
return this;
}
/**
* Get all components attached to this entity.
* @returns Array of components
*/
getAllComponents(): Component[] {
return Array.from(this.components.values());
}
}

View file

@ -1,57 +0,0 @@
/**
* Lightweight EventBus for pub/sub communication between systems
*/
export const Events = {
// Combat Events
DAMAGE_DEALT: 'combat:damage_dealt',
ENTITY_DIED: 'combat:entity_died',
// Evolution Events
EVOLVED: 'evolution:evolved',
MUTATION_GAINED: 'evolution:mutation_gained',
// Leveling Events
EXP_GAINED: 'stats:exp_gained',
LEVEL_UP: 'stats:level_up',
// Skill Events
SKILL_LEARNED: 'skills:learned',
SKILL_COOLDOWN_STARTED: 'skills:cooldown_started'
};
export class EventBus {
constructor() {
this.listeners = new Map();
}
/**
* Subscribe to an event
*/
on(event, callback) {
if (!this.listeners.has(event)) {
this.listeners.set(event, []);
}
this.listeners.get(event).push(callback);
return () => this.off(event, callback);
}
/**
* Unsubscribe from an event
*/
off(event, callback) {
if (!this.listeners.has(event)) return;
const callbacks = this.listeners.get(event);
const index = callbacks.indexOf(callback);
if (index > -1) {
callbacks.splice(index, 1);
}
}
/**
* Emit an event
*/
emit(event, data) {
if (!this.listeners.has(event)) return;
this.listeners.get(event).forEach(callback => callback(data));
}
}

101
src/core/EventBus.ts Normal file
View file

@ -0,0 +1,101 @@
/**
* Enum for game-wide event types.
*/
export enum Events {
DAMAGE_DEALT = 'combat:damage_dealt',
ENTITY_DIED = 'combat:entity_died',
EVOLVED = 'evolution:evolved',
MUTATION_GAINED = 'evolution:mutation_gained',
EXP_GAINED = 'stats:exp_gained',
LEVEL_UP = 'stats:level_up',
SKILL_LEARNED = 'skills:learned',
ATTACK_PERFORMED = 'combat:attack_performed',
SKILL_COOLDOWN_STARTED = 'skills:cooldown_started',
}
/**
* Event data types
*/
export interface DamageDealtEvent {
x: number;
y: number;
value: number;
color: string;
}
export interface MutationGainedEvent {
name: string;
description?: string;
}
export interface SkillLearnedEvent {
id: string;
}
export interface EntityDiedEvent {
entity: unknown;
}
export interface AttackPerformedEvent {
entity: unknown;
angle: number;
}
type EventCallback = (data?: unknown) => void;
/**
* Lightweight EventBus for pub/sub communication between systems.
*/
export class EventBus {
private listeners: Map<string, EventCallback[]>;
constructor() {
this.listeners = new Map();
}
/**
* Subscribe to an event with a callback.
* @param event - The event name from the Events enum
* @param callback - The function to call when the event is emitted
* @returns An unsubscribe function
*/
on(event: string, callback: EventCallback): () => void {
if (!this.listeners.has(event)) {
this.listeners.set(event, []);
}
const callbacks = this.listeners.get(event);
if (callbacks) {
callbacks.push(callback);
}
return () => this.off(event, callback);
}
/**
* Unsubscribe a specific callback from an event.
* @param event - The event name
* @param callback - The original callback function to remove
*/
off(event: string, callback: EventCallback): void {
if (!this.listeners.has(event)) return;
const callbacks = this.listeners.get(event);
if (callbacks) {
const index = callbacks.indexOf(callback);
if (index > -1) {
callbacks.splice(index, 1);
}
}
}
/**
* Emit an event to all subscribers.
* @param event - The event name
* @param data - Data to pass to the callbacks
*/
emit(event: string, data?: unknown): void {
if (!this.listeners.has(event)) return;
const callbacks = this.listeners.get(event);
if (callbacks) {
callbacks.forEach((callback) => callback(data));
}
}
}

View file

@ -1,22 +0,0 @@
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;
}
}

30
src/core/LevelLoader.ts Normal file
View file

@ -0,0 +1,30 @@
import { TileMap } from './TileMap.ts';
/**
* Utility class responsible for generating or loading level tile maps.
*/
export class LevelLoader {
/**
* Generates a simple arena level with walls at the boundaries and random obstacles.
* @param cols - Map width in tiles
* @param rows - Map height in tiles
* @param tileSize - Tile size in pixels
* @returns The generated tile map
*/
static loadSimpleLevel(cols: number, rows: number, tileSize: number): TileMap {
const map = new TileMap(cols, rows, tileSize);
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);
} else {
if (Math.random() < 0.1) {
map.setTile(c, r, 1);
}
}
}
}
return map;
}
}

View file

@ -1,27 +0,0 @@
/**
* 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
];
}
};

35
src/core/Palette.ts Normal file
View file

@ -0,0 +1,35 @@
/**
* Limited color palette used throughout the game to ensure a consistent aesthetic.
*/
export const Palette = {
/** Highlights and UI Text */
WHITE: '#ffffff',
/** Energy and Slime core */
CYAN: '#0ce6f2',
/** Water and friendly elements */
SKY_BLUE: '#0098db',
/** Shadows and visual depth */
ROYAL_BLUE: '#1e579c',
/** Walls and obstacles */
DARK_BLUE: '#203562',
/** Background details */
DARKER_BLUE: '#252446',
/** Deep background and void */
VOID: '#201533',
/**
* Get all colors in the palette as an array.
* @returns Array of hex color strings
*/
getAll(): string[] {
return [
this.WHITE,
this.CYAN,
this.SKY_BLUE,
this.ROYAL_BLUE,
this.DARK_BLUE,
this.DARKER_BLUE,
this.VOID,
];
},
};

View file

@ -1,80 +0,0 @@
/**
* 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;
}
};

105
src/core/PixelFont.ts Normal file
View file

@ -0,0 +1,105 @@
/**
* Simple 5x7 Matrix Pixel Font data.
* Each character is represented by an array of 7 integers, where each integer is a 5-bit mask.
*/
const FONT_DATA: Record<string, number[]> = {
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],
};
/**
* Utility class for rendering text using a custom pixel font.
*/
export const PixelFont = {
/**
* Render a string of text to the canvas.
* @param ctx - The rendering context
* @param text - The text to draw
* @param x - Horizontal start position
* @param y - Vertical start position
* @param color - The color of the text
* @param scale - Pixel scale factor
*/
drawText(
ctx: CanvasRenderingContext2D,
text: string,
x: number,
y: number,
color = '#ffffff',
scale = 1
): void {
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;
});
ctx.restore();
},
/**
* Calculate the total width of a string of text when rendered.
* @param text - The text string
* @param scale - Pixel scale factor
* @returns Width in pixels
*/
getTextWidth(text: string, scale = 1): number {
return text.length * 6 * scale;
},
};

View file

@ -1,25 +1,41 @@
import { EntityType, AnimationState } from './Constants.js';
import { EntityType, AnimationState } from './Constants.ts';
/**
* A 2D grid of pixel values (0-3)
*/
export type SpriteFrame = number[][];
/**
* An array of frames forming an animation
*/
export type SpriteAnimation = SpriteFrame[];
/**
* A map of animation states to animations
*/
export type EntitySpriteData = Record<string, SpriteAnimation>;
/**
* Sprite Library defining pixel art grids as 2D arrays.
*
* Pixel Values:
* 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
export const SpriteLibrary: Record<string, EntitySpriteData> = {
[EntityType.SLIME]: {
[AnimationState.IDLE]: [
[
[0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 1, 1, 1, 1, 0, 0], // Top
[0, 0, 1, 1, 1, 1, 0, 0],
[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, 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],
[0, 1, 1, 1, 1, 1, 1, 0] // Flat-ish base
[0, 1, 1, 1, 1, 1, 1, 0],
],
[
[0, 0, 0, 0, 0, 0, 0, 0],
@ -29,8 +45,8 @@ export const SpriteLibrary = {
[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
]
[1, 1, 1, 1, 1, 1, 1, 1],
],
],
[AnimationState.WALK]: [
[
@ -41,7 +57,7 @@ export const SpriteLibrary = {
[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, 1, 1, 1, 1, 1, 1, 0],
],
[
[0, 0, 0, 0, 0, 0, 0, 0],
@ -51,12 +67,11 @@ export const SpriteLibrary = {
[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]
]
]
[1, 1, 1, 1, 1, 1, 1, 1],
],
],
},
// 8x8 Humanoid - Simple Walk Cycle
[EntityType.HUMANOID]: {
[AnimationState.IDLE]: [
[
@ -67,8 +82,8 @@ export const SpriteLibrary = {
[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]
]
[0, 0, 1, 0, 0, 1, 0, 0],
],
],
[AnimationState.WALK]: [
[
@ -79,7 +94,7 @@ export const SpriteLibrary = {
[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, 1, 1, 0, 0, 0, 0, 0],
],
[
[0, 0, 0, 1, 1, 0, 0, 0],
@ -89,12 +104,11 @@ export const SpriteLibrary = {
[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]
]
]
[0, 0, 0, 0, 0, 1, 1, 0],
],
],
},
// 8x8 Beast - Bounding Cycle
[EntityType.BEAST]: {
[AnimationState.IDLE]: [
[
@ -105,8 +119,8 @@ export const SpriteLibrary = {
[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]
]
[0, 1, 0, 0, 0, 0, 1, 0],
],
],
[AnimationState.WALK]: [
[
@ -117,7 +131,7 @@ export const SpriteLibrary = {
[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],
],
[
[0, 0, 0, 0, 0, 0, 0, 0],
@ -127,12 +141,11 @@ export const SpriteLibrary = {
[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]
]
]
[0, 0, 1, 0, 0, 1, 0, 0],
],
],
},
// 8x8 Elemental - Floating Pulse
[EntityType.ELEMENTAL]: {
[AnimationState.IDLE]: [
[
@ -143,7 +156,7 @@ export const SpriteLibrary = {
[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, 1, 1, 0, 0, 0],
],
[
[0, 0, 0, 0, 0, 0, 0, 0],
@ -153,17 +166,17 @@ export const SpriteLibrary = {
[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, 1, 1, 0, 0, 0],
],
],
},
[EntityType.PROJECTILE]: {
[AnimationState.IDLE]: [
[
[1, 1],
[1, 1]
]
]
}
[1, 1],
],
],
},
};

View file

@ -1,46 +0,0 @@
/**
* Base System class for ECS architecture
* Systems contain logic that operates on entities with specific components
*/
export class System {
constructor(name) {
this.name = name;
this.requiredComponents = [];
this.priority = 0; // Lower priority runs first
}
/**
* Check if an entity matches this system's requirements
*/
matches(entity) {
if (!entity.active) return false;
return this.requiredComponents.every(componentType =>
entity.hasComponent(componentType)
);
}
/**
* Update method - override in subclasses
*/
update(deltaTime, entities) {
// Filter entities that match this system's requirements
const matchingEntities = entities.filter(entity => this.matches(entity));
this.process(deltaTime, matchingEntities);
}
/**
* Process matching entities - override in subclasses
*/
process(_deltaTime, _entities) {
// Override in subclasses
}
/**
* Called when system is added to engine
*/
init(engine) {
this.engine = engine;
}
}

64
src/core/System.ts Normal file
View file

@ -0,0 +1,64 @@
import { Entity } from './Entity.ts';
import type { Engine } from './Engine.ts';
/**
* Base System class for ECS architecture.
* Systems contain logic that operates on entities with specific components.
*/
export class System {
/** Unique identifier for the system */
readonly name: string;
/** List of component types required by this system */
requiredComponents: string[];
/** Execution priority (lower runs first) */
priority: number;
/** Reference to the game engine */
protected engine!: Engine;
/**
* @param name - The unique name of the system
*/
constructor(name: string) {
this.name = name;
this.requiredComponents = [];
this.priority = 0;
}
/**
* Check if an entity matches this system's requirements.
* @param entity - The entity to check
* @returns True if the entity is active and has all required components
*/
matches(entity: Entity): boolean {
if (!entity.active) return false;
return this.requiredComponents.every((componentType) => entity.hasComponent(componentType));
}
/**
* Main update entry point called every frame.
* @param deltaTime - Time elapsed since last frame in seconds
* @param entities - All entities in the engine
*/
update(deltaTime: number, entities: Entity[]): void {
const matchingEntities = entities.filter((entity) => this.matches(entity));
this.process(deltaTime, matchingEntities);
}
/**
* Process matching entities. To be implemented by subclasses.
* @param _deltaTime - Time elapsed since last frame in seconds
* @param _entities - Filtered entities that match this system
*/
process(_deltaTime: number, _entities: Entity[]): void {}
/**
* Called when system is added to engine.
* @param engine - The game engine instance
*/
init(engine: Engine): void {
this.engine = engine;
}
}

View file

@ -1,34 +0,0 @@
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;
}
}

68
src/core/TileMap.ts Normal file
View file

@ -0,0 +1,68 @@
/**
* Class representing a grid-based tile map for the game level.
*/
export class TileMap {
cols: number;
rows: number;
tileSize: number;
tiles: number[];
/**
* @param cols - Number of columns
* @param rows - Number of rows
* @param tileSize - Size of each tile in pixels
*/
constructor(cols: number, rows: number, tileSize: number) {
this.cols = cols;
this.rows = rows;
this.tileSize = tileSize;
this.tiles = new Array(cols * rows).fill(0);
}
/**
* Set the value of a specific tile.
* @param col - Tile column
* @param row - Tile row
* @param value - Tile type value
*/
setTile(col: number, row: number, value: number): void {
if (this.isValid(col, row)) {
this.tiles[row * this.cols + col] = value;
}
}
/**
* Get the type value of a tile. Treats out-of-bounds as solid.
* @param col - Tile column
* @param row - Tile row
* @returns The tile type value
*/
getTile(col: number, row: number): number {
if (this.isValid(col, row)) {
return this.tiles[row * this.cols + col];
}
return 1;
}
/**
* Check if a tile coordinate is within the map boundaries.
* @param col - Tile column
* @param row - Tile row
* @returns True if coordinates are valid
*/
isValid(col: number, row: number): boolean {
return col >= 0 && col < this.cols && row >= 0 && row < this.rows;
}
/**
* Check if a world position (x, y) collides with a solid tile.
* @param x - World X coordinate
* @param y - World Y coordinate
* @returns True if the position is solid
*/
isSolid(x: number, y: number): boolean {
const col = Math.floor(x / this.tileSize);
const row = Math.floor(y / this.tileSize);
return this.getTile(col, row) !== 0;
}
}