Skip to content

Instantly share code, notes, and snippets.

@jmccardle
Last active February 6, 2026 04:19
Show Gist options
  • Select an option

  • Save jmccardle/3293666881d1df468ded15a21b511dfb to your computer and use it in GitHub Desktop.

Select an option

Save jmccardle/3293666881d1df468ded15a21b511dfb to your computer and use it in GitHub Desktop.
"""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