#!/usr/bin/env python3 """ Generate a 128x128 test map for Restitution RTS. Uses simplex noise for natural terrain, drunkard walks for paths, and noise-based placement for props and water. """ import json import math import random from noise import snoise2 # Map configuration MAP_SIZE = 128 TILE_SIZE = 32 OUTPUT_PATH = "/root/restitution/public/tilemaps/testmap/test2.tmj" # Tile ID mappings (based on floorsPrimary.tsx) TILE_GRASS = list(range(0, 8)) # Grass variants (cost=1) TILE_DIRT = list(range(8, 16)) # Dirt transitions (cost=1) TILE_WATER = list(range(20, 25)) # Water tiles (cost=2) TILE_PROPS = list(range(48, 68)) # Collision tiles: trees, bushes, rocks (collide=true) # Clearing centers - 4 large open areas for base placement CLEARINGS = [ {"x": 32, "y": 32, "radius": 15}, {"x": 96, "y": 32, "radius": 15}, {"x": 32, "y": 96, "radius": 15}, {"x": 96, "y": 96, "radius": 15}, ] def is_in_clearing(x, y): """Check if a tile is inside one of the base clearings.""" for c in CLEARINGS: dx = x - c["x"] dy = y - c["y"] if dx * dx + dy * dy < c["radius"] * c["radius"]: return True return False def generate_terrain(): """Generate base terrain using simplex noise.""" floor_layer = [0] * (MAP_SIZE * MAP_SIZE) # Noise parameters scale = 0.05 # How "zoomed in" the noise is octaves = 3 persistence = 0.5 lacunarity = 2.0 for y in range(MAP_SIZE): for x in range(MAP_SIZE): # Multi-octave noise for natural variation noise_val = snoise2( x * scale, y * scale, octaves=octaves, persistence=persistence, lacunarity=lacunarity, repeatx=1024, repeaty=1024, base=random.randint(0, 10000) ) # Normalize from [-1, 1] to [0, 1] normalized = (noise_val + 1) / 2 # Clearings override to grass if is_in_clearing(x, y): tile_id = TILE_GRASS[0] # Plain grass in clearings # Water in low areas (but not in clearings) elif normalized < 0.15: tile_id = random.choice(TILE_WATER) # Dirt paths will be added later, for now use grass else: # Vary grass based on noise value grass_index = min(int(normalized * len(TILE_GRASS)), len(TILE_GRASS) - 1) tile_id = TILE_GRASS[grass_index] floor_layer[y * MAP_SIZE + x] = tile_id + 1 # TMJ uses 1-based GIDs return floor_layer def generate_paths(floor_layer): """Generate winding dirt paths using drunkard walk algorithm.""" path_data = floor_layer.copy() # Path endpoints - connect clearings path_starts = [ (32, 64), # Top-left to center-top (96, 64), # Top-right to center-top (64, 32), # Top-center (64, 96), # Bottom-center (32, 96), # Bottom-left to center (96, 96), # Bottom-right to center ] random.seed(42) # Reproducible paths for start_x, start_y in path_starts: # Drunkard walk from clearing edge outward x, y = start_x, start_y steps = random.randint(80, 150) for _ in range(steps): # Place dirt tile idx = y * MAP_SIZE + x if 0 <= idx < len(path_data): # Don't overwrite water current = path_data[idx] if current not in [t + 1 for t in TILE_WATER]: path_data[idx] = random.choice(TILE_DIRT) + 1 # Random walk with bias toward map edges direction = random.choice(["n", "s", "e", "w"]) if direction == "n" and y > 5: y -= 1 elif direction == "s" and y < MAP_SIZE - 6: y += 1 elif direction == "e" and x < MAP_SIZE - 6: x += 1 elif direction == "w" and x > 5: x -= 1 return path_data def generate_props(floor_layer): """Generate collision layer with trees, bushes, rocks.""" rocks_layer = [0] * (MAP_SIZE * MAP_SIZE) random.seed(123) # Different seed for props for y in range(MAP_SIZE): for x in range(MAP_SIZE): # Skip clearings if is_in_clearing(x, y): continue idx = y * MAP_SIZE + x floor_tile = floor_layer[idx] # Higher prop density near water is_near_water = any( floor_layer[ny * MAP_SIZE + nx] in [t + 1 for t in TILE_WATER] for nx, ny in get_neighbors(x, y) if 0 <= nx < MAP_SIZE and 0 <= ny < MAP_SIZE ) # Base prop chance prop_chance = 0.03 if is_near_water: prop_chance = 0.15 # Much denser near water # Don't place on paths or water if floor_tile in [t + 1 for t in TILE_DIRT + TILE_WATER]: continue if random.random() < prop_chance: # Cluster placement - check if neighbor has prop has_neighbor_prop = any( rocks_layer[ny * MAP_SIZE + nx] != 0 for nx, ny in get_neighbors(x, y) if 0 <= nx < MAP_SIZE and 0 <= ny < MAP_SIZE ) if has_neighbor_prop or random.random() < 0.3: # Place prop (tree, bush, or rock) prop_tile = random.choice(TILE_PROPS) rocks_layer[idx] = prop_tile + 1 return rocks_layer def get_neighbors(x, y): """Get 8 neighboring tile coordinates.""" neighbors = [] for dy in [-1, 0, 1]: for dx in [-1, 0, 1]: if dx == 0 and dy == 0: continue neighbors.append((x + dx, y + dy)) return neighbors def generate_decorations(floor_layer): """Generate optional decoration layer (small accent tiles).""" decor_layer = [0] * (MAP_SIZE * MAP_SIZE) # Sparse decorative elements - flowers, small stones random.seed(456) for y in range(MAP_SIZE): for x in range(MAP_SIZE): if is_in_clearing(x, y): continue if random.random() < 0.01: # 1% chance idx = y * MAP_SIZE + x # Use a non-collision decorative tile decor_layer[idx] = random.choice([38, 39, 40]) + 1 return decor_layer def build_tilemap(): """Generate the complete tilemap and save as TMJ.""" print("Generating terrain...") floor_layer = generate_terrain() print("Adding paths...") floor_layer = generate_paths(floor_layer) print("Placing props and collision tiles...") rocks_layer = generate_props(floor_layer) print("Adding decorations...") decor_layer = generate_decorations(floor_layer) # Build TMJ structure matching test1.tmj format tilemap = { "compressionlevel": -1, "height": MAP_SIZE, "infinite": False, "layers": [ { "data": floor_layer, "height": MAP_SIZE, "id": 1, "name": "Floor", "opacity": 1, "type": "tilelayer", "visible": True, "width": MAP_SIZE, "x": 0, "y": 0 }, { "data": rocks_layer, "height": MAP_SIZE, "id": 2, "name": "Rocks", "opacity": 1, "type": "tilelayer", "visible": True, "width": MAP_SIZE, "x": 0, "y": 0 }, { "data": decor_layer, "height": MAP_SIZE, "id": 3, "name": "Decorations", "opacity": 1, "type": "tilelayer", "visible": True, "width": MAP_SIZE, "x": 0, "y": 0 } ], "nextlayerid": 4, "nextobjectid": 1, "orientation": "isometric", "renderorder": "right-down", "tiledversion": "1.9.2", "tileheight": 16, # Must be 16 for Phaser 3.55 tilemap parser "tilesets": [ { "columns": 11, "firstgid": 1, "image": "../../tilesets/floors32x32.png", "imageheight": 352, "imagewidth": 352, "margin": 0, "name": "floorsPrimary", "objectalignment": "center", "spacing": 0, "tilecount": 121, "tileheight": 32, "tilewidth": 32 } ], "tilewidth": TILE_SIZE, "type": "map", "version": "1.9", "width": MAP_SIZE } # Write output print(f"Writing to {OUTPUT_PATH}...") with open(OUTPUT_PATH, "w") as f: json.dump(tilemap, f, indent=1) # Validate print("Validating JSON...") with open(OUTPUT_PATH, "r") as f: loaded = json.load(f) print(f"\n✓ Generated {MAP_SIZE}x{MAP_SIZE} map ({MAP_SIZE * MAP_SIZE * 3} tiles total)") print(f"✓ Tile size: {TILE_SIZE}x{TILE_SIZE} (fixed from 16)") print(f"✓ 3 layers: Floor, Rocks, Decorations") print(f"✓ 4 base clearings at corners") print(f"✓ Output: {OUTPUT_PATH}") # Stats floor_nonzero = sum(1 for t in floor_layer if t != 0) rocks_nonzero = sum(1 for t in rocks_layer if t != 0) decor_nonzero = sum(1 for t in decor_layer if t != 0) print(f"\nLayer stats:") print(f" Floor: {floor_nonzero}/{len(floor_layer)} tiles placed") print(f" Rocks: {rocks_nonzero}/{len(rocks_layer)} collision tiles") print(f" Decor: {decor_nonzero}/{len(decor_layer)} decorations") if __name__ == "__main__": build_tilemap()