Mi2dRPGamEng/engine/worldgen/terrain.py

165 lines
7.2 KiB
Python

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)