UNPKG

shellquest

Version:

Terminal-based procedurally generated dungeon crawler

507 lines (424 loc) 15.1 kB
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; } }