Last active
February 6, 2026 04:19
-
-
Save jmccardle/3293666881d1df468ded15a21b511dfb to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| """Civilization 1 Clone - Cylindrical World Map with Procedural Generation | |
| Phase 1: World generation, terrain coloring, minimap, camera controls. | |
| """ | |
| import mcrfpy | |
| import sys | |
| import random | |
| # === World Constants === | |
| WORLD_W = 80 | |
| WORLD_H = 50 | |
| GRID_W = WORLD_W * 2 # 160 - doubled for cylindrical wrapping | |
| GRID_H = WORLD_H # 50 | |
| # === Window Layout === | |
| # Window is 1024x768 | |
| # Main map: full left area, minimap in top-right corner | |
| MAIN_MAP_POS = (0, 0) | |
| MAIN_MAP_SIZE = (1024, 768) | |
| MINIMAP_POS = (1024 - 80*2 - 10, 10) # 80 cells * 2px each = 160px wide | |
| MINIMAP_SIZE = (80 * 2, 50 * 2) # 160x100 pixels | |
| CELL_SIZE = 16 # default cell size in pixels | |
| # === Terrain Colors (Civ 1 inspired palette) === | |
| TERRAIN_COLORS = { | |
| "ocean": (16, 48, 178), | |
| "grassland": (32, 160, 32), | |
| "plains": (160, 168, 80), | |
| "forest": (16, 112, 16), | |
| "hills": (144, 128, 80), | |
| "mountains": (160, 160, 160), | |
| "desert": (216, 200, 96), | |
| "jungle": (0, 96, 16), | |
| "swamp": (80, 96, 48), | |
| "river": (48, 96, 208), | |
| "tundra": (176, 192, 208), | |
| "arctic": (240, 244, 255), | |
| } | |
| # === Heightmap-based World Generation === | |
| def lerp(a, b, t): | |
| return a + (b - a) * t | |
| def smooth_noise(grid, x, y, w, h): | |
| """Sample noise grid with bilinear interpolation, wrapping X.""" | |
| ix = int(x) % w | |
| iy = int(y) % h | |
| fx = x - int(x) | |
| fy = y - int(y) | |
| ix1 = (ix + 1) % w | |
| iy1 = min(iy + 1, h - 1) | |
| top = lerp(grid[iy][ix], grid[iy][ix1], fx) | |
| bot = lerp(grid[iy1][ix], grid[iy1][ix1], fx) | |
| return lerp(top, bot, fy) | |
| def generate_noise_layer(w, h, seed): | |
| """Generate a WxH grid of random floats [0,1).""" | |
| rng = random.Random(seed) | |
| return [[rng.random() for _ in range(w)] for _ in range(h)] | |
| def octave_noise(x, y, layers, w, h): | |
| """Multi-octave value noise from pre-generated layers.""" | |
| value = 0.0 | |
| amplitude = 1.0 | |
| total_amp = 0.0 | |
| for scale, noise_grid, nw, nh in layers: | |
| sx = (x / w) * nw * scale | |
| sy = (y / h) * nh * scale | |
| value += smooth_noise(noise_grid, sx, sy, nw, nh) * amplitude | |
| total_amp += amplitude | |
| amplitude *= 0.5 | |
| return value / total_amp | |
| def generate_world(seed=None, ocean_pct=0.60): | |
| """Generate terrain map as WORLD_W x WORLD_H array of terrain type strings. | |
| Two-pass approach: | |
| 1. Generate elevation + moisture heightmaps | |
| 2. Pick sea level from elevation percentile to guarantee target ocean coverage | |
| 3. Classify land tiles by elevation/moisture/latitude | |
| """ | |
| if seed is None: | |
| seed = random.randint(0, 999999) | |
| rng = random.Random(seed) | |
| # Pre-generate noise grids at different resolutions | |
| elev_layers = [] | |
| moist_layers = [] | |
| for i, (scale, nw, nh) in enumerate([(1, 20, 12), (2, 40, 25), (4, 80, 50)]): | |
| elev_layers.append((scale, generate_noise_layer(nw, nh, seed + i), nw, nh)) | |
| moist_layers.append((scale, generate_noise_layer(nw, nh, seed + 100 + i), nw, nh)) | |
| # Continent mask - low-frequency blobs for 2-4 landmasses | |
| continent_noise = generate_noise_layer(10, 6, seed + 200) | |
| # === Pass 1: Build raw elevation and moisture maps === | |
| elev_map = [] | |
| moist_map = [] | |
| temp_map = [] | |
| polar_mask = [] # True if this cell is forced arctic/tundra | |
| for y in range(WORLD_H): | |
| elev_row = [] | |
| moist_row = [] | |
| temp_row = [] | |
| polar_row = [] | |
| for x in range(WORLD_W): | |
| lat = y / (WORLD_H - 1) | |
| temp = 1.0 - abs(lat - 0.5) * 2.0 | |
| # Elevation: octave noise + continent shapes | |
| elev = octave_noise(x, y, elev_layers, WORLD_W, WORLD_H) | |
| cont = smooth_noise(continent_noise, x / WORLD_W * 10, y / WORLD_H * 6, 10, 6) | |
| cont = (cont - 0.5) * 2.5 | |
| elev = elev * 0.4 + max(0.0, min(1.0, cont * 0.5 + 0.5)) * 0.6 | |
| moist = octave_noise(x, y, moist_layers, WORLD_W, WORLD_H) | |
| # Mark polar zones | |
| polar_dist = min(lat, 1.0 - lat) | |
| is_polar = polar_dist < 0.04 | |
| is_tundra_zone = polar_dist < 0.10 | |
| elev_row.append(elev) | |
| moist_row.append(moist) | |
| temp_row.append(temp) | |
| polar_row.append((is_polar, is_tundra_zone)) | |
| elev_map.append(elev_row) | |
| moist_map.append(moist_row) | |
| temp_map.append(temp_row) | |
| polar_mask.append(polar_row) | |
| # === Determine sea level from percentile === | |
| # Collect non-polar elevations and pick the percentile that gives target ocean % | |
| # Polar cells are pre-assigned, so exclude them from the ocean calculation | |
| non_polar_elevs = [] | |
| polar_count = 0 | |
| for y in range(WORLD_H): | |
| for x in range(WORLD_W): | |
| is_polar, is_tundra_zone = polar_mask[y][x] | |
| if is_polar: | |
| polar_count += 1 | |
| else: | |
| non_polar_elevs.append(elev_map[y][x]) | |
| non_polar_elevs.sort() | |
| # We want ocean_pct of total cells to be ocean. | |
| # Polar cells are not ocean, so we need: | |
| # ocean_target_in_non_polar = (ocean_pct * total - 0) / non_polar_count | |
| total = WORLD_W * WORLD_H | |
| target_ocean_non_polar = int(ocean_pct * total) | |
| # Clamp to available non-polar cells | |
| target_ocean_non_polar = min(target_ocean_non_polar, len(non_polar_elevs) - 1) | |
| sea_level = non_polar_elevs[target_ocean_non_polar] | |
| # === Pass 2: Classify terrain === | |
| terrain = [] | |
| for y in range(WORLD_H): | |
| row = [] | |
| for x in range(WORLD_W): | |
| elev = elev_map[y][x] | |
| moist = moist_map[y][x] | |
| temp = temp_map[y][x] | |
| is_polar, is_tundra_zone = polar_mask[y][x] | |
| # Polar ice caps | |
| if is_polar: | |
| row.append("arctic") | |
| continue | |
| # Ocean | |
| if elev < sea_level: | |
| # Tundra-zone shallow water can be tundra coast | |
| if is_tundra_zone and elev > sea_level - 0.05: | |
| row.append("tundra") | |
| else: | |
| row.append("ocean") | |
| continue | |
| # Land classification | |
| land_elev = (elev - sea_level) / (1.0 - sea_level + 0.001) | |
| if land_elev > 0.7: | |
| row.append("mountains") | |
| elif land_elev > 0.45: | |
| row.append("hills") | |
| elif is_tundra_zone: | |
| row.append("tundra") | |
| elif temp > 0.7 and moist > 0.6: | |
| row.append("jungle") | |
| elif temp > 0.65 and moist < 0.3: | |
| row.append("desert") | |
| elif temp < 0.3: | |
| row.append("tundra") | |
| elif moist > 0.55 and land_elev < 0.15: | |
| row.append("swamp") | |
| elif moist > 0.45: | |
| row.append("forest") | |
| elif moist < 0.35: | |
| row.append("plains") | |
| else: | |
| row.append("grassland") | |
| terrain.append(row) | |
| # Add rivers from highlands toward coast | |
| add_rivers(terrain, rng) | |
| # Grassland/river shield bonus (50% chance, determined at mapgen) | |
| shield_tiles = set() | |
| for y in range(WORLD_H): | |
| for x in range(WORLD_W): | |
| if terrain[y][x] in ("grassland", "river") and rng.random() < 0.5: | |
| shield_tiles.add((x, y)) | |
| return terrain, shield_tiles, seed | |
| def add_rivers(terrain, rng, count=8): | |
| """Place river tiles flowing from highlands toward coast.""" | |
| # Find highland sources | |
| sources = [] | |
| for y in range(5, WORLD_H - 5): | |
| for x in range(WORLD_W): | |
| if terrain[y][x] in ("mountains", "hills"): | |
| sources.append((x, y)) | |
| if not sources: | |
| return | |
| rng.shuffle(sources) | |
| placed = 0 | |
| for sx, sy in sources: | |
| if placed >= count: | |
| break | |
| # Trace a path toward lower elevation / coast | |
| cx, cy = sx, sy | |
| length = 0 | |
| visited = set() | |
| while length < 15: | |
| visited.add((cx, cy)) | |
| # Try to move toward nearest ocean, with some randomness | |
| best = None | |
| best_score = 999 | |
| for dx, dy in [(-1,0),(1,0),(0,-1),(0,1)]: | |
| nx = (cx + dx) % WORLD_W | |
| ny = cy + dy | |
| if ny < 0 or ny >= WORLD_H: | |
| continue | |
| if (nx, ny) in visited: | |
| continue | |
| if terrain[ny][nx] == "ocean": | |
| # Reached coast - place river and stop | |
| if terrain[cy][cx] not in ("ocean", "mountains"): | |
| terrain[cy][cx] = "river" | |
| best = None | |
| length = 999 # break outer | |
| placed += 1 | |
| break | |
| if terrain[ny][nx] in ("mountains",): | |
| continue | |
| # Score: prefer moving toward equator-center and lower terrain | |
| score = rng.random() * 3 | |
| if terrain[ny][nx] in ("hills",): | |
| score += 5 # avoid going uphill | |
| best_candidate = (nx, ny, score) | |
| if score < best_score: | |
| best_score = score | |
| best = (nx, ny) | |
| else: | |
| if best is None: | |
| break | |
| cx, cy = best | |
| if terrain[cy][cx] in ("grassland", "plains", "forest", "swamp"): | |
| terrain[cy][cx] = "river" | |
| length += 1 | |
| elif terrain[cy][cx] == "hills": | |
| length += 1 # traverse hills without converting | |
| else: | |
| length += 1 | |
| # === Apply terrain to grids === | |
| def apply_terrain_to_grid(grid, terrain, color_layer): | |
| """Paint terrain colors onto a grid's color layer. | |
| For the main map (160-wide), copies terrain twice for cylindrical wrapping. | |
| For the minimap (80-wide), applies terrain once. | |
| """ | |
| gw = grid.grid_w | |
| gh = grid.grid_h | |
| for y in range(gh): | |
| wy = y % WORLD_H | |
| for x in range(gw): | |
| wx = x % WORLD_W | |
| t = terrain[wy][wx] | |
| r, g, b = TERRAIN_COLORS[t] | |
| color_layer.set((x, y), mcrfpy.Color(r, g, b)) | |
| # === Camera Controls === | |
| class CameraController: | |
| """Manages camera for cylindrical world wrapping.""" | |
| def __init__(self, grid, minimap): | |
| self.grid = grid | |
| self.minimap = minimap | |
| self.cam_x = WORLD_W / 2.0 # Camera position in world tile coords | |
| self.cam_y = WORLD_H / 2.0 | |
| self.scroll_speed = 3 # tiles per keypress | |
| self.update_camera() | |
| def update_camera(self): | |
| """Sync grid camera to our logical position, applying wrap.""" | |
| # Wrap X to [0, WORLD_W) | |
| self.cam_x = self.cam_x % WORLD_W | |
| # Clamp Y to valid range | |
| self.cam_y = max(0, min(WORLD_H - 1, self.cam_y)) | |
| # Main grid camera: offset by WORLD_W/2 to center in the doubled grid | |
| # We render at position (cam_x + WORLD_W/2) so we're in the middle copy | |
| grid_cam_x = (self.cam_x + WORLD_W / 2) * CELL_SIZE | |
| grid_cam_y = self.cam_y * CELL_SIZE | |
| self.grid.center = (grid_cam_x, grid_cam_y) | |
| def scroll(self, dx, dy): | |
| self.cam_x += dx | |
| self.cam_y += dy | |
| self.update_camera() | |
| def center_on(self, world_x, world_y): | |
| self.cam_x = world_x | |
| self.cam_y = world_y | |
| self.update_camera() | |
| # === HUD Elements === | |
| def create_minimap_border(minimap): | |
| """Create a border frame around the minimap.""" | |
| mx, my = MINIMAP_POS | |
| mw, mh = MINIMAP_SIZE | |
| border = mcrfpy.Frame( | |
| pos=(mx - 2, my - 2), | |
| size=(mw + 4, mh + 4), | |
| fill_color=mcrfpy.Color(0, 0, 0, 0), | |
| outline_color=mcrfpy.Color(200, 200, 200), | |
| outline=2.0 | |
| ) | |
| return border | |
| def create_info_panel(): | |
| """Create the tile info panel at bottom of screen.""" | |
| panel = mcrfpy.Frame( | |
| pos=(0, 718), | |
| size=(1024, 50), | |
| fill_color=mcrfpy.Color(20, 20, 40, 200), | |
| outline_color=mcrfpy.Color(100, 100, 140), | |
| outline=1.0 | |
| ) | |
| label = mcrfpy.Caption( | |
| text="Civilization Map - Arrow keys to scroll, click minimap to jump", | |
| pos=(10, 8) | |
| ) | |
| label.fill_color = mcrfpy.Color(200, 200, 220) | |
| panel.children.append(label) | |
| coord_label = mcrfpy.Caption(text="", pos=(10, 28)) | |
| coord_label.fill_color = mcrfpy.Color(160, 180, 200) | |
| panel.children.append(coord_label) | |
| return panel, coord_label | |
| # === Main Setup === | |
| def main(): | |
| # Create scene | |
| scene = mcrfpy.Scene("civ_map") | |
| ui = scene.children | |
| # Generate world | |
| terrain, shield_tiles, seed = generate_world() | |
| # Count terrain types for diagnostics | |
| counts = {} | |
| for row in terrain: | |
| for t in row: | |
| counts[t] = counts.get(t, 0) + 1 | |
| total = WORLD_W * WORLD_H | |
| # === Main Map Grid (160x50, zoomed to 4x) === | |
| main_grid = mcrfpy.Grid( | |
| grid_size=(GRID_W, GRID_H), | |
| pos=MAIN_MAP_POS, | |
| size=MAIN_MAP_SIZE, | |
| zoom=1.5, | |
| layers=[] | |
| ) | |
| main_grid.fill_color = mcrfpy.Color(8, 8, 32) | |
| main_layer = mcrfpy.ColorLayer(z_index=-1, name="terrain") | |
| main_grid.add_layer(main_layer) | |
| ui.append(main_grid) | |
| # === Minimap Grid (80x50) === | |
| # To fit 80 cells into 160px: each cell = 2px, zoom = 2/16 = 0.125 | |
| minimap_zoom = MINIMAP_SIZE[0] / (WORLD_W * CELL_SIZE) | |
| minimap = mcrfpy.Grid( | |
| grid_size=(WORLD_W, WORLD_H), | |
| pos=MINIMAP_POS, | |
| size=MINIMAP_SIZE, | |
| zoom=minimap_zoom, | |
| layers=[] | |
| ) | |
| minimap.fill_color = mcrfpy.Color(8, 8, 32) | |
| mini_layer = mcrfpy.ColorLayer(z_index=-1, name="terrain") | |
| minimap.add_layer(mini_layer) | |
| # Center minimap camera to show entire world | |
| minimap.center = (WORLD_W * CELL_SIZE / 2, WORLD_H * CELL_SIZE / 2) | |
| ui.append(minimap) | |
| # Minimap border | |
| ui.append(create_minimap_border(minimap)) | |
| # Apply terrain to all grids | |
| apply_terrain_to_grid(main_grid, terrain, main_layer) | |
| apply_terrain_to_grid(minimap, terrain, mini_layer) | |
| # Info panel | |
| info_panel, coord_label = create_info_panel() | |
| ui.append(info_panel) | |
| # Camera controller | |
| camera = CameraController(main_grid, minimap) | |
| # === Input Handlers === | |
| def on_key(key, state): | |
| if state != mcrfpy.InputState.PRESSED: | |
| return | |
| speed = camera.scroll_speed | |
| if key == mcrfpy.Key.LEFT or key == mcrfpy.Key.A: | |
| camera.scroll(-speed, 0) | |
| elif key == mcrfpy.Key.RIGHT or key == mcrfpy.Key.D: | |
| camera.scroll(speed, 0) | |
| elif key == mcrfpy.Key.UP or key == mcrfpy.Key.W: | |
| camera.scroll(0, -speed) | |
| elif key == mcrfpy.Key.DOWN or key == mcrfpy.Key.S: | |
| camera.scroll(0, speed) | |
| elif key == mcrfpy.Key.ESCAPE: | |
| sys.exit(0) | |
| elif key == mcrfpy.Key.R: | |
| # Regenerate world | |
| nonlocal terrain, shield_tiles | |
| terrain, shield_tiles, new_seed = generate_world() | |
| apply_terrain_to_grid(main_grid, terrain, main_layer) | |
| apply_terrain_to_grid(minimap, terrain, mini_layer) | |
| scene.on_key = on_key | |
| # Main grid cell hover - show terrain info | |
| def on_cell_enter(cell_pos): | |
| gx = int(cell_pos.x) | |
| gy = int(cell_pos.y) | |
| wx = gx % WORLD_W | |
| wy = gy % WORLD_H | |
| t = terrain[wy][wx] | |
| has_shield = (wx, wy) in shield_tiles | |
| shield_note = " [+1 shield]" if has_shield else "" | |
| coord_label.text = f"({wx}, {wy}) {t.capitalize()}{shield_note}" | |
| main_grid.on_cell_enter = on_cell_enter | |
| # Minimap click - jump camera | |
| def on_minimap_click(cell_pos, button, action): | |
| if action == mcrfpy.InputState.PRESSED: | |
| wx = int(cell_pos.x) % WORLD_W | |
| wy = int(cell_pos.y) % WORLD_H | |
| camera.center_on(wx, wy) | |
| minimap.on_cell_click = on_minimap_click | |
| # Activate scene | |
| mcrfpy.current_scene = scene | |
| main() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment