shellquest
Version:
Terminal-based procedurally generated dungeon crawler
331 lines (285 loc) • 13.1 kB
text/typescript
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;
}
}
}
}
}
}
}
}
}