from __future__ import annotations import random import numpy as np from engine.map import Map from engine.tile import TileLayerInstance from engine.worldgen.noise import perlin_noise_2d MAX_HEIGHT = 6 # number of distinct surface elevations (z = 0..MAX_HEIGHT-1) DEPTH = MAX_HEIGHT + 1 # +1 headroom for a slope "descend" connector above the tallest peak WATER_MAX_Z = 1 # z <= this is underwater SAND_Z = 2 # the one shoreline band just above the waterline STONE_MIN_Z = 5 # z >= this is bare rock/peaks MUD_MOISTURE_THRESHOLD = 0.55 # moisture noise above this, in the dirt/mud band, becomes mud CAVE_MIN_HEIGHT_DIFF = 2 CAVE_CHANCE = 0.2 HEIGHT_NOISE_SCALE = 18.0 MOISTURE_NOISE_SCALE = 12.0 MOISTURE_SEED_OFFSET = 10_000 # keeps the moisture field decorrelated from the height field def terrain_for(z: int, moisture: float) -> str: """Flooring def id for a column of surface height `z`, given its independent moisture sample - see resources/defs/tiles.json's "flooring" section for the matching defs. """ if z <= WATER_MAX_Z: return "water" if z == SAND_Z: return "sand" if z >= STONE_MIN_Z: return "stone" return "mud" if moisture > MUD_MOISTURE_THRESHOLD else "dirt" def generate_heights(width: int, height: int, seed: int, x_offset: float = 0.0, y_offset: float = 0.0) -> np.ndarray: """Integer surface elevation per (x, y) column, 0..MAX_HEIGHT-1, from Perlin noise.""" noise = perlin_noise_2d(width, height, seed, scale=HEIGHT_NOISE_SCALE, x_offset=x_offset, y_offset=y_offset) return (noise * MAX_HEIGHT).astype(int).clip(0, MAX_HEIGHT - 1) def generate_moisture(width: int, height: int, seed: int, x_offset: float = 0.0, y_offset: float = 0.0) -> np.ndarray: """An independent noise field (different seed + scale) so terrain variety isn't purely a function of elevation - e.g. two dirt-band columns at the same height can differ (mud vs. plain dirt) rather than every column at a given height looking identical. """ return perlin_noise_2d( width, height, seed + MOISTURE_SEED_OFFSET, scale=MOISTURE_NOISE_SCALE, x_offset=x_offset, y_offset=y_offset ) def _set_tile( m: Map, x: int, y: int, z: int, *, flooring: str | None = None, room: str | None = None, roof: str | None = None ) -> None: tile = m.get_tile(x, y, z) if flooring is not None: tile.flooring = TileLayerInstance(flooring) if room is not None: tile.room = TileLayerInstance(room) if roof is not None: tile.roof = TileLayerInstance(roof) def _place_slope_connector( m: Map, low_xy: tuple[int, int], high_xy: tuple[int, int], low_z: int, low_terrain: str, high_terrain: str ) -> None: """Lets a character walk directly between two columns exactly 1 level apart (req 3): `high_xy` gets a walkable tile at `low_z` marked to auto-rise (landed on by walking over from `low_xy`), and `low_xy` gets one at `low_z + 1` marked to auto-descend (landed on by walking over from `high_xy`) - see Tile.staircase_direction/World._apply_staircase for the actual auto-shift. Each side keeps its own column's terrain material underfoot. """ lx, ly = low_xy hx, hy = high_xy if m.in_bounds(hx, hy, low_z): _set_tile(m, hx, hy, low_z, flooring=high_terrain, room="slope_up") if m.in_bounds(lx, ly, low_z + 1): _set_tile(m, lx, ly, low_z + 1, flooring=low_terrain, room="slope_down") def _carve_cave_pocket(m: Map, high_xy: tuple[int, int], low_z: int) -> None: """Replaces what would otherwise be solid rock at the base of a steep (2+) cliff with a small walkable, roofed (it's still under the overhanging higher terrain), unwalled cave pocket (req 4) - reachable by walking straight in from the lower neighbor at `low_z`. Doesn't attempt to connect all the way up to the higher column's own surface - a full multi-level tunnel needs more than one waypoint to be climbable (auto-staircase only triggers once per horizontal move, onto a *different* tile - see _place_slope_connector's docstring), which is a bigger corridor-carving feature than "sometimes, a cave forms here" calls for. This is a "sometimes" flavor pocket, not a guaranteed shortcut. """ x, y = high_xy if m.in_bounds(x, y, low_z): tile = m.get_tile(x, y, low_z) tile.flooring = TileLayerInstance("stone") tile.room = None # clears the solid_rock the underground fill pass set here tile.roof = TileLayerInstance("cave_ceiling") def build_terrain_map(map_id: str, heights: np.ndarray, moisture: np.ndarray, rng: random.Random) -> Map: """The deterministic half of terrain generation: turns an already-computed heightmap + moisture map into an actual Map. Split out from generate_terrain_map so tests can drive it with hand-built arrays instead of reverse-engineering a seed that produces a given layout. """ height, width = heights.shape m = Map(map_id, width, height, DEPTH) for y in range(height): for x in range(width): z = int(heights[y, x]) terrain = terrain_for(z, float(moisture[y, x])) for below in range(z): if terrain == "water": # a shallow water column is swimmable all the way down, not just at its # surface - see resources/logic/swimming.py's vertical movement (req 6) _set_tile(m, x, y, below, flooring="water") else: _set_tile(m, x, y, below, room="solid_rock") _set_tile(m, x, y, z, flooring=terrain) # roof left empty - open to the sky (req 2) for y in range(height): for x in range(width): z = int(heights[y, x]) terrain = terrain_for(z, float(moisture[y, x])) for nx, ny in ((x + 1, y), (x, y + 1)): if not (0 <= nx < width and 0 <= ny < height): continue nz = int(heights[ny, nx]) n_terrain = terrain_for(nz, float(moisture[ny, nx])) diff = nz - z if diff == 1: _place_slope_connector(m, (x, y), (nx, ny), z, terrain, n_terrain) elif diff == -1: _place_slope_connector(m, (nx, ny), (x, y), nz, n_terrain, terrain) elif abs(diff) >= CAVE_MIN_HEIGHT_DIFF and rng.random() < CAVE_CHANCE: low_z = z if diff > 0 else nz high_xy = (nx, ny) if diff > 0 else (x, y) _carve_cave_pocket(m, high_xy, low_z) return m def generate_terrain_map( map_id: str, width: int, height: int, seed: int, x_offset: float = 0.0, y_offset: float = 0.0, ) -> Map: """Builds a heightmap-based terrain Map from Perlin noise - see build_terrain_map for the actual tile placement rules (surface material, slopes, cave pockets, underground rock). `x_offset`/`y_offset` sample a shifted window of the same noise field (see engine/worldgen/noise.py::perlin_noise_2d), for tiling multiple generated maps seamlessly. """ heights = generate_heights(width, height, seed, x_offset, y_offset) moisture = generate_moisture(width, height, seed, x_offset, y_offset) rng = random.Random(seed) return build_terrain_map(map_id, heights, moisture, rng)