1753 lines
70 KiB
Python
1753 lines
70 KiB
Python
from __future__ import annotations
|
|
|
|
import argparse
|
|
import gc
|
|
import hashlib
|
|
import json
|
|
import math
|
|
import random
|
|
from collections import deque
|
|
from heapq import heappop, heappush
|
|
from pathlib import Path
|
|
|
|
from PIL import Image, ImageChops, ImageDraw, ImageEnhance, ImageFilter, ImageOps
|
|
|
|
|
|
Image.MAX_IMAGE_PIXELS = None
|
|
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
DEFAULT_DATA = ROOT / "tmp" / "battle-map-v2-runtime-data.json"
|
|
SOURCE_DIR = ROOT / "src" / "assets" / "images" / "battle" / "terrain-v2"
|
|
DOCS_DIR = ROOT / "docs"
|
|
|
|
SOURCE_PATHS = {
|
|
kind: SOURCE_DIR / f"{kind}-v2-source.png"
|
|
for kind in ("plain", "road", "hill", "forest", "river", "cliff", "village", "fort", "camp")
|
|
}
|
|
|
|
REGION_KINDS = ("hill", "forest", "cliff")
|
|
STRUCTURE_KINDS = {"village", "fort", "camp"}
|
|
CONNECTABLE_ROAD = {"road", "village", "fort", "camp"}
|
|
MAX_TEXTURE_EDGE = 8192
|
|
WATER_LAYER_REASSERT_BATTLES = {40, 41, 42, 43, 51, 52, 53, 54, 59, 60, 61, 62, 63}
|
|
GRID_CARDINAL_NEIGHBORS = ((1, 0), (-1, 0), (0, 1), (0, -1))
|
|
GRID_EIGHT_NEIGHBORS = ((0, -1), (1, -1), (1, 0), (1, 1), (0, 1), (-1, 1), (-1, 0), (-1, -1))
|
|
|
|
|
|
def coordinate_hash(x: int, y: int, seed: int, salt: int = 0) -> int:
|
|
value = (x * 0x1F123BB5) ^ (y * 0x5F356495) ^ (seed * 0x9E3779B1) ^ salt
|
|
value &= 0xFFFFFFFF
|
|
value ^= value >> 16
|
|
value = (value * 0x7FEB352D) & 0xFFFFFFFF
|
|
value ^= value >> 15
|
|
value = (value * 0x846CA68B) & 0xFFFFFFFF
|
|
value ^= value >> 16
|
|
return value
|
|
|
|
|
|
def parse_args() -> argparse.Namespace:
|
|
parser = argparse.ArgumentParser(description="Generate topology-safe V2 battle maps from AI-authored terrain sources.")
|
|
parser.add_argument("--data", type=Path, default=DEFAULT_DATA)
|
|
parser.add_argument("--maps", default="2-66", help="Battle numbers, ranges, or comma-separated values (default: 2-66).")
|
|
parser.add_argument("--quality", type=int, default=84)
|
|
parser.add_argument("--contact-sheet", type=Path, default=DOCS_DIR / "battle-map-v2-contact-sheet.jpg")
|
|
parser.add_argument("--manifest", type=Path, default=DOCS_DIR / "battle-map-v2-manifest.json")
|
|
parser.add_argument("--skip-artifacts", action="store_true", help="Skip manifest/contact sheet generation during a targeted preview.")
|
|
return parser.parse_args()
|
|
|
|
|
|
def parse_number_set(value: str) -> set[int]:
|
|
result: set[int] = set()
|
|
for part in value.split(","):
|
|
token = part.strip()
|
|
if not token:
|
|
continue
|
|
if "-" in token:
|
|
start_text, end_text = token.split("-", 1)
|
|
start, end = int(start_text), int(end_text)
|
|
result.update(range(min(start, end), max(start, end) + 1))
|
|
else:
|
|
result.add(int(token))
|
|
return result
|
|
|
|
|
|
def load_sources() -> dict[str, Image.Image]:
|
|
missing = [str(path) for path in SOURCE_PATHS.values() if not path.is_file()]
|
|
if missing:
|
|
raise FileNotFoundError(f"Missing V2 terrain sources: {', '.join(missing)}")
|
|
return {kind: Image.open(path).convert("RGB") for kind, path in SOURCE_PATHS.items()}
|
|
|
|
|
|
def output_tile_size(columns: int, rows: int) -> int:
|
|
longest = max(columns, rows)
|
|
if longest <= 80:
|
|
tile = 96
|
|
elif longest <= 90:
|
|
tile = 88
|
|
elif longest <= 100:
|
|
tile = 80
|
|
elif longest <= 112:
|
|
tile = 72
|
|
elif longest <= 128:
|
|
tile = 64
|
|
else:
|
|
tile = 60
|
|
if longest * tile > MAX_TEXTURE_EDGE:
|
|
tile = MAX_TEXTURE_EDGE // longest
|
|
return tile
|
|
|
|
|
|
def feathered_patch_mask(side: int, alpha: int = 236) -> Image.Image:
|
|
fade = max(24, side // 7)
|
|
mask = Image.new("L", (side, side), 0)
|
|
draw = ImageDraw.Draw(mask)
|
|
inset = max(4, fade // 3)
|
|
draw.rounded_rectangle(
|
|
(inset, inset, side - inset, side - inset),
|
|
radius=max(16, fade),
|
|
fill=alpha,
|
|
)
|
|
return mask.filter(ImageFilter.GaussianBlur(max(8, fade // 2)))
|
|
|
|
|
|
def fill_texture(source: Image.Image, size: tuple[int, int], tile: int, kind: str, seed: int) -> Image.Image:
|
|
"""Synthesize a full material without visible square tiling.
|
|
|
|
A low-frequency color field supplies continuity while overlapping, feathered
|
|
source crops restore game-scale detail. This avoids stamping the source's
|
|
macro composition across large late-game maps.
|
|
"""
|
|
|
|
rng = random.Random(seed)
|
|
width, height = size
|
|
average_sample = source.resize((1, 1), Image.Resampling.BOX)
|
|
average = average_sample.getpixel((0, 0))
|
|
average_sample.close()
|
|
dark = tuple(max(0, channel - (31 if kind == "river" else 26)) for channel in average)
|
|
light = tuple(min(255, channel + (23 if kind == "river" else 29)) for channel in average)
|
|
noise_side = 768
|
|
noise_rng = random.Random(seed ^ 0x5F3759DF)
|
|
noise = Image.frombytes("L", (noise_side, noise_side), noise_rng.randbytes(noise_side * noise_side))
|
|
noise = noise.filter(ImageFilter.GaussianBlur(2.2 if kind == "river" else 3.5))
|
|
noise = noise.resize(size, Image.Resampling.BICUBIC)
|
|
result = ImageOps.colorize(noise, dark, light)
|
|
noise.close()
|
|
|
|
patch_side = min(1152, max(512, tile * (17 if kind == "river" else 14)))
|
|
stride = max(192, round(patch_side * 0.58))
|
|
variants: list[Image.Image] = []
|
|
for index in range(18):
|
|
crop_side = round(source.width * rng.uniform(0.46, 0.82))
|
|
left = rng.randint(0, max(0, source.width - crop_side))
|
|
top = rng.randint(0, max(0, source.height - crop_side))
|
|
crop = source.crop((left, top, left + crop_side, top + crop_side))
|
|
variant = ImageOps.fit(crop, (patch_side, patch_side), method=Image.Resampling.LANCZOS)
|
|
crop.close()
|
|
if index % 4 == 1:
|
|
variant = ImageOps.mirror(variant)
|
|
elif index % 4 == 2:
|
|
variant = variant.transpose(Image.Transpose.ROTATE_180)
|
|
elif index % 4 == 3 and kind != "river":
|
|
variant = ImageOps.flip(variant)
|
|
if kind == "river":
|
|
variant = ImageEnhance.Brightness(variant).enhance(rng.uniform(0.95, 1.05))
|
|
variant = ImageEnhance.Color(variant).enhance(rng.uniform(0.95, 1.04))
|
|
else:
|
|
variant = ImageEnhance.Brightness(variant).enhance(rng.uniform(0.985, 1.018))
|
|
variant = ImageEnhance.Color(variant).enhance(rng.uniform(0.98, 1.02))
|
|
variants.append(variant)
|
|
|
|
patch_mask = feathered_patch_mask(patch_side, 228 if kind == "river" else 148)
|
|
row = 0
|
|
for base_y in range(-patch_side // 2, height + patch_side // 2, stride):
|
|
offset = stride // 2 if row % 2 else 0
|
|
for base_x in range(-patch_side // 2 - offset, width + patch_side // 2, stride):
|
|
jitter = max(12, stride // 7)
|
|
x = base_x + rng.randint(-jitter, jitter)
|
|
y = base_y + rng.randint(-jitter, jitter)
|
|
result.paste(variants[rng.randrange(len(variants))], (x, y), patch_mask)
|
|
row += 1
|
|
|
|
patch_mask.close()
|
|
for variant in variants:
|
|
variant.close()
|
|
return result
|
|
|
|
|
|
def is_kind(terrain: list[list[str]], x: int, y: int, kind: str) -> bool:
|
|
return 0 <= y < len(terrain) and 0 <= x < len(terrain[0]) and terrain[y][x] == kind
|
|
|
|
|
|
def region_mask(terrain: list[list[str]], kind: str, tile: int, seed: int) -> Image.Image:
|
|
width = len(terrain[0]) * tile
|
|
height = len(terrain) * tile
|
|
rng = random.Random(seed * 97 + sum(ord(char) for char in kind))
|
|
mask = Image.new("L", (width, height), 0)
|
|
draw = ImageDraw.Draw(mask)
|
|
for y, row in enumerate(terrain):
|
|
for x, terrain_kind in enumerate(row):
|
|
if terrain_kind != kind:
|
|
continue
|
|
jitter = tile // (14 if kind == "river" else (10 if kind == "cliff" else 7))
|
|
cx = x * tile + tile // 2 + rng.randint(-jitter, jitter)
|
|
cy = y * tile + tile // 2 + rng.randint(-jitter, jitter)
|
|
if kind == "river":
|
|
radius_min, radius_max = (0.63, 0.74)
|
|
elif kind == "cliff":
|
|
radius_min, radius_max = (0.51, 0.63)
|
|
else:
|
|
radius_min, radius_max = (0.49, 0.67)
|
|
rx = round(tile * rng.uniform(radius_min, radius_max))
|
|
ry = round(tile * rng.uniform(radius_min, radius_max))
|
|
draw.ellipse((cx - rx, cy - ry, cx + rx, cy + ry), fill=255)
|
|
core = max(4, round(tile * 0.22))
|
|
center_x = x * tile + tile // 2
|
|
center_y = y * tile + tile // 2
|
|
draw.ellipse((center_x - core, center_y - core, center_x + core, center_y + core), fill=255)
|
|
|
|
for dx, dy in ((1, 0), (0, 1)):
|
|
if not is_kind(terrain, x + dx, y + dy, kind):
|
|
continue
|
|
ncx = (x + dx) * tile + tile // 2
|
|
ncy = (y + dy) * tile + tile // 2
|
|
if kind == "river":
|
|
half_width = round(tile * rng.uniform(0.57, 0.68))
|
|
else:
|
|
half_width = round(tile * rng.uniform(0.40, 0.57))
|
|
if dx:
|
|
draw.polygon(
|
|
[
|
|
(cx, cy - half_width),
|
|
(ncx, ncy - half_width + rng.randint(-tile // 14, tile // 14)),
|
|
(ncx, ncy + half_width + rng.randint(-tile // 14, tile // 14)),
|
|
(cx, cy + half_width),
|
|
],
|
|
fill=255,
|
|
)
|
|
else:
|
|
draw.polygon(
|
|
[
|
|
(cx - half_width, cy),
|
|
(ncx - half_width + rng.randint(-tile // 14, tile // 14), ncy),
|
|
(ncx + half_width + rng.randint(-tile // 14, tile // 14), ncy),
|
|
(cx + half_width, cy),
|
|
],
|
|
fill=255,
|
|
)
|
|
|
|
blur = max(1.0, tile * (0.035 if kind == "river" else 0.045))
|
|
return mask.filter(ImageFilter.GaussianBlur(blur))
|
|
|
|
|
|
def terrain_components(terrain: list[list[str]], kind: str) -> list[list[tuple[int, int]]]:
|
|
cells = {(x, y) for y, row in enumerate(terrain) for x, value in enumerate(row) if value == kind}
|
|
components: list[list[tuple[int, int]]] = []
|
|
while cells:
|
|
start = cells.pop()
|
|
component = [start]
|
|
stack = [start]
|
|
while stack:
|
|
x, y = stack.pop()
|
|
for dx, dy in ((1, 0), (-1, 0), (0, 1), (0, -1)):
|
|
neighbor = (x + dx, y + dy)
|
|
if neighbor in cells:
|
|
cells.remove(neighbor)
|
|
component.append(neighbor)
|
|
stack.append(neighbor)
|
|
components.append(component)
|
|
return components
|
|
|
|
|
|
def component_grid_distances(component: list[tuple[int, int]]) -> dict[tuple[int, int], int]:
|
|
"""Return a cheap Manhattan distance-to-bank field on terrain cells."""
|
|
|
|
cells = set(component)
|
|
distances: dict[tuple[int, int], int] = {}
|
|
pending: deque[tuple[int, int]] = deque()
|
|
for x, y in cells:
|
|
if any((x + dx, y + dy) not in cells for dx, dy in GRID_CARDINAL_NEIGHBORS):
|
|
distances[(x, y)] = 1
|
|
pending.append((x, y))
|
|
|
|
while pending:
|
|
x, y = pending.popleft()
|
|
next_distance = distances[(x, y)] + 1
|
|
for dx, dy in GRID_CARDINAL_NEIGHBORS:
|
|
neighbor = (x + dx, y + dy)
|
|
if neighbor in cells and neighbor not in distances:
|
|
distances[neighbor] = next_distance
|
|
pending.append(neighbor)
|
|
return distances
|
|
|
|
|
|
def thin_grid_component(component: list[tuple[int, int]]) -> set[tuple[int, int]]:
|
|
"""Topology-preserving Zhang-Suen thinning on the small terrain grid."""
|
|
|
|
skeleton = set(component)
|
|
changed = True
|
|
while changed:
|
|
changed = False
|
|
for phase in (0, 1):
|
|
removed: list[tuple[int, int]] = []
|
|
for x, y in skeleton:
|
|
neighbors = [(x + dx, y + dy) in skeleton for dx, dy in GRID_EIGHT_NEIGHBORS]
|
|
neighbor_count = sum(neighbors)
|
|
transitions = sum(
|
|
not neighbors[index] and neighbors[(index + 1) % 8]
|
|
for index in range(8)
|
|
)
|
|
if not 2 <= neighbor_count <= 6 or transitions != 1:
|
|
continue
|
|
|
|
north, _, east, _, south, _, west, _ = neighbors
|
|
if phase == 0:
|
|
if (north and east and south) or (east and south and west):
|
|
continue
|
|
elif (north and east and west) or (north and south and west):
|
|
continue
|
|
removed.append((x, y))
|
|
|
|
if removed:
|
|
skeleton.difference_update(removed)
|
|
changed = True
|
|
return skeleton
|
|
|
|
|
|
def skeleton_adjacency(
|
|
skeleton: set[tuple[int, int]],
|
|
) -> dict[tuple[int, int], tuple[tuple[int, int], ...]]:
|
|
return {
|
|
point: tuple(
|
|
sorted(
|
|
(point[0] + dx, point[1] + dy)
|
|
for dx, dy in GRID_EIGHT_NEIGHBORS
|
|
if (point[0] + dx, point[1] + dy) in skeleton
|
|
)
|
|
)
|
|
for point in skeleton
|
|
}
|
|
|
|
|
|
def skeleton_junction_clusters(
|
|
adjacency: dict[tuple[int, int], tuple[tuple[int, int], ...]],
|
|
) -> list[set[tuple[int, int]]]:
|
|
remaining = {point for point, neighbors in adjacency.items() if len(neighbors) >= 3}
|
|
clusters: list[set[tuple[int, int]]] = []
|
|
while remaining:
|
|
start = remaining.pop()
|
|
cluster = {start}
|
|
pending = [start]
|
|
while pending:
|
|
x, y = pending.pop()
|
|
for dx, dy in GRID_EIGHT_NEIGHBORS:
|
|
neighbor = (x + dx, y + dy)
|
|
if neighbor in remaining:
|
|
remaining.remove(neighbor)
|
|
cluster.add(neighbor)
|
|
pending.append(neighbor)
|
|
clusters.append(cluster)
|
|
return clusters
|
|
|
|
|
|
def extend_skeleton_endpoint(
|
|
path: list[tuple[int, int]],
|
|
component_cells: set[tuple[int, int]],
|
|
from_start: bool,
|
|
) -> tuple[float, float]:
|
|
endpoint = path[0] if from_start else path[-1]
|
|
inner_index = min(3, len(path) - 1)
|
|
inner = path[inner_index] if from_start else path[-inner_index - 1]
|
|
dx = (endpoint[0] > inner[0]) - (endpoint[0] < inner[0])
|
|
dy = (endpoint[1] > inner[1]) - (endpoint[1] < inner[1])
|
|
if dx == 0 and dy == 0 and len(path) >= 2:
|
|
adjacent = path[1] if from_start else path[-2]
|
|
dx = (endpoint[0] > adjacent[0]) - (endpoint[0] < adjacent[0])
|
|
dy = (endpoint[1] > adjacent[1]) - (endpoint[1] < adjacent[1])
|
|
|
|
current = endpoint
|
|
while dx or dy:
|
|
candidate = (current[0] + dx, current[1] + dy)
|
|
if candidate not in component_cells:
|
|
break
|
|
current = candidate
|
|
return current[0] + 0.5 + dx * 0.5, current[1] + 0.5 + dy * 0.5
|
|
|
|
|
|
def trace_skeleton_chains(
|
|
skeleton: set[tuple[int, int]],
|
|
component: list[tuple[int, int]],
|
|
) -> tuple[
|
|
list[tuple[list[tuple[float, float]], bool, bool, bool]],
|
|
list[tuple[float, float]],
|
|
int,
|
|
]:
|
|
"""Contract junction pixels and return endpoint-to-junction skeleton chains."""
|
|
|
|
adjacency = skeleton_adjacency(skeleton)
|
|
endpoint_cells = {point for point, neighbors in adjacency.items() if len(neighbors) <= 1}
|
|
junction_clusters = skeleton_junction_clusters(adjacency)
|
|
junction_index: dict[tuple[int, int], int] = {}
|
|
junction_points: list[tuple[float, float]] = []
|
|
for index, cluster in enumerate(junction_clusters):
|
|
for point in cluster:
|
|
junction_index[point] = index
|
|
junction_points.append(
|
|
(
|
|
sum(x + 0.5 for x, _ in cluster) / len(cluster),
|
|
sum(y + 0.5 for _, y in cluster) / len(cluster),
|
|
)
|
|
)
|
|
|
|
critical = endpoint_cells | set(junction_index)
|
|
visited_edges: set[tuple[tuple[int, int], tuple[int, int]]] = set()
|
|
component_cells = set(component)
|
|
chains: list[tuple[list[tuple[float, float]], bool, bool, bool]] = []
|
|
|
|
def edge_key(
|
|
first: tuple[int, int], second: tuple[int, int]
|
|
) -> tuple[tuple[int, int], tuple[int, int]]:
|
|
return (first, second) if first <= second else (second, first)
|
|
|
|
for start in sorted(critical):
|
|
for neighbor in adjacency[start]:
|
|
first_edge = edge_key(start, neighbor)
|
|
if first_edge in visited_edges:
|
|
continue
|
|
visited_edges.add(first_edge)
|
|
path = [start, neighbor]
|
|
previous, current = start, neighbor
|
|
while current not in critical:
|
|
candidates = [candidate for candidate in adjacency[current] if candidate != previous]
|
|
if not candidates:
|
|
break
|
|
following = candidates[0]
|
|
next_edge = edge_key(current, following)
|
|
if next_edge in visited_edges:
|
|
break
|
|
visited_edges.add(next_edge)
|
|
path.append(following)
|
|
previous, current = current, following
|
|
|
|
start_junction = junction_index.get(path[0])
|
|
end_junction = junction_index.get(path[-1])
|
|
if start_junction is not None and start_junction == end_junction and len(path) <= 2:
|
|
continue
|
|
|
|
points = [(x + 0.5, y + 0.5) for x, y in path]
|
|
if start_junction is not None:
|
|
points[0] = junction_points[start_junction]
|
|
if end_junction is not None:
|
|
points[-1] = junction_points[end_junction]
|
|
start_is_endpoint = path[0] in endpoint_cells
|
|
end_is_endpoint = path[-1] in endpoint_cells
|
|
if start_is_endpoint:
|
|
points[0] = extend_skeleton_endpoint(path, component_cells, True)
|
|
if end_is_endpoint:
|
|
points[-1] = extend_skeleton_endpoint(path, component_cells, False)
|
|
if len(points) >= 2 and points[0] != points[-1]:
|
|
chains.append((points, start_is_endpoint, end_is_endpoint, False))
|
|
|
|
# A ring has no critical cell, so trace any remaining degree-two cycle once.
|
|
for start in sorted(skeleton):
|
|
for neighbor in adjacency[start]:
|
|
first_edge = edge_key(start, neighbor)
|
|
if first_edge in visited_edges:
|
|
continue
|
|
visited_edges.add(first_edge)
|
|
path = [start, neighbor]
|
|
previous, current = start, neighbor
|
|
while current != start:
|
|
candidates = [
|
|
candidate
|
|
for candidate in adjacency[current]
|
|
if candidate != previous and edge_key(current, candidate) not in visited_edges
|
|
]
|
|
if not candidates:
|
|
break
|
|
following = candidates[0]
|
|
visited_edges.add(edge_key(current, following))
|
|
path.append(following)
|
|
previous, current = current, following
|
|
closed = path[-1] == start
|
|
grid_points = [(x + 0.5, y + 0.5) for x, y in (path[:-1] if closed else path)]
|
|
if len(grid_points) >= 2:
|
|
chains.append((grid_points, False, False, closed))
|
|
|
|
return chains, junction_points, len(endpoint_cells)
|
|
|
|
|
|
def polyline_tortuosity(points: list[tuple[float, float]]) -> float:
|
|
if len(points) < 2:
|
|
return math.inf
|
|
length = polyline_length(points)
|
|
chord = math.hypot(points[-1][0] - points[0][0], points[-1][1] - points[0][1])
|
|
return length / chord if chord > 1e-6 else math.inf
|
|
|
|
|
|
def polyline_length(points: list[tuple[float, float]]) -> float:
|
|
return sum(
|
|
math.hypot(second[0] - first[0], second[1] - first[1])
|
|
for first, second in zip(points, points[1:])
|
|
)
|
|
|
|
|
|
def skeleton_node_key(point: tuple[float, float]) -> tuple[float, float]:
|
|
return round(point[0], 6), round(point[1], 6)
|
|
|
|
|
|
def skeleton_chain_graph(
|
|
chains: list[tuple[list[tuple[float, float]], bool, bool, bool]],
|
|
) -> tuple[
|
|
dict[tuple[float, float], list[tuple[tuple[float, float], int, float]]],
|
|
dict[tuple[float, float], tuple[float, float]],
|
|
]:
|
|
adjacency: dict[
|
|
tuple[float, float], list[tuple[tuple[float, float], int, float]]
|
|
] = {}
|
|
node_points: dict[tuple[float, float], tuple[float, float]] = {}
|
|
for index, (points, _, _, closed) in enumerate(chains):
|
|
if closed or len(points) < 2:
|
|
continue
|
|
start = skeleton_node_key(points[0])
|
|
end = skeleton_node_key(points[-1])
|
|
if start == end:
|
|
continue
|
|
node_points.setdefault(start, points[0])
|
|
node_points.setdefault(end, points[-1])
|
|
weight = max(1e-6, polyline_length(points))
|
|
adjacency.setdefault(start, []).append((end, index, weight))
|
|
adjacency.setdefault(end, []).append((start, index, weight))
|
|
for edges in adjacency.values():
|
|
edges.sort(key=lambda edge: (edge[0], edge[1]))
|
|
return adjacency, node_points
|
|
|
|
|
|
def weighted_skeleton_paths(
|
|
adjacency: dict[
|
|
tuple[float, float], list[tuple[tuple[float, float], int, float]]
|
|
],
|
|
start: tuple[float, float],
|
|
) -> tuple[
|
|
dict[tuple[float, float], float],
|
|
dict[tuple[float, float], tuple[tuple[float, float], int]],
|
|
]:
|
|
distances = {start: 0.0}
|
|
parents: dict[tuple[float, float], tuple[tuple[float, float], int]] = {}
|
|
pending = [(0.0, start)]
|
|
while pending:
|
|
distance, current = heappop(pending)
|
|
if distance > distances[current] + 1e-9:
|
|
continue
|
|
for neighbor, edge_index, weight in adjacency.get(current, []):
|
|
candidate = distance + weight
|
|
if candidate + 1e-9 >= distances.get(neighbor, math.inf):
|
|
continue
|
|
distances[neighbor] = candidate
|
|
parents[neighbor] = current, edge_index
|
|
heappush(pending, (candidate, neighbor))
|
|
return distances, parents
|
|
|
|
|
|
def skeleton_path_to(
|
|
parents: dict[tuple[float, float], tuple[tuple[float, float], int]],
|
|
start: tuple[float, float],
|
|
end: tuple[float, float],
|
|
) -> tuple[list[tuple[float, float]], list[int]]:
|
|
nodes = [end]
|
|
edges: list[int] = []
|
|
current = end
|
|
while current != start:
|
|
parent = parents.get(current)
|
|
if parent is None:
|
|
return [], []
|
|
current, edge_index = parent
|
|
nodes.append(current)
|
|
edges.append(edge_index)
|
|
nodes.reverse()
|
|
edges.reverse()
|
|
return nodes, edges
|
|
|
|
|
|
def active_skeleton_chains(
|
|
chains: list[tuple[list[tuple[float, float]], bool, bool, bool]],
|
|
kept_indices: set[int] | None = None,
|
|
) -> tuple[
|
|
list[tuple[list[tuple[float, float]], bool, bool, bool]],
|
|
list[tuple[float, float]],
|
|
int,
|
|
]:
|
|
kept = set(range(len(chains))) if kept_indices is None else kept_indices
|
|
adjacency, node_points = skeleton_chain_graph(
|
|
[chain for index, chain in enumerate(chains) if index in kept]
|
|
)
|
|
degrees = {node: len(edges) for node, edges in adjacency.items()}
|
|
active_chains: list[tuple[list[tuple[float, float]], bool, bool, bool]] = []
|
|
for index, (points, _, _, closed) in enumerate(chains):
|
|
if index not in kept:
|
|
continue
|
|
if closed:
|
|
active_chains.append((points, False, False, True))
|
|
continue
|
|
start = skeleton_node_key(points[0])
|
|
end = skeleton_node_key(points[-1])
|
|
active_chains.append((points, degrees.get(start) == 1, degrees.get(end) == 1, False))
|
|
junction_points = sorted(
|
|
(node_points[node] for node, degree in degrees.items() if degree >= 3),
|
|
key=lambda point: (point[1], point[0]),
|
|
)
|
|
endpoint_count = sum(degree == 1 for degree in degrees.values())
|
|
return active_chains, junction_points, endpoint_count
|
|
|
|
|
|
def prune_skeleton_branches(
|
|
chains: list[tuple[list[tuple[float, float]], bool, bool, bool]],
|
|
raw_endpoint_count: int,
|
|
) -> tuple[
|
|
list[tuple[list[tuple[float, float]], bool, bool, bool]],
|
|
list[tuple[float, float]],
|
|
int,
|
|
dict[str, object],
|
|
]:
|
|
"""Keep the widest direct backbone for an over-connected flood grid.
|
|
|
|
Synthetic flood grids can have a very long U-shaped graph diameter. Choosing
|
|
the endpoint pair with the widest straight-line span (then the shorter graph
|
|
path) avoids that artificial U while preserving an edge-to-edge backbone.
|
|
The shortest-path formulation also deterministically resolves the occasional
|
|
one-cycle artifact produced by grid thinning.
|
|
"""
|
|
|
|
adjacency, _ = skeleton_chain_graph(chains)
|
|
endpoints = sorted(node for node, edges in adjacency.items() if len(edges) == 1)
|
|
if raw_endpoint_count <= 4 or len(endpoints) <= 4:
|
|
active_chains, junction_points, endpoint_count = active_skeleton_chains(chains)
|
|
return active_chains, junction_points, endpoint_count, {
|
|
"pruned": False,
|
|
"diameter_weight": None,
|
|
"tributary_weights": [],
|
|
}
|
|
|
|
path_cache = {
|
|
endpoint: weighted_skeleton_paths(adjacency, endpoint)
|
|
for endpoint in endpoints
|
|
}
|
|
diameter_start: tuple[float, float] | None = None
|
|
diameter_end: tuple[float, float] | None = None
|
|
diameter_span = -1.0
|
|
diameter_weight = math.inf
|
|
for start_index, start in enumerate(endpoints):
|
|
distances, _ = path_cache[start]
|
|
for end in endpoints[start_index + 1 :]:
|
|
distance = distances.get(end)
|
|
if distance is None:
|
|
continue
|
|
span = math.hypot(end[0] - start[0], end[1] - start[1])
|
|
pair = (start, end)
|
|
best_pair = (diameter_start, diameter_end)
|
|
if span > diameter_span + 1e-9 or (
|
|
abs(span - diameter_span) <= 1e-9
|
|
and (
|
|
distance < diameter_weight - 1e-9
|
|
or (
|
|
abs(distance - diameter_weight) <= 1e-9
|
|
and (diameter_start is None or pair < best_pair)
|
|
)
|
|
)
|
|
):
|
|
diameter_start, diameter_end = pair
|
|
diameter_span = span
|
|
diameter_weight = distance
|
|
|
|
if diameter_start is None or diameter_end is None:
|
|
active_chains, junction_points, endpoint_count = active_skeleton_chains(chains)
|
|
return active_chains, junction_points, endpoint_count, {
|
|
"pruned": False,
|
|
"diameter_weight": None,
|
|
"tributary_weights": [],
|
|
}
|
|
|
|
_, diameter_parents = path_cache[diameter_start]
|
|
diameter_nodes, diameter_edges = skeleton_path_to(
|
|
diameter_parents,
|
|
diameter_start,
|
|
diameter_end,
|
|
)
|
|
diameter_node_set = set(diameter_nodes)
|
|
kept_indices = set(diameter_edges)
|
|
tributaries: list[tuple[float, tuple[float, float], list[int]]] = []
|
|
for endpoint in endpoints:
|
|
if endpoint in diameter_node_set:
|
|
continue
|
|
distances, parents = path_cache[endpoint]
|
|
reachable = [node for node in diameter_node_set if node in distances]
|
|
if not reachable:
|
|
continue
|
|
attachment = min(reachable, key=lambda node: (distances[node], node))
|
|
_, branch_edges = skeleton_path_to(parents, endpoint, attachment)
|
|
if branch_edges:
|
|
tributaries.append((distances[attachment], endpoint, branch_edges))
|
|
|
|
tributaries.sort(key=lambda branch: (-branch[0], branch[1], tuple(branch[2])))
|
|
# Extra terminal branches in the synthetic 40-43 flood grids still read as
|
|
# a comb/U-shaped canal after cycle pruning. The direct backbone already
|
|
# spans the encounter, so omit optional tributaries from the visual water.
|
|
selected_tributaries = []
|
|
for _, _, branch_edges in selected_tributaries:
|
|
kept_indices.update(branch_edges)
|
|
|
|
active_chains, junction_points, endpoint_count = active_skeleton_chains(
|
|
chains,
|
|
kept_indices,
|
|
)
|
|
return active_chains, junction_points, endpoint_count, {
|
|
"pruned": len(kept_indices) < len(chains),
|
|
"diameter_weight": diameter_weight,
|
|
"tributary_weights": [weight for weight, _, _ in selected_tributaries],
|
|
}
|
|
|
|
|
|
def sparse_branched_river_layout(
|
|
component: list[tuple[int, int]],
|
|
component_width: int,
|
|
component_height: int,
|
|
thick_floodplain: bool,
|
|
) -> dict[str, object] | None:
|
|
"""Return a medial layout only for sparse, band-like complex components."""
|
|
|
|
if not thick_floodplain:
|
|
return None
|
|
major = max(component_width, component_height)
|
|
minor = min(component_width, component_height)
|
|
density = len(component) / max(1, component_width * component_height)
|
|
if density > 0.55:
|
|
return None
|
|
|
|
distances = component_grid_distances(component)
|
|
max_radius = max(distances.values(), default=0)
|
|
if max_radius > max(6, round(major * 0.14)):
|
|
return None
|
|
|
|
skeleton = thin_grid_component(component)
|
|
raw_chains, raw_junction_points, raw_endpoint_count = trace_skeleton_chains(
|
|
skeleton,
|
|
component,
|
|
)
|
|
aspect = major / max(1, minor)
|
|
raw_open_chains = [points for points, _, _, closed in raw_chains if not closed]
|
|
raw_tortuosity = max(
|
|
(polyline_tortuosity(points) for points in raw_open_chains),
|
|
default=math.inf,
|
|
)
|
|
complex_topology = (
|
|
aspect < 1.45
|
|
or raw_endpoint_count != 2
|
|
or bool(raw_junction_points)
|
|
or len(raw_chains) != 1
|
|
or raw_tortuosity > 1.22
|
|
)
|
|
if not complex_topology:
|
|
return None
|
|
chains, junction_points, endpoint_count, pruning = prune_skeleton_branches(
|
|
raw_chains,
|
|
raw_endpoint_count,
|
|
)
|
|
open_chains = [points for points, _, _, closed in chains if not closed]
|
|
tortuosity = max((polyline_tortuosity(points) for points in open_chains), default=math.inf)
|
|
return {
|
|
"chains": chains,
|
|
"density": density,
|
|
"distances": distances,
|
|
"endpoint_count": endpoint_count,
|
|
"junction_points": junction_points,
|
|
"max_radius": max_radius,
|
|
"raw_chain_count": len(raw_chains),
|
|
"raw_endpoint_count": raw_endpoint_count,
|
|
"raw_junction_count": len(raw_junction_points),
|
|
"skeleton": skeleton,
|
|
"tortuosity": tortuosity,
|
|
**pruning,
|
|
}
|
|
|
|
|
|
def chaikin_open(points: list[tuple[float, float]], rounds: int = 2) -> list[tuple[float, float]]:
|
|
result = points
|
|
for _ in range(rounds):
|
|
if len(result) < 3:
|
|
break
|
|
smoothed = [result[0]]
|
|
for first, second in zip(result, result[1:]):
|
|
smoothed.extend(
|
|
(
|
|
(first[0] * 0.75 + second[0] * 0.25, first[1] * 0.75 + second[1] * 0.25),
|
|
(first[0] * 0.25 + second[0] * 0.75, first[1] * 0.25 + second[1] * 0.75),
|
|
)
|
|
)
|
|
smoothed.append(result[-1])
|
|
result = smoothed
|
|
return result
|
|
|
|
|
|
def organic_skeleton_chain(
|
|
points: list[tuple[float, float]],
|
|
distances: dict[tuple[int, int], int],
|
|
width_tiles: float,
|
|
rng: random.Random,
|
|
) -> list[tuple[float, float]]:
|
|
anchors = [points[0]]
|
|
anchors.extend(points[index] for index in range(3, len(points) - 1, 3))
|
|
if points[-1] != anchors[-1]:
|
|
anchors.append(points[-1])
|
|
if len(anchors) <= 2:
|
|
return anchors
|
|
|
|
organic = [anchors[0]]
|
|
wander = 0.0
|
|
for index in range(1, len(anchors) - 1):
|
|
previous, point, following = anchors[index - 1], anchors[index], anchors[index + 1]
|
|
tangent_x = following[0] - previous[0]
|
|
tangent_y = following[1] - previous[1]
|
|
tangent_length = max(1e-6, math.hypot(tangent_x, tangent_y))
|
|
normal_x, normal_y = -tangent_y / tangent_length, tangent_x / tangent_length
|
|
cell = (math.floor(point[0]), math.floor(point[1]))
|
|
clearance = max(0.0, distances.get(cell, 1) - width_tiles / 2 - 0.5)
|
|
# Broad synthetic bands need a visible low-frequency shoreline drift;
|
|
# sub-tile noise alone still reads as a ruler-straight L/T junction at
|
|
# overview scale. Clearance keeps the curve safely inside gameplay's
|
|
# logical river cells before the final mask clip.
|
|
limit = min(1.35, clearance * 0.70)
|
|
wander = max(-limit, min(limit, wander * 0.72 + rng.uniform(-0.70, 0.70)))
|
|
organic.append((point[0] + normal_x * wander, point[1] + normal_y * wander))
|
|
organic.append(anchors[-1])
|
|
return chaikin_open(organic)
|
|
|
|
|
|
def inset_polyline_endpoint(
|
|
points: list[tuple[float, float]],
|
|
distance: float,
|
|
from_start: bool,
|
|
) -> tuple[float, float]:
|
|
if len(points) < 2 or distance <= 0:
|
|
return points[0] if from_start else points[-1]
|
|
total_length = polyline_length(points)
|
|
remaining = min(distance, total_length * 0.45)
|
|
ordered = points if from_start else list(reversed(points))
|
|
for first, second in zip(ordered, ordered[1:]):
|
|
segment_length = math.hypot(second[0] - first[0], second[1] - first[1])
|
|
if segment_length <= 1e-9:
|
|
continue
|
|
if remaining <= segment_length:
|
|
fraction = remaining / segment_length
|
|
return (
|
|
first[0] + (second[0] - first[0]) * fraction,
|
|
first[1] + (second[1] - first[1]) * fraction,
|
|
)
|
|
remaining -= segment_length
|
|
return ordered[-1]
|
|
|
|
|
|
def endpoint_touches_map_edge(
|
|
point: tuple[float, float],
|
|
grid_width: int,
|
|
grid_height: int,
|
|
) -> bool:
|
|
epsilon = 1e-6
|
|
return (
|
|
point[0] <= epsilon
|
|
or point[0] >= grid_width - epsilon
|
|
or point[1] <= epsilon
|
|
or point[1] >= grid_height - epsilon
|
|
)
|
|
|
|
|
|
def draw_medial_river_component(
|
|
channel_draw: ImageDraw.ImageDraw,
|
|
component: list[tuple[int, int]],
|
|
layout: dict[str, object],
|
|
tile: int,
|
|
seed: int,
|
|
grid_width: int,
|
|
grid_height: int,
|
|
) -> None:
|
|
skeleton = layout["skeleton"]
|
|
distances = layout["distances"]
|
|
chains = layout["chains"]
|
|
junction_points = layout["junction_points"]
|
|
assert isinstance(skeleton, set)
|
|
assert isinstance(distances, dict)
|
|
assert isinstance(chains, list)
|
|
assert isinstance(junction_points, list)
|
|
|
|
radii = sorted(distances[point] for point in skeleton)
|
|
middle = len(radii) // 2
|
|
median_radius = (
|
|
radii[middle]
|
|
if len(radii) % 2
|
|
else (radii[middle - 1] + radii[middle]) / 2
|
|
)
|
|
width_tiles = max(2.0, min(2.8, median_radius * 2 * 0.34))
|
|
channel_width = round(width_tiles * tile)
|
|
component_salt = len(component) * 0x45D9F3B
|
|
for x, y in component:
|
|
component_salt ^= coordinate_hash(x, y, 0, 0xA24BAED5)
|
|
min_x = min(x for x, _ in component)
|
|
min_y = min(y for _, y in component)
|
|
rng = random.Random(coordinate_hash(min_x, min_y, seed, component_salt))
|
|
|
|
radius = channel_width / 2
|
|
for points, start_is_endpoint, end_is_endpoint, closed in chains:
|
|
if closed:
|
|
organic = points[::3] or points
|
|
if organic[-1] != organic[0]:
|
|
organic = organic + [organic[0]]
|
|
else:
|
|
organic = organic_skeleton_chain(points, distances, width_tiles, rng)
|
|
original = organic
|
|
organic = list(organic)
|
|
# Leave enough logical-bank clearance for the full round cap.
|
|
# Narrow five-cell flood bands otherwise clip the cap back into a
|
|
# flat plateau even though wider late-game channels look correct.
|
|
endpoint_inset = width_tiles * 0.72
|
|
if start_is_endpoint and not endpoint_touches_map_edge(
|
|
points[0],
|
|
grid_width,
|
|
grid_height,
|
|
):
|
|
organic[0] = inset_polyline_endpoint(original, endpoint_inset, True)
|
|
if end_is_endpoint and not endpoint_touches_map_edge(
|
|
points[-1],
|
|
grid_width,
|
|
grid_height,
|
|
):
|
|
organic[-1] = inset_polyline_endpoint(original, endpoint_inset, False)
|
|
pixel_points = [(round(x * tile), round(y * tile)) for x, y in organic]
|
|
if len(pixel_points) < 2:
|
|
continue
|
|
channel_draw.line(pixel_points, fill=255, width=channel_width, joint="curve")
|
|
if start_is_endpoint:
|
|
x, y = pixel_points[0]
|
|
channel_draw.ellipse((x - radius, y - radius, x + radius, y + radius), fill=255)
|
|
if end_is_endpoint:
|
|
x, y = pixel_points[-1]
|
|
channel_draw.ellipse((x - radius, y - radius, x + radius, y + radius), fill=255)
|
|
|
|
for x, y in junction_points:
|
|
pixel_x, pixel_y = round(x * tile), round(y * tile)
|
|
channel_draw.ellipse(
|
|
(pixel_x - radius, pixel_y - radius, pixel_x + radius, pixel_y + radius),
|
|
fill=255,
|
|
)
|
|
|
|
|
|
def river_masks(terrain: list[list[str]], tile: int, seed: int) -> tuple[Image.Image, Image.Image]:
|
|
"""Return logical floodplain and visual water masks.
|
|
|
|
Normal one- or two-cell rivers keep their full footprint. Very thick
|
|
late-game river blocks become either a simple axial channel or a medial
|
|
skeleton that preserves sparse L/T/U-shaped branch topology.
|
|
"""
|
|
|
|
logical = region_mask(terrain, "river", tile, seed)
|
|
visual = logical.copy()
|
|
channels = Image.new("L", logical.size, 0)
|
|
channel_draw = ImageDraw.Draw(channels)
|
|
visual_draw = ImageDraw.Draw(visual)
|
|
rng = random.Random(seed * 227)
|
|
|
|
for component in terrain_components(terrain, "river"):
|
|
xs = [x for x, _ in component]
|
|
ys = [y for _, y in component]
|
|
min_x, max_x = min(xs), max(xs)
|
|
min_y, max_y = min(ys), max(ys)
|
|
component_width = max_x - min_x + 1
|
|
component_height = max_y - min_y + 1
|
|
major = max(component_width, component_height)
|
|
minor = min(component_width, component_height)
|
|
aspect = major / max(1, minor)
|
|
thick_floodplain = minor >= 5 and len(component) >= major * 3
|
|
elongated_channel = aspect >= 1.45 and major >= 4
|
|
if not thick_floodplain and not elongated_channel:
|
|
continue
|
|
|
|
medial_layout = sparse_branched_river_layout(
|
|
component,
|
|
component_width,
|
|
component_height,
|
|
thick_floodplain,
|
|
)
|
|
component_density = len(component) / max(1, component_width * component_height)
|
|
compact_lake = aspect < 1.45 and medial_layout is None and component_density >= 0.55
|
|
# Sparse band networks use the topology-preserving medial layout. A
|
|
# genuinely compact near-square water body can still read as a lake;
|
|
# irregular sparse shapes must never fall back to the bbox ellipse.
|
|
if aspect < 1.45 and medial_layout is None and not compact_lake:
|
|
continue
|
|
|
|
clear_padding = max(4, tile // 3)
|
|
for x, y in component:
|
|
visual_draw.rectangle(
|
|
(
|
|
x * tile - clear_padding,
|
|
y * tile - clear_padding,
|
|
(x + 1) * tile + clear_padding,
|
|
(y + 1) * tile + clear_padding,
|
|
),
|
|
fill=0,
|
|
)
|
|
|
|
if medial_layout is not None:
|
|
draw_medial_river_component(
|
|
channel_draw,
|
|
component,
|
|
medial_layout,
|
|
tile,
|
|
seed,
|
|
len(terrain[0]),
|
|
len(terrain),
|
|
)
|
|
continue
|
|
|
|
if compact_lake:
|
|
center_x = (min_x + max_x + 1) * tile / 2
|
|
center_y = (min_y + max_y + 1) * tile / 2
|
|
radius_x = component_width * tile * rng.uniform(0.25, 0.37)
|
|
radius_y = component_height * tile * rng.uniform(0.25, 0.37)
|
|
channel_draw.ellipse(
|
|
(center_x - radius_x, center_y - radius_y, center_x + radius_x, center_y + radius_y),
|
|
fill=255,
|
|
)
|
|
continue
|
|
|
|
horizontal = component_width >= component_height
|
|
cells_by_axis: dict[int, list[int]] = {}
|
|
for x, y in component:
|
|
axis, cross = (x, y) if horizontal else (y, x)
|
|
cells_by_axis.setdefault(axis, []).append(cross)
|
|
start_axis = min(cells_by_axis)
|
|
end_axis = max(cells_by_axis)
|
|
points: list[tuple[int, int]] = []
|
|
wander = 0.0
|
|
axis_values = list(range(start_axis, end_axis + 1, 3))
|
|
if not axis_values or axis_values[-1] != end_axis:
|
|
axis_values.append(end_axis)
|
|
for axis in axis_values:
|
|
available = cells_by_axis.get(axis)
|
|
if not available:
|
|
nearest_axis = min(cells_by_axis, key=lambda candidate: abs(candidate - axis))
|
|
available = cells_by_axis[nearest_axis]
|
|
low, high = min(available), max(available)
|
|
center_cross = (low + high + 1) * tile / 2
|
|
span = max(tile, (high - low + 1) * tile)
|
|
wander = wander * 0.62 + rng.uniform(-tile * 0.72, tile * 0.72)
|
|
wander = max(-span * 0.24, min(span * 0.24, wander))
|
|
axis_pixel = axis * tile + tile // 2
|
|
cross_pixel = round(center_cross + wander)
|
|
points.append((axis_pixel, cross_pixel) if horizontal else (cross_pixel, axis_pixel))
|
|
if len(points) == 1:
|
|
points.append((points[0][0] + (tile if horizontal else 0), points[0][1] + (0 if horizontal else tile)))
|
|
if thick_floodplain:
|
|
channel_width = max(round(tile * 1.35), min(round(tile * 2.8), round(minor * tile * 0.28)))
|
|
else:
|
|
channel_width = max(round(tile * 0.88), min(round(tile * 2.25), round(minor * tile * 0.88)))
|
|
|
|
# Normalize axial endpoints to the logical floodplain edge. Internal
|
|
# ends then move roughly one cap radius inward so clipping cannot turn
|
|
# the round cap into a flat wall; map-edge ends stay flush off-map.
|
|
if horizontal:
|
|
points[0] = (start_axis * tile, points[0][1])
|
|
points[-1] = ((end_axis + 1) * tile, points[-1][1])
|
|
start_off_map = start_axis == 0
|
|
end_off_map = end_axis == len(terrain[0]) - 1
|
|
else:
|
|
points[0] = (points[0][0], start_axis * tile)
|
|
points[-1] = (points[-1][0], (end_axis + 1) * tile)
|
|
start_off_map = start_axis == 0
|
|
end_off_map = end_axis == len(terrain) - 1
|
|
original_points = list(points)
|
|
endpoint_inset = channel_width * 0.45
|
|
if not start_off_map:
|
|
points[0] = inset_polyline_endpoint(original_points, endpoint_inset, True)
|
|
if not end_off_map:
|
|
points[-1] = inset_polyline_endpoint(original_points, endpoint_inset, False)
|
|
points = [(round(x), round(y)) for x, y in points]
|
|
channel_draw.line(points, fill=255, width=channel_width, joint="curve")
|
|
cap_radius = channel_width / 2
|
|
for cap_x, cap_y in (points[0], points[-1]):
|
|
channel_draw.ellipse(
|
|
(
|
|
cap_x - cap_radius,
|
|
cap_y - cap_radius,
|
|
cap_x + cap_radius,
|
|
cap_y + cap_radius,
|
|
),
|
|
fill=255,
|
|
)
|
|
|
|
softened_channels = channels.filter(ImageFilter.GaussianBlur(max(2, tile * 0.055)))
|
|
clipped_channels = ImageChops.darker(softened_channels, logical)
|
|
combined = ImageChops.lighter(visual, clipped_channels)
|
|
visual.close()
|
|
channels.close()
|
|
softened_channels.close()
|
|
clipped_channels.close()
|
|
return logical, combined
|
|
|
|
|
|
def selected_road_edges(terrain: list[list[str]], seed: int) -> list[tuple[tuple[int, int], tuple[int, int]]]:
|
|
rows = len(terrain)
|
|
columns = len(terrain[0])
|
|
|
|
def road_degree(x: int, y: int) -> int:
|
|
return sum(
|
|
1
|
|
for dx, dy in ((1, 0), (-1, 0), (0, 1), (0, -1))
|
|
if 0 <= y + dy < rows and 0 <= x + dx < columns and terrain[y + dy][x + dx] == "road"
|
|
)
|
|
|
|
def lane_selected(index: int, salt: int) -> bool:
|
|
value = (index * 1_103_515_245 + seed * 12_345 + salt * 2_654_435_761) & 0xFFFFFFFF
|
|
return value % 100 < 26
|
|
|
|
edges: list[tuple[tuple[int, int], tuple[int, int]]] = []
|
|
for y, row in enumerate(terrain):
|
|
for x, kind in enumerate(row):
|
|
if kind != "road":
|
|
continue
|
|
current_degree = road_degree(x, y)
|
|
for dx, dy in ((1, 0), (0, 1)):
|
|
nx, ny = x + dx, y + dy
|
|
if not (0 <= ny < rows and 0 <= nx < columns):
|
|
continue
|
|
neighbor_kind = terrain[ny][nx]
|
|
if neighbor_kind in STRUCTURE_KINDS:
|
|
edges.append(((x, y), (nx, ny)))
|
|
continue
|
|
if neighbor_kind != "road":
|
|
continue
|
|
neighbor_degree = road_degree(nx, ny)
|
|
narrow_route = current_degree <= 2 or neighbor_degree <= 2
|
|
broad_lane = lane_selected(y, 17) if dx else lane_selected(x, 31)
|
|
edge_variation = ((x * 928_371 + y * 689_287 + seed * 97) % 100) < 12
|
|
if narrow_route or broad_lane or edge_variation:
|
|
edges.append(((x, y), (nx, ny)))
|
|
return edges
|
|
|
|
|
|
def road_paths(terrain: list[list[str]], tile: int, seed: int) -> list[list[tuple[int, int]]]:
|
|
rng = random.Random(seed * 239)
|
|
broad_cells: set[tuple[int, int]] = set()
|
|
paths: list[list[tuple[int, int]]] = []
|
|
|
|
def component_path(
|
|
component: list[tuple[int, int]],
|
|
horizontal: bool,
|
|
fraction: float,
|
|
salt: int,
|
|
) -> list[tuple[int, int]]:
|
|
cells_by_axis: dict[int, list[int]] = {}
|
|
for x, y in component:
|
|
axis, cross = (x, y) if horizontal else (y, x)
|
|
cells_by_axis.setdefault(axis, []).append(cross)
|
|
start_axis, end_axis = min(cells_by_axis), max(cells_by_axis)
|
|
axes = list(range(start_axis, end_axis + 1, 3))
|
|
if axes[-1] != end_axis:
|
|
axes.append(end_axis)
|
|
local_rng = random.Random(seed * 257 + salt)
|
|
points: list[tuple[int, int]] = []
|
|
wander = 0.0
|
|
for axis in axes:
|
|
available = cells_by_axis.get(axis)
|
|
if not available:
|
|
nearest_axis = min(cells_by_axis, key=lambda candidate: abs(candidate - axis))
|
|
available = cells_by_axis[nearest_axis]
|
|
ordered = sorted(available)
|
|
base = ordered[min(len(ordered) - 1, round((len(ordered) - 1) * fraction))]
|
|
wander = wander * 0.58 + local_rng.uniform(-0.55, 0.55)
|
|
cross = max(min(ordered) + 0.25, min(max(ordered) + 0.75, base + 0.5 + wander))
|
|
axis_pixel = axis * tile + tile // 2
|
|
cross_pixel = round(cross * tile)
|
|
points.append((axis_pixel, cross_pixel) if horizontal else (cross_pixel, axis_pixel))
|
|
return points
|
|
|
|
for component_index, component in enumerate(terrain_components(terrain, "road")):
|
|
xs = [x for x, _ in component]
|
|
ys = [y for _, y in component]
|
|
width = max(xs) - min(xs) + 1
|
|
height = max(ys) - min(ys) + 1
|
|
density = len(component) / max(1, width * height)
|
|
broad = len(component) >= 90 and min(width, height) >= 6 and (density >= 0.24 or len(component) >= 500)
|
|
if not broad:
|
|
continue
|
|
broad_cells.update(component)
|
|
if width >= height:
|
|
fractions = (0.28, 0.62, 0.82) if len(component) >= 1200 else (0.34, 0.7)
|
|
for offset, fraction in enumerate(fractions):
|
|
paths.append(component_path(component, True, fraction, component_index * 17 + offset))
|
|
paths.append(component_path(component, False, 0.52, component_index * 17 + 11))
|
|
else:
|
|
fractions = (0.28, 0.62, 0.82) if len(component) >= 1200 else (0.34, 0.7)
|
|
for offset, fraction in enumerate(fractions):
|
|
paths.append(component_path(component, False, fraction, component_index * 17 + offset))
|
|
paths.append(component_path(component, True, 0.52, component_index * 17 + 11))
|
|
|
|
for (x, y), (nx, ny) in selected_road_edges(terrain, seed):
|
|
if (x, y) in broad_cells and (nx, ny) in broad_cells:
|
|
continue
|
|
x1, y1 = x * tile + tile // 2, y * tile + tile // 2
|
|
x2, y2 = nx * tile + tile // 2, ny * tile + tile // 2
|
|
mid = (
|
|
round((x1 + x2) / 2 + rng.randint(-tile // 7, tile // 7)),
|
|
round((y1 + y2) / 2 + rng.randint(-tile // 7, tile // 7)),
|
|
)
|
|
paths.append([(x1, y1), mid, (x2, y2)])
|
|
|
|
return [path for path in paths if len(path) >= 2]
|
|
|
|
|
|
def road_mask(terrain: list[list[str]], tile: int, seed: int) -> Image.Image:
|
|
width = len(terrain[0]) * tile
|
|
height = len(terrain) * tile
|
|
mask = Image.new("L", (width, height), 0)
|
|
draw = ImageDraw.Draw(mask)
|
|
road_width = max(11, round(tile * 0.24))
|
|
for path in road_paths(terrain, tile, seed):
|
|
draw.line(path, fill=255, width=road_width, joint="curve")
|
|
|
|
return mask.filter(ImageFilter.GaussianBlur(max(1.0, tile * 0.055)))
|
|
|
|
|
|
def shifted_mask(mask: Image.Image, dx: int, dy: int) -> Image.Image:
|
|
shifted = Image.new("L", mask.size, 0)
|
|
shifted.paste(mask, (dx, dy))
|
|
return shifted
|
|
|
|
|
|
def composite_texture(canvas: Image.Image, texture: Image.Image, mask: Image.Image) -> Image.Image:
|
|
canvas.paste(texture, (0, 0), mask)
|
|
texture.close()
|
|
return canvas
|
|
|
|
|
|
def apply_tint(canvas: Image.Image, mask: Image.Image, color: tuple[int, int, int], strength: float) -> Image.Image:
|
|
tint_mask = mask.point(lambda value: round(value * strength))
|
|
canvas.paste(color, (0, 0, canvas.width, canvas.height), tint_mask)
|
|
tint_mask.close()
|
|
return canvas
|
|
|
|
|
|
def paste_region_features(
|
|
canvas: Image.Image,
|
|
source: Image.Image,
|
|
terrain: list[list[str]],
|
|
kind: str,
|
|
region: Image.Image,
|
|
tile: int,
|
|
seed: int,
|
|
) -> Image.Image:
|
|
"""Scatter large feathered material crops instead of one crop per cell."""
|
|
|
|
rng = random.Random(seed * 181 + len(kind) * 37)
|
|
cells = [(x, y) for y, row in enumerate(terrain) for x, value in enumerate(row) if value == kind]
|
|
if not cells:
|
|
return canvas
|
|
|
|
density = {
|
|
"forest": (31, 43),
|
|
"hill": (12, 34),
|
|
"cliff": (17, 48),
|
|
}[kind]
|
|
multipliers = {
|
|
"forest": (2.15, 2.65, 3.15),
|
|
"hill": (2.75, 3.35, 3.95),
|
|
"cliff": (2.35, 2.9, 3.45),
|
|
}[kind]
|
|
|
|
variants: list[tuple[Image.Image, Image.Image]] = []
|
|
for multiplier in multipliers:
|
|
side = max(tile, round(tile * multiplier))
|
|
for index in range(6):
|
|
crop_side = round(source.width * rng.uniform(0.36, 0.62))
|
|
left = rng.randint(0, max(0, source.width - crop_side))
|
|
top = rng.randint(0, max(0, source.height - crop_side))
|
|
crop = source.crop((left, top, left + crop_side, top + crop_side))
|
|
feature_mask = None
|
|
if kind == "forest":
|
|
feature_mask = ImageOps.grayscale(crop).point(
|
|
lambda value: max(0, min(255, (82 - value) * 5))
|
|
)
|
|
feature_mask = ImageOps.fit(feature_mask, (side, side), method=Image.Resampling.LANCZOS)
|
|
variant = ImageOps.fit(crop, (side, side), method=Image.Resampling.LANCZOS)
|
|
crop.close()
|
|
if index % 3 == 1:
|
|
variant = ImageOps.mirror(variant)
|
|
if feature_mask is not None:
|
|
feature_mask = ImageOps.mirror(feature_mask)
|
|
elif index % 3 == 2:
|
|
variant = variant.transpose(Image.Transpose.ROTATE_180)
|
|
if feature_mask is not None:
|
|
feature_mask = feature_mask.transpose(Image.Transpose.ROTATE_180)
|
|
variant = ImageEnhance.Brightness(variant).enhance(rng.uniform(0.94, 1.05))
|
|
soft_mask = feathered_patch_mask(side, 242 if kind == "cliff" else 232)
|
|
if feature_mask is not None:
|
|
feature_mask = feature_mask.filter(ImageFilter.GaussianBlur(max(1.5, tile * 0.035)))
|
|
combined_mask = ImageChops.multiply(soft_mask, feature_mask)
|
|
soft_mask.close()
|
|
feature_mask.close()
|
|
soft_mask = combined_mask
|
|
variants.append((variant, soft_mask))
|
|
|
|
candidates: list[tuple[int, int, int]] = []
|
|
for x, y in cells:
|
|
boundary = any(not is_kind(terrain, x + dx, y + dy, kind) for dx, dy in ((1, 0), (-1, 0), (0, 1), (0, -1)))
|
|
threshold = density[1] if boundary else density[0]
|
|
value = coordinate_hash(x, y, seed, 0xA511E9B3)
|
|
if value % 100 < threshold:
|
|
candidates.append((value, x, y))
|
|
|
|
candidates.sort()
|
|
exclusion = {"forest": 1, "hill": 2, "cliff": 2}[kind]
|
|
selected: list[tuple[int, int]] = []
|
|
selected_set: set[tuple[int, int]] = set()
|
|
for _, x, y in candidates:
|
|
if any(
|
|
(x + dx, y + dy) in selected_set
|
|
for dx in range(-exclusion, exclusion + 1)
|
|
for dy in range(-exclusion, exclusion + 1)
|
|
if dx * dx + dy * dy <= exclusion * exclusion
|
|
):
|
|
continue
|
|
selected.append((x, y))
|
|
selected_set.add((x, y))
|
|
if not selected:
|
|
selected.append(cells[len(cells) // 2])
|
|
|
|
max_jitter = max(2, round(tile * 0.34))
|
|
for x, y in selected:
|
|
index = coordinate_hash(x, y, seed, 0x63D83595) % len(variants)
|
|
variant, soft_mask = variants[index]
|
|
side = variant.width
|
|
left = x * tile + (tile - side) // 2 + rng.randint(-max_jitter, max_jitter)
|
|
top = y * tile + (tile - side) // 2 + rng.randint(-max_jitter, max_jitter)
|
|
clip = region.crop((left, top, left + side, top + side))
|
|
bleed_size = min(side - 1, max(5, round(tile * 0.72)) | 1)
|
|
expanded_clip = clip.filter(ImageFilter.MaxFilter(bleed_size))
|
|
combined = ImageChops.multiply(soft_mask, expanded_clip)
|
|
canvas.paste(variant, (left, top), combined)
|
|
clip.close()
|
|
expanded_clip.close()
|
|
combined.close()
|
|
|
|
for variant, soft_mask in variants:
|
|
variant.close()
|
|
soft_mask.close()
|
|
return canvas
|
|
|
|
|
|
def structure_variant_mask(variant: Image.Image, tile: int) -> Image.Image:
|
|
side = variant.width
|
|
blurred = variant.filter(ImageFilter.GaussianBlur(max(5, round(side * 0.085))))
|
|
detail = ImageOps.grayscale(ImageChops.difference(variant, blurred))
|
|
blurred.close()
|
|
detail = detail.point(lambda value: max(0, min(255, (value - 5) * 18)))
|
|
dilation = min(side - 1, max(7, round(side * 0.13)) | 1)
|
|
detail = detail.filter(ImageFilter.MaxFilter(dilation))
|
|
detail = detail.filter(ImageFilter.GaussianBlur(max(2, round(side * 0.035))))
|
|
|
|
radial = Image.new("L", (side, side), 0)
|
|
radial_draw = ImageDraw.Draw(radial)
|
|
inset = max(2, round(tile * 0.08))
|
|
radial_draw.ellipse((inset, inset, side - inset, side - inset), fill=255)
|
|
radial = radial.filter(ImageFilter.GaussianBlur(max(8, round(side * 0.11))))
|
|
ground_halo = radial.point(lambda value: round(value * 0.16))
|
|
foreground = ImageChops.multiply(detail, radial)
|
|
result = ImageChops.lighter(foreground, ground_halo)
|
|
detail.close()
|
|
radial.close()
|
|
ground_halo.close()
|
|
foreground.close()
|
|
return result
|
|
|
|
|
|
def add_region(canvas: Image.Image, sources: dict[str, Image.Image], terrain: list[list[str]], kind: str, tile: int, seed: int) -> Image.Image:
|
|
mask = region_mask(terrain, kind, tile, seed)
|
|
if mask.getbbox() is None:
|
|
mask.close()
|
|
return canvas
|
|
|
|
if kind in {"forest", "hill", "cliff"}:
|
|
shifted = shifted_mask(mask, max(2, tile // 18), max(3, tile // 14))
|
|
shadow_mask = ImageChops.subtract(shifted, mask).filter(ImageFilter.GaussianBlur(max(2, tile // 11)))
|
|
shadow = Image.new("RGB", canvas.size, (24, 25, 18))
|
|
shadow_strength = shadow_mask.point(lambda value: round(value * (0.38 if kind == "cliff" else 0.24)))
|
|
shadowed = Image.composite(shadow, canvas, shadow_strength)
|
|
canvas.close()
|
|
canvas = shadowed
|
|
shadow.close()
|
|
shifted.close()
|
|
shadow_mask.close()
|
|
shadow_strength.close()
|
|
|
|
tint, strength = {
|
|
"forest": ((45, 60, 34), 0.035),
|
|
"hill": ((113, 96, 63), 0.02),
|
|
"cliff": ((111, 101, 79), 0.08),
|
|
}[kind]
|
|
canvas = apply_tint(canvas, mask, tint, strength)
|
|
canvas = paste_region_features(canvas, sources[kind], terrain, kind, mask, tile, seed + 17)
|
|
mask.close()
|
|
return canvas
|
|
|
|
|
|
def add_river(canvas: Image.Image, sources: dict[str, Image.Image], terrain: list[list[str]], tile: int, seed: int) -> Image.Image:
|
|
logical_mask, mask = river_masks(terrain, tile, seed)
|
|
if mask.getbbox() is None:
|
|
logical_mask.close()
|
|
mask.close()
|
|
return canvas
|
|
canvas = apply_tint(canvas, logical_mask, (82, 86, 62), 0.075)
|
|
bank_size = max(5, round(tile * 0.32)) | 1
|
|
expanded = mask.filter(ImageFilter.MaxFilter(bank_size))
|
|
bank = ImageChops.subtract(expanded, mask).filter(ImageFilter.GaussianBlur(max(1, tile // 24)))
|
|
bank_layer = Image.new("RGB", canvas.size, (67, 64, 43))
|
|
bank_strength = bank.point(lambda value: round(value * 0.78))
|
|
banked = Image.composite(bank_layer, canvas, bank_strength)
|
|
canvas.close()
|
|
canvas = banked
|
|
bank_layer.close()
|
|
bank_strength.close()
|
|
texture = fill_texture(sources["river"], canvas.size, tile, "river", seed + 29)
|
|
water_tint = Image.new("RGB", canvas.size, (43, 71, 73))
|
|
tinted_texture = Image.blend(texture, water_tint, 0.22)
|
|
texture.close()
|
|
water_tint.close()
|
|
texture = tinted_texture
|
|
canvas = composite_texture(canvas, texture, mask)
|
|
expanded.close()
|
|
bank.close()
|
|
logical_mask.close()
|
|
mask.close()
|
|
return canvas
|
|
|
|
|
|
def add_roads(canvas: Image.Image, sources: dict[str, Image.Image], terrain: list[list[str]], tile: int, seed: int) -> Image.Image:
|
|
mask = road_mask(terrain, tile, seed)
|
|
if mask.getbbox() is None:
|
|
mask.close()
|
|
return canvas
|
|
shadow_mask = shifted_mask(mask, max(2, tile // 25), max(2, tile // 20)).filter(ImageFilter.GaussianBlur(max(2, tile // 14)))
|
|
shadow_layer = Image.new("RGB", canvas.size, (61, 48, 32))
|
|
shadow_strength = shadow_mask.point(lambda value: round(value * 0.20))
|
|
shadowed = Image.composite(shadow_layer, canvas, shadow_strength)
|
|
canvas.close()
|
|
canvas = shadowed
|
|
shadow_layer.close()
|
|
shadow_mask.close()
|
|
shadow_strength.close()
|
|
|
|
packed_earth = Image.new("RGB", canvas.size, (154, 127, 84))
|
|
packed_route = Image.blend(canvas, packed_earth, 0.38)
|
|
routed = Image.composite(packed_route, canvas, mask)
|
|
canvas.close()
|
|
canvas = routed
|
|
packed_earth.close()
|
|
packed_route.close()
|
|
mask.close()
|
|
return canvas
|
|
|
|
|
|
def add_structures(canvas: Image.Image, sources: dict[str, Image.Image], terrain: list[list[str]], tile: int, seed: int) -> Image.Image:
|
|
rng = random.Random(seed * 149)
|
|
for kind in ("village", "fort", "camp"):
|
|
cells = {(x, y) for y, row in enumerate(terrain) for x, terrain_kind in enumerate(row) if terrain_kind == kind}
|
|
if not cells:
|
|
continue
|
|
|
|
components: list[list[tuple[int, int]]] = []
|
|
remaining = set(cells)
|
|
while remaining:
|
|
start = remaining.pop()
|
|
component = [start]
|
|
stack = [start]
|
|
while stack:
|
|
x, y = stack.pop()
|
|
for dx, dy in ((1, 0), (-1, 0), (0, 1), (0, -1)):
|
|
neighbor = (x + dx, y + dy)
|
|
if neighbor in remaining:
|
|
remaining.remove(neighbor)
|
|
component.append(neighbor)
|
|
stack.append(neighbor)
|
|
components.append(component)
|
|
|
|
selected_cells: set[tuple[int, int]] = set()
|
|
for component in components:
|
|
center_x = sum(x for x, _ in component) / len(component)
|
|
center_y = sum(y for _, y in component) / len(component)
|
|
representative = min(component, key=lambda point: (point[0] - center_x) ** 2 + (point[1] - center_y) ** 2)
|
|
selected_cells.add(representative)
|
|
if len(component) <= 3:
|
|
selected_cells.update(component)
|
|
else:
|
|
target = min(
|
|
{"village": 6, "camp": 6, "fort": 8}[kind],
|
|
max(2, 1 + round(math.sqrt(len(component)) / (2.5 if kind == "fort" else 2.1))),
|
|
)
|
|
minimum_distance = 3.1 if kind == "fort" else 2.35
|
|
candidates = component[:]
|
|
rng.shuffle(candidates)
|
|
component_selected = [representative]
|
|
for candidate in candidates:
|
|
if len(component_selected) >= target:
|
|
break
|
|
if all(
|
|
(candidate[0] - point[0]) ** 2 + (candidate[1] - point[1]) ** 2 >= minimum_distance**2
|
|
for point in component_selected
|
|
):
|
|
component_selected.append(candidate)
|
|
selected_cells.update(component_selected)
|
|
|
|
source = sources[kind]
|
|
half_w, half_h = source.width // 2, source.height // 2
|
|
quadrants = [
|
|
source.crop((0, 0, half_w, half_h)),
|
|
source.crop((half_w, 0, source.width, half_h)),
|
|
source.crop((0, half_h, half_w, source.height)),
|
|
source.crop((half_w, half_h, source.width, source.height)),
|
|
]
|
|
variant_size = max(
|
|
tile,
|
|
round(tile * {"village": 1.9, "camp": 2.0, "fort": 2.35}[kind]),
|
|
)
|
|
variants = [ImageOps.fit(variant, (variant_size, variant_size), method=Image.Resampling.LANCZOS) for variant in quadrants]
|
|
for variant in quadrants:
|
|
variant.close()
|
|
|
|
variant_masks = [structure_variant_mask(variant, tile) for variant in variants]
|
|
|
|
for x, y in sorted(selected_cells, key=lambda point: (point[1], point[0])):
|
|
index = coordinate_hash(x, y, seed, 0xC2B2AE35) % len(variants)
|
|
variant = variants[index]
|
|
left = x * tile + (tile - variant_size) // 2 + rng.randint(-max(1, tile // 24), max(1, tile // 24))
|
|
top = y * tile + (tile - variant_size) // 2 + rng.randint(-max(1, tile // 24), max(1, tile // 24))
|
|
canvas.paste(variant, (left, top), variant_masks[index])
|
|
|
|
for variant in variants:
|
|
variant.close()
|
|
for variant_mask in variant_masks:
|
|
variant_mask.close()
|
|
|
|
return canvas
|
|
|
|
|
|
def add_route_ruts(canvas: Image.Image, terrain: list[list[str]], tile: int, seed: int) -> Image.Image:
|
|
overlay = Image.new("RGBA", canvas.size, (0, 0, 0, 0))
|
|
draw = ImageDraw.Draw(overlay)
|
|
|
|
for path in road_paths(terrain, tile, seed):
|
|
horizontal = abs(path[-1][0] - path[0][0]) >= abs(path[-1][1] - path[0][1])
|
|
normal_x, normal_y = (0, 1) if horizontal else (1, 0)
|
|
offset = max(3, tile // 11)
|
|
for sign in (-1, 1):
|
|
jx = normal_x * offset * sign
|
|
jy = normal_y * offset * sign
|
|
draw.line(
|
|
[(x + jx, y + jy) for x, y in path],
|
|
fill=(70, 52, 34, 58),
|
|
width=max(1, tile // 34),
|
|
joint="curve",
|
|
)
|
|
composited = Image.alpha_composite(canvas.convert("RGBA"), overlay.filter(ImageFilter.GaussianBlur(0.55))).convert("RGB")
|
|
canvas.close()
|
|
canvas = composited
|
|
overlay.close()
|
|
return canvas
|
|
|
|
|
|
def theme_settings(number: int) -> tuple[tuple[int, int, int], float, float, float, float]:
|
|
if number <= 4:
|
|
return (120, 105, 66), 0.035, 0.97, 0.94, 0.98
|
|
if number <= 6:
|
|
return (126, 108, 77), 0.055, 0.96, 0.94, 1.0
|
|
if number <= 15:
|
|
return (74, 96, 76), 0.045, 0.95, 0.93, 0.98
|
|
if number <= 19:
|
|
return (61, 93, 57), 0.06, 0.96, 0.98, 0.98
|
|
if number <= 24:
|
|
return (49, 87, 88), 0.055, 0.95, 0.93, 0.99
|
|
if number <= 37:
|
|
return (125, 100, 69), 0.07, 0.96, 0.9, 1.01
|
|
if number <= 44:
|
|
return (52, 82, 80), 0.065, 0.94, 0.88, 0.98
|
|
if number <= 46:
|
|
return (117, 76, 48), 0.075, 0.93, 0.88, 1.0
|
|
if number <= 54:
|
|
return (57, 87, 50), 0.085, 0.96, 1.03, 1.0
|
|
if number <= 63:
|
|
return (139, 111, 72), 0.08, 0.96, 0.86, 1.01
|
|
return (130, 106, 68), 0.09, 0.96, 0.83, 1.01
|
|
|
|
|
|
def add_event_atmosphere(canvas: Image.Image, terrain: list[list[str]], tile: int, number: int, seed: int) -> Image.Image:
|
|
tint, tint_alpha, brightness, saturation, contrast = theme_settings(number)
|
|
canvas = Image.blend(canvas, Image.new("RGB", canvas.size, tint), tint_alpha)
|
|
canvas = ImageEnhance.Color(canvas).enhance(saturation)
|
|
canvas = ImageEnhance.Contrast(canvas).enhance(contrast)
|
|
canvas = ImageEnhance.Brightness(canvas).enhance(brightness)
|
|
|
|
if number == 9:
|
|
canvas = Image.blend(canvas, Image.new("RGB", canvas.size, (25, 36, 48)), 0.28)
|
|
canvas = ImageEnhance.Brightness(canvas).enhance(0.78)
|
|
elif number in {22, 46}:
|
|
rng = random.Random(seed * 211)
|
|
glow = Image.new("RGBA", canvas.size, (0, 0, 0, 0))
|
|
draw = ImageDraw.Draw(glow)
|
|
candidates = [(x, y) for y, row in enumerate(terrain) for x, kind in enumerate(row) if kind in {"camp", "fort", "forest"}]
|
|
rng.shuffle(candidates)
|
|
for x, y in candidates[: max(5, min(18, len(candidates) // 20))]:
|
|
cx, cy = x * tile + tile // 2, y * tile + tile // 2
|
|
radius = rng.randint(tile // 2, tile * 2)
|
|
draw.ellipse((cx - radius, cy - radius, cx + radius, cy + radius), fill=(198, 77, 24, rng.randint(22, 42)))
|
|
canvas = Image.alpha_composite(canvas.convert("RGBA"), glow.filter(ImageFilter.GaussianBlur(max(8, tile // 2)))).convert("RGB")
|
|
glow.close()
|
|
elif number in {40, 61}:
|
|
canvas = Image.blend(canvas, Image.new("RGB", canvas.size, (47, 64, 70)), 0.1 if number == 40 else 0.15)
|
|
canvas = ImageEnhance.Brightness(canvas).enhance(0.91 if number == 61 else 0.96)
|
|
|
|
return canvas
|
|
|
|
|
|
def add_finish(canvas: Image.Image, seed: int) -> Image.Image:
|
|
width, height = canvas.size
|
|
noise_side = 512
|
|
noise_rng = random.Random(seed ^ 0x9E3779B9)
|
|
noise = Image.frombytes("L", (noise_side, noise_side), noise_rng.randbytes(noise_side * noise_side))
|
|
noise = noise.filter(ImageFilter.GaussianBlur(1.1)).resize(canvas.size, Image.Resampling.BILINEAR)
|
|
noise_color = ImageOps.colorize(noise, (49, 47, 35), (153, 143, 101))
|
|
canvas = Image.blend(canvas, noise_color, 0.02)
|
|
noise.close()
|
|
noise_color.close()
|
|
|
|
vignette_small = Image.new("L", (512, 512), 0)
|
|
vdraw = ImageDraw.Draw(vignette_small)
|
|
vdraw.ellipse((-55, -40, 567, 570), fill=255)
|
|
vignette_small = vignette_small.filter(ImageFilter.GaussianBlur(52))
|
|
vignette = vignette_small.resize((width, height), Image.Resampling.BILINEAR)
|
|
dark = Image.new("RGB", canvas.size, (22, 22, 17))
|
|
edge_mask = ImageOps.invert(vignette).point(lambda value: round(value * 0.11))
|
|
canvas = Image.composite(dark, canvas, edge_mask)
|
|
vignette_small.close()
|
|
vignette.close()
|
|
dark.close()
|
|
edge_mask.close()
|
|
return canvas
|
|
|
|
|
|
def build_map(battle: dict[str, object], sources: dict[str, Image.Image]) -> tuple[Image.Image, int]:
|
|
number = int(battle["number"])
|
|
terrain = battle["terrain"]
|
|
columns = int(battle["width"])
|
|
rows = int(battle["height"])
|
|
tile = output_tile_size(columns, rows)
|
|
size = (columns * tile, rows * tile)
|
|
seed = 260715 + number * 1009
|
|
|
|
canvas = fill_texture(sources["plain"], size, tile, "plain", seed)
|
|
canvas = add_river(canvas, sources, terrain, tile, seed)
|
|
for index, kind in enumerate(REGION_KINDS):
|
|
canvas = add_region(canvas, sources, terrain, kind, tile, seed + index * 31)
|
|
canvas = add_roads(canvas, sources, terrain, tile, seed)
|
|
canvas = add_route_ruts(canvas, terrain, tile, seed)
|
|
canvas = add_structures(canvas, sources, terrain, tile, seed)
|
|
if number in WATER_LAYER_REASSERT_BATTLES:
|
|
# Reviewed late-game water fields use intentionally generous feature
|
|
# bleed. Reassert water last so roads and terrain stamps cannot read as
|
|
# floating tiles inside the channel.
|
|
canvas = add_river(canvas, sources, terrain, tile, seed)
|
|
canvas = add_event_atmosphere(canvas, terrain, tile, number, seed)
|
|
canvas = add_finish(canvas, seed)
|
|
return canvas, tile
|
|
|
|
|
|
def sha256(path: Path) -> str:
|
|
digest = hashlib.sha256()
|
|
with path.open("rb") as file:
|
|
for chunk in iter(lambda: file.read(1024 * 1024), b""):
|
|
digest.update(chunk)
|
|
return digest.hexdigest()
|
|
|
|
|
|
def write_contact_sheet(battles: list[dict[str, object]], output: Path) -> None:
|
|
card_width, card_height = 360, 310
|
|
columns = 6
|
|
rows = math.ceil(len(battles) / columns)
|
|
sheet = Image.new("RGB", (columns * card_width, rows * card_height), (24, 25, 20))
|
|
draw = ImageDraw.Draw(sheet)
|
|
for index, battle in enumerate(battles):
|
|
path = ROOT / str(battle["assetPath"])
|
|
if not path.is_file():
|
|
continue
|
|
with Image.open(path) as image:
|
|
preview = ImageOps.contain(image.convert("RGB"), (card_width - 20, card_height - 44), Image.Resampling.LANCZOS)
|
|
x = (index % columns) * card_width
|
|
y = (index // columns) * card_height
|
|
sheet.paste(preview, (x + (card_width - preview.width) // 2, y + 30 + (card_height - 44 - preview.height) // 2))
|
|
draw.text((x + 10, y + 8), f"{int(battle['number']):02d} {battle['id']}", fill=(231, 219, 180))
|
|
output.parent.mkdir(parents=True, exist_ok=True)
|
|
sheet.save(output, "JPEG", quality=90, optimize=True)
|
|
|
|
|
|
def write_manifest(battles: list[dict[str, object]], output: Path) -> None:
|
|
rows = []
|
|
for battle in battles:
|
|
path = ROOT / str(battle["assetPath"])
|
|
with Image.open(path) as image:
|
|
width, height = image.size
|
|
rows.append(
|
|
{
|
|
"number": battle["number"],
|
|
"id": battle["id"],
|
|
"mapTextureKey": battle["mapTextureKey"],
|
|
"assetPath": battle["assetPath"],
|
|
"grid": [battle["width"], battle["height"]],
|
|
"image": [width, height],
|
|
"tilePixels": min(width // int(battle["width"]), height // int(battle["height"])),
|
|
"bytes": path.stat().st_size,
|
|
"sha256": sha256(path),
|
|
}
|
|
)
|
|
output.parent.mkdir(parents=True, exist_ok=True)
|
|
output.write_text(json.dumps({"pipeline": "battle-map-v2-atlas-compositor", "maps": rows}, indent=2) + "\n", encoding="utf-8")
|
|
|
|
|
|
def main() -> None:
|
|
args = parse_args()
|
|
selected_numbers = parse_number_set(args.maps)
|
|
payload = json.loads(args.data.read_text(encoding="utf-8"))
|
|
battles = payload["battles"]
|
|
sources = load_sources()
|
|
|
|
for battle in battles:
|
|
number = int(battle["number"])
|
|
if number not in selected_numbers:
|
|
continue
|
|
output = ROOT / str(battle["assetPath"])
|
|
image, tile = build_map(battle, sources)
|
|
if image.width > MAX_TEXTURE_EDGE or image.height > MAX_TEXTURE_EDGE:
|
|
raise RuntimeError(f"{battle['id']}: generated texture {image.size} exceeds {MAX_TEXTURE_EDGE}px budget")
|
|
output.parent.mkdir(parents=True, exist_ok=True)
|
|
image.save(output, "WEBP", quality=args.quality, method=4)
|
|
image.close()
|
|
print(f"[{number:02d}/66] {battle['id']} -> {output.name} ({int(battle['width']) * tile}x{int(battle['height']) * tile}, {tile}px/tile)")
|
|
gc.collect()
|
|
|
|
for source in sources.values():
|
|
source.close()
|
|
if not args.skip_artifacts:
|
|
write_manifest(battles, args.manifest)
|
|
write_contact_sheet(battles, args.contact_sheet)
|
|
print(f"Wrote {args.manifest}")
|
|
print(f"Wrote {args.contact_sheet}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|