shellquest
Version:
Terminal-based procedurally generated dungeon crawler
507 lines (424 loc) • 15.1 kB
text/typescript
import {Level} from './level/overworld/level.ts';
import {DungeonLevel} from './level/dungeon/DungeonLevel.ts';
import {Camera} from './Camera.ts';
import {TILE_SIZE, TileMap} from './TileMap.ts';
import {LayeredRenderer, type RenderTile} from './drawing/layeredRenderer.ts';
import {CollisionSystem} from './collision/CollisionSystem.ts';
import {MAP_HEIGHT, MAP_WIDTH} from './constants.ts';
import {renderer} from '../../index.tsx';
import {Player} from './entities/Player.ts';
import {Enemy} from './entities/Enemy.ts';
import {PixelCanvas} from './drawing/pixelCanvas.ts';
import {DungeonLighting} from './lighting/DungeonLighting.ts';
import {applyLighting} from './lighting/LightingRenderer.ts';
import type {KeyEvent} from '@opentui/core';
// Level type for switching between different level generators
export type LevelType = 'overworld' | 'dungeon';
export class ZombieAttackGame {
level: Level | DungeonLevel;
camera: Camera;
tileMap: TileMap;
layeredRenderer!: LayeredRenderer;
collisionSystem: CollisionSystem;
levelType: LevelType;
// Game loop
private gameLoopInterval: NodeJS.Timeout | number | null = null;
private readonly TICK_RATE = 60; // ticks per second
private readonly TICK_INTERVAL = 1000 / this.TICK_RATE; // ms between ticks
private lastTickTime = 0;
private uiUpdateCounter = 0;
private readonly UI_UPDATE_FREQUENCY = 6; // Update UI every 6 ticks (10 times per second)
mainCanvas: PixelCanvas;
// Lighting system
private dungeonLighting: DungeonLighting | null = null;
private lightingEnabled: boolean = true;
constructor(
public width: number,
public height: number,
levelType: LevelType = 'dungeon',
) {
this.mainCanvas = new PixelCanvas(this.width, this.height);
this.levelType = levelType;
// Create the appropriate level type
if (levelType === 'dungeon') {
this.level = new DungeonLevel(MAP_WIDTH, MAP_HEIGHT);
} else {
this.level = new Level(MAP_WIDTH, MAP_HEIGHT);
}
this.camera = new Camera(this);
this.collisionSystem = new CollisionSystem(TILE_SIZE * 2);
// Initialize tile map
this.tileMap = new TileMap();
this.initializeSync();
renderer.on('resize', () => {
this.camera.update(this.level.player.x, this.level.player.y);
this.render();
});
// Start the game loop
this.startGameLoop();
}
private initializeSync(): void {
const player = new Player(this);
this.level.addEntity(player);
this.level.setupTileDefinitions(this.tileMap);
this.collisionSystem.addEntity(player);
this.setupLayeredRenderer();
this.setupInput();
// Generate procedural level
this.level.generateMap(Math.random().toString());
// Set player spawn position based on level type
if (this.level instanceof DungeonLevel) {
const spawn = this.level.getSpawnPoint();
player.x = spawn.x * TILE_SIZE + TILE_SIZE / 2;
player.y = spawn.y * TILE_SIZE + TILE_SIZE / 2;
// Initialize lighting system for dungeon
this.initializeLighting(player);
}
// Spawn some test enemies near the player
this.spawnTestEnemies();
// Add all entities to collision system
for (const entity of this.level.getEntities()) {
if (entity !== player) {
this.collisionSystem.addEntity(entity);
}
}
// Center camera on player
this.camera.update(this.level.player.x, this.level.player.y);
this.render();
}
private setupLayeredRenderer(): void {
// 5 layers: 0=ground, 1=shadows (under entities), 2=entities, 3=top tiles, 4=UI
this.layeredRenderer = new LayeredRenderer(this.mainCanvas, this.tileMap, this.camera, 5, 0, 0);
}
private initializeLighting(player: Player): void {
if (!(this.level instanceof DungeonLevel)) return;
// Create the lighting system
this.dungeonLighting = new DungeonLighting({});
// Get the dungeon grid for torch placement
const grid = this.level.getGrid();
if (grid) {
// Place wall torches throughout the dungeon
const seed = this.hashString('default_torches');
const torches = this.dungeonLighting.placeTorches(grid, MAP_WIDTH, MAP_HEIGHT, seed, {
minSpacing: 8,
frequency: 0.15,
maxTorches: 60,
preferCorridors: true,
});
for (const torch of torches) {
const tile = this.level.getTile(torch.tileX, torch.tileY);
if (tile) {
tile.topTile2 = 'dungeon-lantern';
}
}
}
// Initialize player's torch
this.dungeonLighting.initializePlayerTorch(player.x + TILE_SIZE / 2, player.y + TILE_SIZE / 2);
console.log(`Lighting initialized: ${this.dungeonLighting.getStats().torches} torches placed`);
}
private hashString(str: string): number {
let hash = 0;
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i);
hash = (hash << 5) - hash + char;
hash = hash & hash;
}
return Math.abs(hash);
}
private setupInput(): void {
renderer.keyInput.on('keydown', (key: KeyEvent) => {
switch (key.name) {
case 'o':
this.dungeonLighting?.changePlayerTorch((light) => {
light.intensity += 0.1;
if (light.intensity > 3) {
light.intensity = 0;
}
});
break;
case 'space':
this.level.player.attack();
if (this.level.player.weaponType === 'wand') {
this.handleSpellCast();
} else {
this.handlePlayerAttack();
}
break;
case 'e':
if (this.level.player.weaponType === 'wand') {
this.level.player.cycleSpell();
}
break;
case '1':
this.level.player.equipWeapon('sword');
break;
case '2':
this.level.player.equipWeapon('wand');
break;
case 'l':
// Toggle lighting with 'L' key
this.lightingEnabled = !this.lightingEnabled;
console.log(`Lighting ${this.lightingEnabled ? 'enabled' : 'disabled'}`);
break;
}
});
renderer.keyInput.on('keydown', (key: KeyEvent) => {
let dx = 0;
let dy = 0;
switch (key.name) {
case 'w':
case 'up':
dy = -1;
break;
case 's':
case 'down':
dy = 1;
break;
case 'a':
case 'left':
dx = -1;
break;
case 'd':
case 'right':
dx = 1;
break;
}
if (key.shift) {
this.level.player.setSprinting(true);
}
if (dx !== 0 || dy !== 0) {
const currentDir = this.level.player.getMoveDirection();
if (dx !== 0) currentDir.x = dx;
if (dy !== 0) currentDir.y = dy;
this.level.player.setMoveDirection(currentDir.x, currentDir.y);
}
});
renderer.keyInput.on('keyup', (key: KeyEvent) => {
const currentDir = this.level.player.getMoveDirection();
switch (key.name) {
case 'w':
case 'up':
if (currentDir.y === -1) {
currentDir.y = 0;
}
break;
case 's':
case 'down':
if (currentDir.y === 1) {
currentDir.y = 0;
}
break;
case 'a':
case 'left':
if (currentDir.x === -1) {
currentDir.x = 0;
}
break;
case 'd':
case 'right':
if (currentDir.x === 1) {
currentDir.x = 0;
}
break;
}
if (!key.shift) {
this.level.player.setSprinting(false);
}
this.level.player.setMoveDirection(currentDir.x, currentDir.y);
});
}
private startGameLoop(): void {
this.lastTickTime = Date.now();
this.gameLoopInterval = setInterval(() => {
const currentTime = Date.now();
const deltaTime = currentTime - this.lastTickTime;
this.lastTickTime = currentTime;
this.tick(deltaTime);
}, this.TICK_INTERVAL);
}
private tick(deltaTime: number): void {
this.update(deltaTime);
this.uiUpdateCounter++;
if (this.uiUpdateCounter >= this.UI_UPDATE_FREQUENCY) {
this.uiUpdateCounter = 0;
}
}
private update(deltaTime: number): void {
// Update all entities
for (const entity of this.level.getEntities()) {
entity.update(deltaTime, this.level, this.collisionSystem);
this.collisionSystem.updateEntity(entity);
}
// Update camera
this.camera.update(this.level.player.x, this.level.player.y);
// Update exploration for minimap (only for dungeon levels)
if (this.level instanceof DungeonLevel) {
this.level.exploreAroundPosition(this.level.player.x, this.level.player.y, 16);
}
// Update lighting system (handles flicker animation)
if (this.dungeonLighting && this.lightingEnabled) {
this.dungeonLighting.update(deltaTime);
// Update player torch position to follow player
this.dungeonLighting.updatePlayerTorch(
this.level.player.x + TILE_SIZE / 2,
this.level.player.y + TILE_SIZE / 2,
);
}
}
render(): void {
this.layeredRenderer.clear();
// Layer 0: Ground/ceiling tiles
this.layeredRenderer.renderTilesGridToLayer(
0,
this.level.getBottomLayerTiles(),
this.camera.x,
this.camera.y,
);
// Layer 1: Shadows (rendered UNDER entities)
if (this.level instanceof DungeonLevel) {
this.layeredRenderer.renderTilesToLayer(
1,
this.level.getShadowTiles(),
this.camera.x,
this.camera.y,
);
}
// Layer 2: Y-sorted sprite layer (entities + wall faces, tracks, decals)
// This allows entities to appear in front of or behind walls based on Y position
const entities = this.level.getEntities();
if (this.level instanceof DungeonLevel) {
// Use Y-sorted rendering for proper depth ordering
const ySortableTiles = this.level.getYSortableTiles();
this.layeredRenderer.renderYSorted(2, entities, ySortableTiles, this.camera.x, this.camera.y);
// Layer 3: Overlay tiles (wall crowns that always render on top)
this.layeredRenderer.renderTilesToLayer(
3,
this.level.getOverlayTiles(),
this.camera.x,
this.camera.y,
);
} else {
// Fallback for non-dungeon levels: use old rendering approach
for (const entity of entities) {
entity.layer = 2;
}
this.layeredRenderer.renderEntities(entities, this.camera.x, this.camera.y);
this.layeredRenderer.renderTilesToLayer(
3,
this.level.getTopLayerTiles(),
this.camera.x,
this.camera.y,
);
}
// Apply lighting after all layers are rendered
this.applyLightingEffect();
}
private applyLightingEffect(): void {
if (!this.dungeonLighting || !this.lightingEnabled) return;
// Compute light map for current viewport
const lightMap = this.dungeonLighting.computeLightMap(
this.mainCanvas.width,
this.mainCanvas.height,
this.camera.x,
this.camera.y,
);
// Apply lighting to the main canvas with banding/dithering
applyLighting(this.mainCanvas, lightMap, {warmTint: true});
}
/**
* Get post-processing callback for lighting (used by zombieCanvas)
*/
getLightingPostProcess(): ((canvas: PixelCanvas) => void) | null {
if (!this.dungeonLighting || !this.lightingEnabled) return null;
return (canvas: PixelCanvas) => {
const lightMap = this.dungeonLighting!.computeLightMap(
canvas.width,
canvas.height,
this.camera.x,
this.camera.y,
);
applyLighting(canvas, lightMap, {warmTint: true});
};
}
private handlePlayerAttack(): void {
const player = this.level.player;
const attackRange = TILE_SIZE * 1.5;
const attackDamage = 10;
for (const entity of this.level.getEntities()) {
if (entity instanceof Enemy) {
const dx = entity.x - player.x;
const dy = entity.y - player.y;
const distance = Math.sqrt(dx * dx + dy * dy);
if (distance <= attackRange) {
const inFrontOfPlayer = player.facingLeft ? dx < 0 : dx > 0;
if (inFrontOfPlayer || Math.abs(dx) < TILE_SIZE / 2) {
entity.takeDamage(attackDamage);
}
}
}
}
}
private handleSpellCast(): void {
const player = this.level.player;
const projectile = player.castSpell();
if (projectile) {
this.level.addEntity(projectile);
this.collisionSystem.addEntity(projectile);
}
}
private spawnTestEnemies(): void {
const player = this.level.player;
const numEnemies = 10;
const radius = TILE_SIZE * 10;
for (let i = 0; i < numEnemies; i++) {
const angle = (i / numEnemies) * Math.PI * 2;
const enemy = new Enemy(this, 'zombie');
const targetX = player.x + Math.cos(angle) * radius;
const targetY = player.y + Math.sin(angle) * radius;
let validPosition = false;
let attempts = 0;
const maxAttempts = 20;
while (!validPosition && attempts < maxAttempts) {
const testX = targetX + (Math.random() - 0.5) * TILE_SIZE * 4;
const testY = targetY + (Math.random() - 0.5) * TILE_SIZE * 4;
const tileX = Math.floor(testX / TILE_SIZE);
const tileY = Math.floor(testY / TILE_SIZE);
if (!this.level.isSolid(tileX, tileY)) {
enemy.x = testX;
enemy.y = testY;
validPosition = true;
}
attempts++;
}
if (validPosition) {
this.level.addEntity(enemy);
}
}
}
destroy(): void {
if (this.gameLoopInterval) {
clearInterval(this.gameLoopInterval as NodeJS.Timeout);
this.gameLoopInterval = null;
}
}
resize(width: number, height: number): void {
this.width = width;
this.height = height;
this.mainCanvas.resize(width, height);
this.layeredRenderer.resize(width, height);
}
// ===========================================================================
// LIGHTING CONTROLS
// ===========================================================================
setLightingEnabled(enabled: boolean): void {
this.lightingEnabled = enabled;
}
setAmbientLight(level: number): void {
if (this.dungeonLighting) {
this.dungeonLighting.setAmbientLight(level);
}
}
getDungeonLighting(): DungeonLighting | null {
return this.dungeonLighting;
}
}