UNPKG

shellquest

Version:

Terminal-based procedurally generated dungeon crawler

331 lines (285 loc) 13.1 kB
import type {PRNG} from 'seedrandom'; import {createNoise2D} from 'simplex-noise'; import type {LevelTile} from '../types.ts'; import {getTileName} from '../types.ts'; export class TreeGenerator { constructor( private tiles: LevelTile[][], private width: number, private height: number, ) {} generateTrees(rng: PRNG): void { // First, place tileable tree areas (forests) this.generateTileableForests(rng); // Then add tree clusters this.generateTreeClusters(rng); // Finally, scatter individual trees this.generateIndividualTrees(rng); // Add some bushes, vines, and mushrooms for variety this.generateSmallVegetation(rng); } private generateTileableForests(rng: PRNG): void { const forestNoise = createNoise2D(rng); // Generate forest regions using noise // The tileable trees work where each 2x2 block can overlap with adjacent blocks // When tiling horizontally: top-left and top-right occupy same position on different layers // When tiling vertically: top-left and bottom-left occupy same position on different layers for (let y = 0; y < this.height - 6; y += 2) { for (let x = 0; x < this.width - 6; x += 2) { const noiseValue = forestNoise(x * 0.02, y * 0.02); // Create dense forest areas if (noiseValue > 0.75) { // Choose tree tile type for this forest area const treeType = rng() < 0.5 ? 'tree-tile-1' : 'tree-tile-2'; // Determine forest size based on noise const forestWidth = Math.floor(rng() * 4) + 3; // 3-6 tiles wide (in 2x2 blocks) const forestHeight = Math.floor(rng() * 4) + 3; // 3-6 tiles tall (in 2x2 blocks) // Place the tileable forest pattern with proper overlapping for (let fy = 0; fy < forestHeight; fy++) { for (let fx = 0; fx < forestWidth; fx++) { // Each "tile unit" is 2x2, but when tiling they overlap by 1 // So we increment by 1 instead of 2 for seamless tiling const tileX = x + fx; const tileY = y + fy; if (tileX + 1 >= this.width || tileY + 1 >= this.height) continue; // Check if this position can accept trees let canPlace = true; const tile = this.tiles[tileY][tileX]; if (tile.solid && !tile.topTile && !tile.topTile2) { canPlace = false; } if (canPlace) { // Place the 2x2 pattern with overlapping for seamless tiling // The pattern tiles by having edges share the same position on different layers // Top-left corner if (!this.tiles[tileY][tileX].topTile) { this.tiles[tileY][tileX].topTile = `${treeType}-top-left`; this.tiles[tileY][tileX].solid = true; } else if (!this.tiles[tileY][tileX].topTile2) { // If topTile is occupied, use topTile2 for overlapping this.tiles[tileY][tileX].topTile2 = `${treeType}-top-left`; } // Top-right corner (can overlap with next tile's top-left) if (tileX + 1 < this.width) { if (!this.tiles[tileY][tileX + 1].topTile) { this.tiles[tileY][tileX + 1].topTile = `${treeType}-top-right`; this.tiles[tileY][tileX + 1].solid = true; } else if (!this.tiles[tileY][tileX + 1].topTile2) { this.tiles[tileY][tileX + 1].topTile2 = `${treeType}-top-right`; } } // Bottom-left corner (can overlap with next tile's top-left) if (tileY + 1 < this.height) { if (!this.tiles[tileY + 1][tileX].topTile) { this.tiles[tileY + 1][tileX].topTile = `${treeType}-bottom-left`; this.tiles[tileY + 1][tileX].solid = true; } else if (!this.tiles[tileY + 1][tileX].topTile2) { this.tiles[tileY + 1][tileX].topTile2 = `${treeType}-bottom-left`; } } // Bottom-right corner (can overlap with adjacent tiles) if (tileX + 1 < this.width && tileY + 1 < this.height) { if (!this.tiles[tileY + 1][tileX + 1].topTile) { this.tiles[tileY + 1][tileX + 1].topTile = `${treeType}-bottom-right`; this.tiles[tileY + 1][tileX + 1].solid = true; } else if (!this.tiles[tileY + 1][tileX + 1].topTile2) { this.tiles[tileY + 1][tileX + 1].topTile2 = `${treeType}-bottom-right`; } } } } } } } } // Add some additional smaller forest patches with proper tiling for (let i = 0; i < 10; i++) { const startX = Math.floor(rng() * (this.width - 8)); const startY = Math.floor(rng() * (this.height - 8)); const treeType = rng() < 0.5 ? 'tree-tile-1' : 'tree-tile-2'; const patchWidth = Math.floor(rng() * 3) + 2; // 2-4 blocks const patchHeight = Math.floor(rng() * 3) + 2; // 2-4 blocks for (let py = 0; py < patchHeight; py++) { for (let px = 0; px < patchWidth; px++) { const tileX = startX + px; const tileY = startY + py; if (tileX + 1 >= this.width || tileY + 1 >= this.height) continue; // Place with proper layer management for overlapping if (!this.tiles[tileY][tileX].topTile) { this.tiles[tileY][tileX].topTile = `${treeType}-top-left`; this.tiles[tileY][tileX].solid = true; } else if (!this.tiles[tileY][tileX].topTile2) { this.tiles[tileY][tileX].topTile2 = `${treeType}-top-left`; } if (!this.tiles[tileY][tileX + 1].topTile) { this.tiles[tileY][tileX + 1].topTile = `${treeType}-top-right`; this.tiles[tileY][tileX + 1].solid = true; } else if (!this.tiles[tileY][tileX + 1].topTile2) { this.tiles[tileY][tileX + 1].topTile2 = `${treeType}-top-right`; } if (!this.tiles[tileY + 1][tileX].topTile) { this.tiles[tileY + 1][tileX].topTile = `${treeType}-bottom-left`; this.tiles[tileY + 1][tileX].solid = true; } else if (!this.tiles[tileY + 1][tileX].topTile2) { this.tiles[tileY + 1][tileX].topTile2 = `${treeType}-bottom-left`; } if (!this.tiles[tileY + 1][tileX + 1].topTile) { this.tiles[tileY + 1][tileX + 1].topTile = `${treeType}-bottom-right`; this.tiles[tileY + 1][tileX + 1].solid = true; } else if (!this.tiles[tileY + 1][tileX + 1].topTile2) { this.tiles[tileY + 1][tileX + 1].topTile2 = `${treeType}-bottom-right`; } } } } } private generateTreeClusters(rng: PRNG): void { const clusterCount = Math.floor(rng() * 100) + 15; // 15-35 clusters for (let i = 0; i < clusterCount; i++) { // Random position with padding const x = Math.floor(rng() * (this.width - 5)) + 2; const y = Math.floor(rng() * (this.height - 5)) + 2; // Check if 3x3 area is clear (cluster needs space) let canPlace = true; for (let dy = -1; dy < 2; dy++) { for (let dx = -1; dx < 2; dx++) { const checkX = x + dx; const checkY = y + dy; if ( checkX < 0 || checkX >= this.width || checkY < 0 || checkY >= this.height || this.tiles[checkY][checkX].topTile || this.tiles[checkY][checkX].solid ) { canPlace = false; break; } } if (!canPlace) break; } if (canPlace) { // Choose cluster type const clusterType = rng() < 0.5 ? 'tree-cluster-1' : 'tree-cluster-2'; // Place the 5-sprite cluster pattern // Top if (y - 1 >= 0) { this.tiles[y - 1][x].topTile = `${clusterType}-top`; this.tiles[y - 1][x].solid = true; } // Middle row: left, center, right if (x - 1 >= 0) { this.tiles[y][x - 1].topTile = `${clusterType}-left`; this.tiles[y][x - 1].solid = true; } this.tiles[y][x].topTile = `${clusterType}-center`; this.tiles[y][x].solid = true; if (x + 1 < this.width) { this.tiles[y][x + 1].topTile = `${clusterType}-right`; this.tiles[y][x + 1].solid = true; } // Bottom if (y + 1 < this.height) { this.tiles[y + 1][x].topTile = `${clusterType}-bottom`; this.tiles[y + 1][x].solid = true; } } } } private generateIndividualTrees(rng: PRNG): void { const treeNoise = createNoise2D(rng); for (let y = 1; y < this.height - 1; y++) { for (let x = 0; x < this.width; x++) { const noiseValue = treeNoise(x * 0.1, y * 0.1); const moisture = treeNoise(x * 0.05 + 1000, y * 0.05 + 1000); // Place trees based on noise and terrain if (noiseValue > 0.5 && moisture > -0.2 && rng() < 0.55) { const tile = this.tiles[y][x]; // Only place on grass tiles without existing content const bottomTileName = getTileName(tile.bottomTile); if (bottomTileName.startsWith('grass') && !tile.topTile && !tile.solid) { const treeRoll = rng(); if (treeRoll < 0.3) { // Small tree (single tile) const treeType = rng() < 0.5 ? 'small-tree-1' : 'small-tree-2'; this.tiles[y][x].topTile = treeType; this.tiles[y][x].solid = true; } else if (treeRoll < 0.6) { // Tall tree (2 tiles high) if (y > 0 && !this.tiles[y - 1][x].topTile) { const treeType = rng() < 0.5 ? 'tall-tree-1' : 'tall-tree-2'; this.tiles[y - 1][x].topTile = `${treeType}-top`; this.tiles[y - 1][x].solid = treeType === 'tall-tree-2'; // tall-tree-2 top is solid this.tiles[y][x].topTile = `${treeType}-bottom`; this.tiles[y][x].solid = true; } } } } } } } private generateSmallVegetation(rng: PRNG): void { const vegetationNoise = createNoise2D(rng); for (let y = 0; y < this.height; y++) { for (let x = 0; x < this.width; x++) { const noiseValue = vegetationNoise(x * 0.15, y * 0.15); if (noiseValue > 0.6 && rng() < 0.27) { const tile = this.tiles[y][x]; // Only place on appropriate terrain if (!tile.topTile && !tile.solid) { const vegetationType = rng(); const bottomTileName = getTileName(tile.bottomTile); if (bottomTileName.startsWith('grass')) { if (vegetationType < 0.4) { // Bush this.tiles[y][x].topTile = 'bush-1'; this.tiles[y][x].solid = true; } else if (vegetationType < 0.7) { // Mushroom this.tiles[y][x].topTile = 'mushroom-1'; this.tiles[y][x].solid = true; } } else if (bottomTileName.startsWith('dirt')) { if (vegetationType < 0.3) { // Vine on dirt areas this.tiles[y][x].topTile = 'vine-1'; this.tiles[y][x].solid = true; } else if (vegetationType < 0.5) { // Mushroom this.tiles[y][x].topTile = 'mushroom-1'; this.tiles[y][x].solid = true; } } } } } } // Add some strategic bushes near paths for (let y = 0; y < this.height; y++) { for (let x = 0; x < this.width; x++) { if (this.tiles[y][x].bottomTile === 'grass-walking-path') { // Occasionally place bushes alongside paths if (rng() < 0.02) { for (let dx = -1; dx <= 1; dx += 2) { const checkX = x + dx; if ( checkX >= 0 && checkX < this.width && !this.tiles[y][checkX].topTile && !this.tiles[y][checkX].solid && getTileName(this.tiles[y][checkX].bottomTile).startsWith('grass') ) { if (rng() < 0.3) { this.tiles[y][checkX].topTile = 'bush-1'; this.tiles[y][checkX].solid = true; } } } } } } } } }