Skip to main content
Glama
lakes.ts12.8 kB
/** * Lake Generation Module * * Identifies terrain depressions and fills them with lakes. * Uses a "pour point" algorithm - lakes fill up to the lowest point * on their rim where water can overflow. * * Algorithm: * 1. Find sinks (river endpoints that can't drain) * 2. For each sink, find the depression it sits in * 3. Calculate the pour point (lowest rim elevation) * 4. Fill all tiles below the pour point elevation * 5. Find spillway for outgoing river */ export interface LakeGenerationOptions { width: number; height: number; elevation: Uint8Array; rivers: Uint8Array; seaLevel?: number; minLakeSize?: number; maxLakeSize?: number; /** Minimum depth of depression to form a lake (pour point - sink) */ minDepth?: number; /** Maximum lake fill level above sink (caps very deep lakes) */ maxFillDepth?: number; /** Maximum elevation for lake formation (lakes don't form on high terrain) */ maxLakeElevation?: number; } export interface LakeSpillway { /** X coordinate of spillway (on lake edge) */ lakeX: number; /** Y coordinate of spillway (on lake edge) */ lakeY: number; /** X coordinate of outflow target (where river starts) */ outflowX: number; /** Y coordinate of outflow target (where river starts) */ outflowY: number; /** Elevation at the spillway point */ elevation: number; } export interface LakeResult { /** Map of lake tiles (1 = lake, 0 = not lake) */ lakeMap: Uint8Array; /** Number of lakes generated */ lakeCount: number; /** Spillway points where rivers can flow out of lakes */ spillways: LakeSpillway[]; } const toIndex = (x: number, y: number, width: number) => y * width + x; const fromIndex = (index: number, width: number) => ({ x: index % width, y: Math.floor(index / width) }); // 8-directional neighbors for more natural lake shapes const NEIGHBORS_8 = [ { dx: 0, dy: -1 }, // N { dx: 1, dy: -1 }, // NE { dx: 1, dy: 0 }, // E { dx: 1, dy: 1 }, // SE { dx: 0, dy: 1 }, // S { dx: -1, dy: 1 }, // SW { dx: -1, dy: 0 }, // W { dx: -1, dy: -1 }, // NW ]; // 4-directional for more controlled operations const NEIGHBORS_4 = [ { dx: 0, dy: -1 }, { dx: 1, dy: 0 }, { dx: 0, dy: 1 }, { dx: -1, dy: 0 }, ]; /** * Generate lakes by identifying and filling terrain depressions */ export function generateLakes(options: LakeGenerationOptions): LakeResult { const { width, height, elevation, rivers, seaLevel = 20, minLakeSize = 6, // Increased from 4 - require larger depressions maxLakeSize = 60, // Reduced from 100 - fewer massive lakes minDepth = 4, // Increased from 2 - require deeper depressions maxFillDepth = 12, // Reduced from 15 - shallower lake fills maxLakeElevation = 55, // Lakes only form below this elevation (0-100 scale) } = options; const size = width * height; const lakeMap = new Uint8Array(size); const spillways: LakeSpillway[] = []; const processed = new Uint8Array(size); // Track which tiles have been considered for lakes // Find depression seeds - river tiles that are local minima at reasonable elevations const depressionSeeds = findDepressionSeeds(elevation, rivers, seaLevel, maxLakeElevation, width, height); console.error(`Found ${depressionSeeds.length} potential depression seeds`); let lakeCount = 0; // Sort seeds by elevation (lowest first) to process deepest depressions first depressionSeeds.sort((a, b) => elevation[a] - elevation[b]); for (const seedIdx of depressionSeeds) { // Skip if this tile is already part of a lake if (processed[seedIdx]) continue; const seedElev = elevation[seedIdx]; // Find the depression boundary and pour point const depression = findDepressionBoundary( seedIdx, elevation, seaLevel, processed, width, height, maxLakeSize * 3 // Search limit (larger than max lake to find rim) ); if (!depression) continue; const { pourPoint, pourPointElev, basinTiles } = depression; // Calculate depression depth const depth = pourPointElev - seedElev; // Skip shallow depressions if (depth < minDepth) { // Mark as processed so we don't revisit for (const idx of basinTiles) { processed[idx] = 1; } continue; } // Calculate lake fill level (pour point elevation minus 1, capped by maxFillDepth) const maxLakeLevel = seedElev + maxFillDepth; const lakeLevel = Math.min(pourPointElev - 1, maxLakeLevel); // Fill the depression up to lake level const lakeTiles = fillToLevel( seedIdx, lakeLevel, elevation, seaLevel, processed, width, height, maxLakeSize ); // Check size constraints if (lakeTiles.length >= minLakeSize && lakeTiles.length <= maxLakeSize) { // Create the lake for (const idx of lakeTiles) { lakeMap[idx] = 1; processed[idx] = 1; } lakeCount++; // Add spillway at pour point if (pourPoint !== -1) { const spillway = createSpillway( pourPoint, lakeTiles, elevation, seaLevel, width, height ); if (spillway) { spillways.push(spillway); } } console.error(`Created lake #${lakeCount}: ${lakeTiles.length} tiles, depth=${depth}, level=${lakeLevel}`); } else { // Mark basin as processed even if no lake formed for (const idx of basinTiles) { processed[idx] = 1; } if (lakeTiles.length > maxLakeSize) { console.error(`Rejected lake: too large (${lakeTiles.length} tiles)`); } } } return { lakeMap, lakeCount, spillways }; } /** * Find depression seeds - river tiles that are local minima AND have significantly * lower elevation than their surroundings AND below max lake elevation */ function findDepressionSeeds( elevation: Uint8Array, rivers: Uint8Array, seaLevel: number, maxLakeElevation: number, width: number, height: number ): number[] { const seeds: number[] = []; for (let y = 1; y < height - 1; y++) { for (let x = 1; x < width - 1; x++) { const idx = toIndex(x, y, width); // Must be a river tile and above sea level if (rivers[idx] === 0) continue; if (elevation[idx] < seaLevel) continue; // Lakes don't form at high elevations (mountains, high hills) if (elevation[idx] > maxLakeElevation) continue; const elev = elevation[idx]; let isLocalMin = true; let neighborCount = 0; let higherNeighbors = 0; let nearOcean = false; // Check all 8 neighbors for (const { dx, dy } of NEIGHBORS_8) { const nx = x + dx; const ny = y + dy; if (nx < 0 || nx >= width || ny < 0 || ny >= height) continue; const nIdx = toIndex(nx, ny, width); neighborCount++; // Check if near ocean if (elevation[nIdx] < seaLevel) { nearOcean = true; break; } // Check if any neighbor is lower (not a local minimum) if (elevation[nIdx] < elev) { isLocalMin = false; } if (elevation[nIdx] > elev) { higherNeighbors++; } } // Skip tiles adjacent to ocean (they drain to sea) if (nearOcean) continue; // Must be a true local minimum (no lower neighbors) // AND surrounded by MOSTLY higher terrain (suggests a real depression) // Increased threshold from 0.5 to 0.75 for more pronounced depressions if (isLocalMin && higherNeighbors >= neighborCount * 0.75) { seeds.push(idx); } } } return seeds; } /** * Find the boundary of a depression and its pour point * Returns the pour point (lowest rim cell) and the elevation there */ function findDepressionBoundary( seedIdx: number, elevation: Uint8Array, seaLevel: number, processed: Uint8Array, width: number, height: number, searchLimit: number ): { pourPoint: number; pourPointElev: number; basinTiles: number[] } | null { const seedElev = elevation[seedIdx]; const basinTiles: number[] = []; const visited = new Set<number>(); // Use a priority queue approach - expand from lowest to highest // Find the rim (where elevation increases significantly) // Start BFS from seed, tracking elevation changes const queue: number[] = [seedIdx]; visited.add(seedIdx); let pourPoint = -1; let pourPointElev = Infinity; while (queue.length > 0 && basinTiles.length < searchLimit) { const idx = queue.shift()!; const elev = elevation[idx]; // Skip ocean tiles if (elev < seaLevel) continue; // Skip already processed (part of another lake) if (processed[idx]) continue; basinTiles.push(idx); const { x, y } = fromIndex(idx, width); for (const { dx, dy } of NEIGHBORS_4) { const nx = x + dx; const ny = y + dy; if (nx < 0 || nx >= width || ny < 0 || ny >= height) continue; const nIdx = toIndex(nx, ny, width); if (visited.has(nIdx)) continue; visited.add(nIdx); const nElev = elevation[nIdx]; // If neighbor is below sea level, this depression drains to ocean // Not a valid closed basin if (nElev < seaLevel) { return null; } // If neighbor is higher than seed elevation + a threshold, // it's part of the rim, not the basin // We're looking for the LOWEST point on the rim if (nElev > seedElev + 1) { // This is a rim candidate if (nElev < pourPointElev) { pourPointElev = nElev; pourPoint = nIdx; } } else { // Still in the basin, continue exploring queue.push(nIdx); } } } // Validate we found a proper rim if (pourPoint === -1 || pourPointElev <= seedElev) { return null; } return { pourPoint, pourPointElev, basinTiles }; } /** * Fill a depression up to a given elevation level */ function fillToLevel( seedIdx: number, lakeLevel: number, elevation: Uint8Array, seaLevel: number, processed: Uint8Array, width: number, height: number, maxSize: number ): number[] { const lakeTiles: number[] = []; const visited = new Set<number>(); const queue: number[] = [seedIdx]; visited.add(seedIdx); while (queue.length > 0 && lakeTiles.length < maxSize) { const idx = queue.shift()!; const elev = elevation[idx]; // Only include tiles at or below lake level (and above sea level) if (elev > lakeLevel || elev < seaLevel) continue; // Don't include tiles already part of another lake if (processed[idx]) continue; lakeTiles.push(idx); const { x, y } = fromIndex(idx, width); for (const { dx, dy } of NEIGHBORS_4) { const nx = x + dx; const ny = y + dy; if (nx < 0 || nx >= width || ny < 0 || ny >= height) continue; const nIdx = toIndex(nx, ny, width); if (visited.has(nIdx)) continue; visited.add(nIdx); queue.push(nIdx); } } return lakeTiles; } /** * Create a spillway at the pour point */ function createSpillway( pourPoint: number, lakeTiles: number[], elevation: Uint8Array, seaLevel: number, width: number, height: number ): LakeSpillway | null { const lakeSet = new Set(lakeTiles); const { x: pourX, y: pourY } = fromIndex(pourPoint, width); // Find the adjacent lake tile (the point on the lake shore) let lakeX = -1; let lakeY = -1; for (const { dx, dy } of NEIGHBORS_4) { const nx = pourX + dx; const ny = pourY + dy; if (nx < 0 || nx >= width || ny < 0 || ny >= height) continue; const nIdx = toIndex(nx, ny, width); if (lakeSet.has(nIdx)) { lakeX = nx; lakeY = ny; break; } } if (lakeX === -1) return null; // Find where the spillway flows to (lowest non-lake neighbor of pour point) let outflowX = -1; let outflowY = -1; let lowestElev = elevation[pourPoint]; for (const { dx, dy } of NEIGHBORS_8) { const nx = pourX + dx; const ny = pourY + dy; if (nx < 0 || nx >= width || ny < 0 || ny >= height) continue; const nIdx = toIndex(nx, ny, width); // Skip lake tiles if (lakeSet.has(nIdx)) continue; // Skip ocean (lake already drains there) if (elevation[nIdx] < seaLevel) continue; const nElev = elevation[nIdx]; if (nElev < lowestElev) { lowestElev = nElev; outflowX = nx; outflowY = ny; } } if (outflowX === -1) return null; return { lakeX, lakeY, outflowX, outflowY, elevation: elevation[pourPoint], }; }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/Mnehmos/rpg-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server