Make world wrap seamlessly in both axes
Tileable Perlin noise: each octave wraps its integer grid coordinates with modulo at the octave's frequency, so gradients at opposite edges match and the noise field is continuous across the boundary. Coarse elevation grid interpolation wraps instead of padding boundary cells. Rivers can flow across world edges. All coordinate access (get_tile, is_passable, get_viewport) wraps via modulo. Movement, spawn search, nearby-player detection, and viewport relative positions all handle the toroidal topology.
This commit is contained in:
parent
9cd9c6f790
commit
8934397b1e
6 changed files with 112 additions and 80 deletions
|
|
@ -34,9 +34,19 @@ async def cmd_look(player: Player, args: str) -> None:
|
|||
if other.name == player.name:
|
||||
continue
|
||||
|
||||
# Calculate relative position
|
||||
rel_x = other.x - player.x + center_x
|
||||
rel_y = other.y - player.y + center_y
|
||||
# Calculate relative position (shortest path wrapping)
|
||||
dx = other.x - player.x
|
||||
dy = other.y - player.y
|
||||
if dx > world.width // 2:
|
||||
dx -= world.width
|
||||
elif dx < -(world.width // 2):
|
||||
dx += world.width
|
||||
if dy > world.height // 2:
|
||||
dy -= world.height
|
||||
elif dy < -(world.height // 2):
|
||||
dy += world.height
|
||||
rel_x = dx + center_x
|
||||
rel_y = dy + center_y
|
||||
|
||||
# Check if within viewport bounds
|
||||
if 0 <= rel_x < VIEWPORT_WIDTH and 0 <= rel_y < VIEWPORT_HEIGHT:
|
||||
|
|
|
|||
|
|
@ -65,8 +65,7 @@ async def move_player(player: Player, dx: int, dy: int, direction_name: str) ->
|
|||
dy: Y delta
|
||||
direction_name: Full name of the direction for messages
|
||||
"""
|
||||
target_x = player.x + dx
|
||||
target_y = player.y + dy
|
||||
target_x, target_y = world.wrap(player.x + dx, player.y + dy)
|
||||
|
||||
# Check if the target is passable
|
||||
if not world.is_passable(target_x, target_y):
|
||||
|
|
@ -111,8 +110,12 @@ async def send_nearby_message(player: Player, x: int, y: int, message: str) -> N
|
|||
if other.name == player.name:
|
||||
continue
|
||||
|
||||
# Check if other player is within viewport range
|
||||
if abs(other.x - x) <= viewport_range and abs(other.y - y) <= viewport_range:
|
||||
# Check if other player is within viewport range (wrapping)
|
||||
dx_dist = abs(other.x - x)
|
||||
dy_dist = abs(other.y - y)
|
||||
dx_dist = min(dx_dist, world.width - dx_dist)
|
||||
dy_dist = min(dy_dist, world.height - dy_dist)
|
||||
if dx_dist <= viewport_range and dy_dist <= viewport_range:
|
||||
other.writer.write(message)
|
||||
await other.writer.drain()
|
||||
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ def find_passable_start(world: World, start_x: int, start_y: int) -> tuple[int,
|
|||
if world.is_passable(start_x, start_y):
|
||||
return start_x, start_y
|
||||
|
||||
# Spiral outward from the starting position
|
||||
# Spiral outward from the starting position (wrapping)
|
||||
for radius in range(1, 100):
|
||||
for dx in range(-radius, radius + 1):
|
||||
for dy in range(-radius, radius + 1):
|
||||
|
|
@ -46,12 +46,7 @@ def find_passable_start(world: World, start_x: int, start_y: int) -> tuple[int,
|
|||
if abs(dx) != radius and abs(dy) != radius:
|
||||
continue
|
||||
|
||||
x = start_x + dx
|
||||
y = start_y + dy
|
||||
|
||||
# Check bounds
|
||||
if not (0 <= x < world.width and 0 <= y < world.height):
|
||||
continue
|
||||
x, y = world.wrap(start_x + dx, start_y + dy)
|
||||
|
||||
if world.is_passable(x, y):
|
||||
return x, y
|
||||
|
|
|
|||
|
|
@ -12,13 +12,18 @@ def _make_perm(seed: int) -> list[int]:
|
|||
return p + p
|
||||
|
||||
|
||||
def _noise(perm: list[int], x: float, y: float) -> float:
|
||||
"""2D Perlin noise, fully inlined for speed."""
|
||||
def _noise(perm: list[int], x: float, y: float, px: int, py: int) -> float:
|
||||
"""2D tileable Perlin noise.
|
||||
|
||||
Wraps seamlessly with period px along x and py along y.
|
||||
"""
|
||||
# unit grid cell
|
||||
fx = math.floor(x)
|
||||
fy = math.floor(y)
|
||||
xi = int(fx) & 255
|
||||
yi = int(fy) & 255
|
||||
xi = int(fx) % px
|
||||
yi = int(fy) % py
|
||||
xi1 = (xi + 1) % px
|
||||
yi1 = (yi + 1) % py
|
||||
|
||||
# relative coordinates within cell
|
||||
xf = x - fx
|
||||
|
|
@ -28,11 +33,11 @@ def _noise(perm: list[int], x: float, y: float) -> float:
|
|||
u = xf * xf * xf * (xf * (xf * 6 - 15) + 10)
|
||||
v = yf * yf * yf * (yf * (yf * 6 - 15) + 10)
|
||||
|
||||
# hash coordinates of 4 corners
|
||||
# hash coordinates of 4 corners (wrapping)
|
||||
aa = perm[perm[xi] + yi]
|
||||
ab = perm[perm[xi] + yi + 1]
|
||||
ba = perm[perm[xi + 1] + yi]
|
||||
bb = perm[perm[xi + 1] + yi + 1]
|
||||
ab = perm[perm[xi] + yi1]
|
||||
ba = perm[perm[xi1] + yi]
|
||||
bb = perm[perm[xi1] + yi1]
|
||||
|
||||
# grad + lerp inlined for all 4 corners
|
||||
xf1 = xf - 1
|
||||
|
|
@ -74,19 +79,20 @@ class World:
|
|||
self.terrain = self._generate_terrain()
|
||||
|
||||
def _generate_elevation(self) -> list[list[float]]:
|
||||
"""Generate elevation map using fractal noise.
|
||||
"""Generate tileable elevation map using fractal noise.
|
||||
|
||||
Computes at 1/4 resolution and interpolates up for speed.
|
||||
Tiles seamlessly in both axes so the world can wrap.
|
||||
"""
|
||||
w = self.width
|
||||
h = self.height
|
||||
perm = _make_perm(self.seed)
|
||||
noise = _noise
|
||||
|
||||
# generate at 1/4 resolution
|
||||
# generate at 1/4 resolution (no boundary padding, we wrap instead)
|
||||
step = 4
|
||||
cw = w // step + 2 # +2 for interpolation boundary
|
||||
ch = h // step + 2
|
||||
cw = w // step
|
||||
ch = h // step
|
||||
|
||||
coarse = [[0.0] * cw for _ in range(ch)]
|
||||
min_val = float("inf")
|
||||
|
|
@ -95,14 +101,15 @@ class World:
|
|||
for cy in range(ch):
|
||||
row = coarse[cy]
|
||||
for cx in range(cw):
|
||||
# map coarse coords back to world space
|
||||
# map coarse coords back to world space [0, 1)
|
||||
wx = cx * step / w
|
||||
wy = cy * step / h
|
||||
|
||||
# each octave tiles at its own frequency
|
||||
value = (
|
||||
noise(perm, wx * 4, wy * 4)
|
||||
+ noise(perm, wx * 8, wy * 8) * 0.5
|
||||
+ noise(perm, wx * 16, wy * 16) * 0.25
|
||||
noise(perm, wx * 4, wy * 4, 4, 4)
|
||||
+ noise(perm, wx * 8, wy * 8, 8, 8) * 0.5
|
||||
+ noise(perm, wx * 16, wy * 16, 16, 16) * 0.25
|
||||
)
|
||||
row[cx] = value
|
||||
if value < min_val:
|
||||
|
|
@ -119,15 +126,16 @@ class World:
|
|||
for cx in range(cw):
|
||||
row[cx] = (row[cx] - min_val) * inv_range
|
||||
|
||||
# bilinear interpolation to full resolution
|
||||
# bilinear interpolation to full resolution (wrapping at edges)
|
||||
inv_step = 1.0 / step
|
||||
elevation = [[0.0] * w for _ in range(h)]
|
||||
|
||||
for y in range(h):
|
||||
cy_f = y * inv_step
|
||||
cy0 = int(cy_f)
|
||||
cy1 = min(cy0 + 1, ch - 1)
|
||||
fy = cy_f - cy0
|
||||
cy_floor = int(cy_f)
|
||||
cy0 = cy_floor % ch
|
||||
cy1 = (cy0 + 1) % ch
|
||||
fy = cy_f - cy_floor
|
||||
fy_inv = 1.0 - fy
|
||||
row_0 = coarse[cy0]
|
||||
row_1 = coarse[cy1]
|
||||
|
|
@ -135,9 +143,10 @@ class World:
|
|||
|
||||
for x in range(w):
|
||||
cx_f = x * inv_step
|
||||
cx0 = int(cx_f)
|
||||
cx1 = min(cx0 + 1, cw - 1)
|
||||
fx = cx_f - cx0
|
||||
cx_floor = int(cx_f)
|
||||
cx0 = cx_floor % cw
|
||||
cx1 = (cx0 + 1) % cw
|
||||
fx = cx_f - cx_floor
|
||||
|
||||
# bilinear interpolation
|
||||
out_row[x] = (
|
||||
|
|
@ -187,47 +196,44 @@ class World:
|
|||
) -> None:
|
||||
"""Generate rivers from mountains to water bodies."""
|
||||
rng = random.Random(self.seed)
|
||||
w = self.width
|
||||
h = self.height
|
||||
|
||||
# find some high-elevation starting points
|
||||
num_rivers = max(5, (self.width * self.height) // 50000)
|
||||
num_rivers = max(5, (w * h) // 50000)
|
||||
|
||||
for _ in range(num_rivers):
|
||||
# pick random high elevation point
|
||||
x = rng.randint(0, self.width - 1)
|
||||
y = rng.randint(0, self.height - 1)
|
||||
x = rng.randint(0, w - 1)
|
||||
y = rng.randint(0, h - 1)
|
||||
|
||||
if elevation[y][x] < 0.6:
|
||||
continue
|
||||
|
||||
# trace downhill
|
||||
# trace downhill (wrapping at edges)
|
||||
visited = set()
|
||||
while True:
|
||||
if (x, y) in visited or len(visited) > 200:
|
||||
break
|
||||
visited.add((x, y))
|
||||
|
||||
# if we hit water or edge, stop
|
||||
# if we hit water, stop
|
||||
if terrain[y][x] == self.TERRAIN_CHARS["water"]:
|
||||
break
|
||||
if x <= 0 or x >= self.width - 1 or y <= 0 or y >= self.height - 1:
|
||||
break
|
||||
|
||||
# carve river
|
||||
if elevation[y][x] < 0.75: # don't carve through mountains
|
||||
terrain[y][x] = self.TERRAIN_CHARS["water"]
|
||||
|
||||
# find steepest descent to neighbor
|
||||
# find steepest descent to neighbor (wrapping)
|
||||
current_elevation = elevation[y][x]
|
||||
best_x, best_y = x, y
|
||||
best_elevation = current_elevation
|
||||
|
||||
for dx, dy in [(-1, 0), (1, 0), (0, -1), (0, 1)]:
|
||||
nx, ny = x + dx, y + dy
|
||||
if (
|
||||
0 <= nx < self.width
|
||||
and 0 <= ny < self.height
|
||||
and elevation[ny][nx] < best_elevation
|
||||
):
|
||||
nx = (x + dx) % w
|
||||
ny = (y + dy) % h
|
||||
if elevation[ny][nx] < best_elevation:
|
||||
best_elevation = elevation[ny][nx]
|
||||
best_x, best_y = nx, ny
|
||||
|
||||
|
|
@ -243,39 +249,36 @@ class World:
|
|||
self._generate_rivers(terrain, elevation)
|
||||
return terrain
|
||||
|
||||
def wrap(self, x: int, y: int) -> tuple[int, int]:
|
||||
"""Wrap coordinates to stay within world bounds."""
|
||||
return x % self.width, y % self.height
|
||||
|
||||
def get_tile(self, x: int, y: int) -> str:
|
||||
"""Return terrain character at position."""
|
||||
if not (0 <= x < self.width and 0 <= y < self.height):
|
||||
raise ValueError(f"Coordinates ({x}, {y}) out of bounds")
|
||||
return self.terrain[y][x]
|
||||
"""Return terrain character at position (wrapping)."""
|
||||
wx, wy = self.wrap(x, y)
|
||||
return self.terrain[wy][wx]
|
||||
|
||||
def is_passable(self, x: int, y: int) -> bool:
|
||||
"""Check if position is passable."""
|
||||
"""Check if position is passable (wrapping)."""
|
||||
tile = self.get_tile(x, y)
|
||||
return tile not in {self.TERRAIN_CHARS["mountain"], self.TERRAIN_CHARS["water"]}
|
||||
|
||||
def get_viewport(
|
||||
self, cx: int, cy: int, width: int, height: int
|
||||
) -> list[list[str]]:
|
||||
"""Return 2D slice of terrain centered on (cx, cy)."""
|
||||
"""Return 2D slice of terrain centered on (cx, cy), wrapping at edges."""
|
||||
viewport = []
|
||||
|
||||
# calculate bounds
|
||||
half_width = width // 2
|
||||
half_height = height // 2
|
||||
|
||||
start_y = cy - half_height
|
||||
end_y = start_y + height
|
||||
|
||||
start_x = cx - half_width
|
||||
end_x = start_x + width
|
||||
|
||||
for y in range(start_y, end_y):
|
||||
for dy in range(height):
|
||||
row = []
|
||||
for x in range(start_x, end_x):
|
||||
# clamp to world bounds
|
||||
wx = max(0, min(x, self.width - 1))
|
||||
wy = max(0, min(y, self.height - 1))
|
||||
for dx in range(width):
|
||||
wx, wy = self.wrap(start_x + dx, start_y + dy)
|
||||
row.append(self.terrain[wy][wx])
|
||||
viewport.append(row)
|
||||
|
||||
|
|
|
|||
|
|
@ -25,7 +25,10 @@ def mock_reader():
|
|||
@pytest.fixture
|
||||
def mock_world():
|
||||
world = MagicMock()
|
||||
world.width = 100
|
||||
world.height = 100
|
||||
world.is_passable = MagicMock(return_value=True)
|
||||
world.wrap = MagicMock(side_effect=lambda x, y: (x % 100, y % 100))
|
||||
# Create a 21x11 viewport filled with "."
|
||||
viewport = [["." for _ in range(21)] for _ in range(11)]
|
||||
world.get_viewport = MagicMock(return_value=viewport)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
import pytest
|
||||
|
||||
from mudlib.world.terrain import World
|
||||
|
||||
|
||||
|
|
@ -36,12 +34,11 @@ def test_world_dimensions():
|
|||
world.get_tile(0, 0)
|
||||
world.get_tile(99, 49)
|
||||
|
||||
# should raise for out of bounds
|
||||
with pytest.raises((IndexError, ValueError)):
|
||||
world.get_tile(100, 0)
|
||||
|
||||
with pytest.raises((IndexError, ValueError)):
|
||||
world.get_tile(0, 50)
|
||||
# coordinates wrap instead of raising
|
||||
assert world.get_tile(100, 0) == world.get_tile(0, 0)
|
||||
assert world.get_tile(0, 50) == world.get_tile(0, 0)
|
||||
assert world.get_tile(-1, 0) == world.get_tile(99, 0)
|
||||
assert world.get_tile(0, -1) == world.get_tile(0, 49)
|
||||
|
||||
|
||||
def test_get_tile_returns_valid_terrain():
|
||||
|
|
@ -129,19 +126,23 @@ def test_get_viewport_centers_correctly():
|
|||
assert center_tile == world.get_tile(50, 50)
|
||||
|
||||
|
||||
def test_get_viewport_handles_edges():
|
||||
"""Viewport handles world boundaries correctly."""
|
||||
def test_get_viewport_wraps_at_edges():
|
||||
"""Viewport wraps around world boundaries."""
|
||||
world = World(seed=42, width=100, height=100)
|
||||
|
||||
# near top-left corner
|
||||
viewport = world.get_viewport(2, 2, width=5, height=5)
|
||||
# near top-left corner - viewport should wrap to bottom/right
|
||||
viewport = world.get_viewport(0, 0, width=5, height=5)
|
||||
assert len(viewport) == 5
|
||||
assert len(viewport[0]) == 5
|
||||
# top-left of viewport wraps to bottom-right of world
|
||||
assert viewport[0][0] == world.get_tile(98, 98)
|
||||
|
||||
# near bottom-right corner
|
||||
viewport = world.get_viewport(97, 97, width=5, height=5)
|
||||
# near bottom-right corner - viewport should wrap to top/left
|
||||
viewport = world.get_viewport(99, 99, width=5, height=5)
|
||||
assert len(viewport) == 5
|
||||
assert len(viewport[0]) == 5
|
||||
# bottom-right of viewport wraps to top-left of world
|
||||
assert viewport[4][4] == world.get_tile(1, 1)
|
||||
|
||||
|
||||
def test_terrain_distribution_reasonable():
|
||||
|
|
@ -178,6 +179,23 @@ def test_rivers_exist():
|
|||
assert water_count > 0
|
||||
|
||||
|
||||
def test_terrain_tiles_seamlessly():
|
||||
"""Terrain at opposite edges matches for seamless wrapping."""
|
||||
world = World(seed=42, width=100, height=100)
|
||||
|
||||
# check that a strip along the right edge is continuous with the left edge
|
||||
# by verifying the elevation-derived terrain doesn't have a hard seam.
|
||||
# the noise is tileable, so adjacent tiles across the boundary should be
|
||||
# from the same smooth noise field (not identical, but not a cliff).
|
||||
# we verify the wrap helper itself works correctly:
|
||||
for y in range(100):
|
||||
assert world.get_tile(-1, y) == world.get_tile(99, y)
|
||||
assert world.get_tile(100, y) == world.get_tile(0, y)
|
||||
for x in range(100):
|
||||
assert world.get_tile(x, -1) == world.get_tile(x, 99)
|
||||
assert world.get_tile(x, 100) == world.get_tile(x, 0)
|
||||
|
||||
|
||||
def test_large_world_generation():
|
||||
"""Can generate a 1000x1000 world without errors."""
|
||||
world = World(seed=42, width=1000, height=1000)
|
||||
|
|
|
|||
Loading…
Reference in a new issue