From 8934397b1e2afdcf8b4e0d37035527ccea4f4a70 Mon Sep 17 00:00:00 2001 From: Jared Miller Date: Sat, 7 Feb 2026 13:50:06 -0500 Subject: [PATCH] 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. --- src/mudlib/commands/look.py | 16 ++++- src/mudlib/commands/movement.py | 11 ++-- src/mudlib/server.py | 9 +-- src/mudlib/world/terrain.py | 107 ++++++++++++++++---------------- tests/test_commands.py | 3 + tests/test_terrain.py | 46 +++++++++----- 6 files changed, 112 insertions(+), 80 deletions(-) diff --git a/src/mudlib/commands/look.py b/src/mudlib/commands/look.py index d2ecd56..777bb49 100644 --- a/src/mudlib/commands/look.py +++ b/src/mudlib/commands/look.py @@ -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: diff --git a/src/mudlib/commands/movement.py b/src/mudlib/commands/movement.py index a1bac65..e3dc767 100644 --- a/src/mudlib/commands/movement.py +++ b/src/mudlib/commands/movement.py @@ -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() diff --git a/src/mudlib/server.py b/src/mudlib/server.py index 76c454b..7bc6e28 100644 --- a/src/mudlib/server.py +++ b/src/mudlib/server.py @@ -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 diff --git a/src/mudlib/world/terrain.py b/src/mudlib/world/terrain.py index 2781636..58e4af7 100644 --- a/src/mudlib/world/terrain.py +++ b/src/mudlib/world/terrain.py @@ -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) diff --git a/tests/test_commands.py b/tests/test_commands.py index e1b4fe0..04e2687 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -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) diff --git a/tests/test_terrain.py b/tests/test_terrain.py index bab0a3e..07f113d 100644 --- a/tests/test_terrain.py +++ b/tests/test_terrain.py @@ -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)