from __future__ import annotations import ast import math import random import re from pathlib import Path from PIL import Image, ImageDraw, ImageFilter ROOT = Path(__file__).resolve().parents[1] SCENARIO_PATH = ROOT / "src" / "game" / "data" / "scenario.ts" OUTPUT_PATH = ROOT / "src" / "assets" / "images" / "battle" / "first-battle-map.png" BASE_TILE = 128 TILE = 224 SEED = 314159 def s(value: float) -> int: return max(1, round(value * TILE / BASE_TILE)) def load_first_battle_map() -> tuple[int, int, list[list[str]]]: source = SCENARIO_PATH.read_text(encoding="utf-8") width_match = re.search(r"firstBattleMap:\s*BattleMap\s*=\s*\{\s*width:\s*(\d+)", source) height_match = re.search(r"firstBattleMap:[\s\S]*?height:\s*(\d+)", source) terrain_match = re.search(r"firstBattleMap:[\s\S]*?terrain:\s*(\[[\s\S]*?\n\s*\])\s*\n\s*\};", source) if not width_match or not height_match or not terrain_match: raise RuntimeError("Could not parse firstBattleMap from scenario.ts") width = int(width_match.group(1)) height = int(height_match.group(1)) terrain = ast.literal_eval(terrain_match.group(1)) if len(terrain) != height or any(len(row) != width for row in terrain): raise RuntimeError("Parsed terrain dimensions do not match firstBattleMap width/height") return width, height, terrain def clamp(value: int) -> int: return max(0, min(255, value)) def jitter(color: tuple[int, int, int], amount: int, rng: random.Random) -> tuple[int, int, int]: return tuple(clamp(channel + rng.randint(-amount, amount)) for channel in color) def tile_box(x: int, y: int, inset: int = 0) -> tuple[int, int, int, int]: return (x * TILE + inset, y * TILE + inset, (x + 1) * TILE - inset, (y + 1) * TILE - inset) def tile_center(x: int, y: int) -> tuple[int, int]: return (x * TILE + TILE // 2, y * TILE + TILE // 2) def neighbor_is(terrain: list[list[str]], x: int, y: int, kinds: set[str]) -> bool: return 0 <= y < len(terrain) and 0 <= x < len(terrain[0]) and terrain[y][x] in kinds def draw_base(width: int, height: int, terrain: list[list[str]], rng: random.Random) -> Image.Image: image = Image.new("RGB", (width * TILE, height * TILE), (94, 109, 62)) draw = ImageDraw.Draw(image) palettes = { "plain": [(92, 112, 61), (117, 119, 68), (112, 96, 54), (78, 105, 58)], "road": [(157, 128, 82), (177, 147, 92), (132, 107, 72)], "forest": [(42, 80, 43), (56, 95, 43), (72, 102, 50), (33, 67, 40)], "hill": [(105, 100, 70), (128, 116, 79), (83, 91, 62)], "village": [(118, 98, 62), (100, 113, 64), (151, 126, 79)], "fort": [(94, 79, 63), (118, 98, 74), (84, 76, 63)], "camp": [(105, 95, 55), (123, 112, 66), (85, 109, 70)], "river": [(50, 93, 101), (41, 80, 92), (74, 118, 121)], "cliff": [(57, 54, 47), (76, 70, 56), (43, 43, 38)], } ground_noise = Image.new("RGBA", image.size, (0, 0, 0, 0)) noise_draw = ImageDraw.Draw(ground_noise) for _ in range(width * height * 42): px = rng.randrange(width * TILE) py = rng.randrange(height * TILE) radius = rng.randint(s(2), s(11)) color = jitter(rng.choice(palettes["plain"]), 16, rng) noise_draw.ellipse((px - radius, py - radius, px + radius, py + radius), fill=(*color, rng.randint(18, 42))) ground_noise = ground_noise.filter(ImageFilter.GaussianBlur(s(1.2))) image = Image.alpha_composite(image.convert("RGBA"), ground_noise).convert("RGB") draw = ImageDraw.Draw(image) tint = Image.new("RGBA", image.size, (0, 0, 0, 0)) tint_draw = ImageDraw.Draw(tint) for y, row in enumerate(terrain): for x, kind in enumerate(row): base = rng.choice(palettes[kind]) alpha = { "plain": 18, "road": 34, "forest": 68, "hill": 54, "village": 40, "fort": 52, "camp": 40, "river": 88, "cliff": 82, }[kind] tint_draw.rectangle(tile_box(x, y), fill=(*jitter(base, 8, rng), alpha)) for _ in range(64): px = x * TILE + rng.randrange(TILE) py = y * TILE + rng.randrange(TILE) radius = rng.randint(s(1), s(3)) color = jitter(rng.choice(palettes[kind]), 14, rng) draw.ellipse((px - radius, py - radius, px + radius, py + radius), fill=color) tint = tint.filter(ImageFilter.GaussianBlur(s(18))) image = Image.alpha_composite(image.convert("RGBA"), tint).convert("RGB") return image def draw_grass_detail(draw: ImageDraw.ImageDraw, terrain: list[list[str]], rng: random.Random) -> None: for y, row in enumerate(terrain): for x, kind in enumerate(row): if kind not in {"plain", "camp", "village"}: continue for _ in range(52): px = x * TILE + rng.randint(s(4), TILE - s(4)) py = y * TILE + rng.randint(s(4), TILE - s(4)) length = rng.randint(s(5), s(15)) color = rng.choice([(70, 116, 55), (126, 116, 54), (62, 94, 47), (148, 130, 68)]) draw.line((px, py, px + rng.randint(-s(3), s(3)), py - length), fill=color, width=s(1)) for _ in range(14): px = x * TILE + rng.randint(s(8), TILE - s(8)) py = y * TILE + rng.randint(s(8), TILE - s(8)) color = jitter(rng.choice([(121, 105, 58), (83, 102, 57), (151, 128, 71)]), 9, rng) draw.ellipse((px - s(3), py - s(2), px + s(4), py + s(2)), fill=color) def draw_roads(draw: ImageDraw.ImageDraw, terrain: list[list[str]], rng: random.Random) -> None: road_kinds = {"road", "village", "fort", "camp"} shadow = (96, 78, 54) road = (181, 145, 92) highlight = (210, 178, 119) for y, row in enumerate(terrain): for x, kind in enumerate(row): if kind not in road_kinds: continue cx, cy = tile_center(x, y) connections = [] for dx, dy in ((1, 0), (-1, 0), (0, 1), (0, -1)): if neighbor_is(terrain, x + dx, y + dy, road_kinds): connections.append((dx, dy)) if not connections: connections = [(0, 0)] for dx, dy in connections: end_x = cx + dx * TILE // 2 end_y = cy + dy * TILE // 2 wobble = rng.randint(-s(8), s(8)) if dx: points = [(cx, cy + wobble), ((cx + end_x) // 2, cy - wobble), (end_x, end_y + wobble)] elif dy: points = [(cx + wobble, cy), (cx - wobble, (cy + end_y) // 2), (end_x + wobble, end_y)] else: points = [(cx - s(34), cy), (cx, cy + s(16)), (cx + s(34), cy - s(10))] draw.line(points, fill=shadow, width=s(50), joint="curve") draw.line(points, fill=road, width=s(41), joint="curve") draw.line(points, fill=highlight, width=s(7), joint="curve") for _ in range(52): px = x * TILE + rng.randint(s(10), TILE - s(10)) py = y * TILE + rng.randint(s(10), TILE - s(10)) draw.ellipse((px - s(2), py - s(1), px + s(3), py + s(2)), fill=jitter((122, 96, 62), 10, rng)) for _ in range(7): px = x * TILE + rng.randint(s(18), TILE - s(18)) py = y * TILE + rng.randint(s(18), TILE - s(18)) draw.line( (px - s(14), py + rng.randint(-s(4), s(4)), px + s(16), py + rng.randint(-s(4), s(4))), fill=jitter((102, 79, 52), 8, rng), width=s(2) ) def draw_rivers(draw: ImageDraw.ImageDraw, terrain: list[list[str]], rng: random.Random) -> None: for y, row in enumerate(terrain): for x, kind in enumerate(row): if kind != "river": continue left, top, right, bottom = tile_box(x, y) cx, cy = tile_center(x, y) north = neighbor_is(terrain, x, y - 1, {"river"}) south = neighbor_is(terrain, x, y + 1, {"river"}) east = neighbor_is(terrain, x + 1, y, {"river"}) west = neighbor_is(terrain, x - 1, y, {"river"}) water = [(left + s(10), top + s(10)), (right - s(8), top + s(2)), (right - s(12), bottom - s(8)), (left + s(4), bottom - s(2))] if north: water[0] = (left + s(8), top - s(8)) water[1] = (right - s(4), top - s(8)) if south: water[2] = (right - s(10), bottom + s(8)) water[3] = (left + s(8), bottom + s(8)) draw.polygon(water, fill=(43, 94, 107)) draw.polygon([(left + s(3), top + s(2)), (right - s(3), top), (right - s(8), top + s(18)), (left + s(8), top + s(20))], fill=(69, 117, 119)) for _ in range(24): sx = rng.randint(left + s(12), right - s(12)) sy = rng.randint(top + s(15), bottom - s(15)) draw.arc((sx - s(18), sy - s(5), sx + s(18), sy + s(9)), 190, 340, fill=(135, 177, 175), width=s(1)) for _ in range(8): sx = rng.randint(left + s(12), right - s(12)) sy = rng.randint(top + s(18), bottom - s(18)) draw.line((sx - s(22), sy, sx + s(24), sy + rng.randint(-s(5), s(5))), fill=(55, 125, 137), width=s(2)) for side, connected in ((left, west), (right, east)): if not connected: draw.line((side, top + s(6), side, bottom - s(6)), fill=(83, 87, 62), width=s(6)) if not north: draw.line((left + s(5), top, right - s(5), top), fill=(91, 91, 60), width=s(6)) if not south: draw.line((left + s(4), bottom, right - s(6), bottom), fill=(80, 83, 57), width=s(7)) for _ in range(12): rx = rng.randint(left + s(8), right - s(8)) ry = rng.randint(top + s(8), bottom - s(8)) draw.ellipse((rx - s(5), ry - s(3), rx + s(6), ry + s(4)), fill=(85, 92, 80)) if not (north or south or east or west): draw.ellipse((cx - s(24), cy - s(20), cx + s(24), cy + s(20)), fill=(44, 92, 103)) def draw_forests(draw: ImageDraw.ImageDraw, terrain: list[list[str]], rng: random.Random) -> None: for y, row in enumerate(terrain): for x, kind in enumerate(row): if kind != "forest": continue left, top, right, bottom = tile_box(x, y) for _ in range(28): px = rng.randint(left + s(12), right - s(12)) py = rng.randint(top + s(14), bottom - s(12)) trunk = jitter((78, 54, 34), 8, rng) draw.rectangle((px - s(3), py + s(7), px + s(3), py + s(23)), fill=trunk) for radius, color in ((24, (36, 74, 39)), (18, (54, 96, 42)), (12, (81, 120, 53))): radius = s(radius) ox = rng.randint(-s(4), s(4)) oy = rng.randint(-s(5), s(4)) draw.ellipse((px - radius + ox, py - radius + oy, px + radius + ox, py + radius + oy), fill=jitter(color, 8, rng)) draw.ellipse((px - s(14), py - s(18), px + s(4), py - s(2)), fill=(103, 132, 56)) for _ in range(28): px = rng.randint(left, right) py = rng.randint(top, bottom) draw.ellipse((px - s(3), py - s(3), px + s(3), py + s(3)), fill=(24, 54, 31)) for _ in range(6): sx = rng.randint(left + s(20), right - s(20)) sy = rng.randint(top + s(20), bottom - s(20)) draw.arc((sx - s(26), sy - s(14), sx + s(28), sy + s(16)), 185, 335, fill=(22, 49, 29), width=s(2)) def draw_hills_and_cliffs(draw: ImageDraw.ImageDraw, terrain: list[list[str]], rng: random.Random) -> None: for y, row in enumerate(terrain): for x, kind in enumerate(row): if kind not in {"hill", "cliff"}: continue left, top, right, bottom = tile_box(x, y) if kind == "hill": for _ in range(10): cx = rng.randint(left + s(22), right - s(22)) cy = rng.randint(top + s(22), bottom - s(22)) rx = rng.randint(s(20), s(42)) ry = rng.randint(s(10), s(25)) draw.ellipse((cx - rx, cy - ry, cx + rx, cy + ry), fill=jitter((126, 113, 80), 12, rng)) draw.arc((cx - rx, cy - ry, cx + rx, cy + ry), 180, 350, fill=(176, 160, 112), width=s(2)) draw.arc((cx - rx, cy - ry, cx + rx, cy + ry), 20, 160, fill=(72, 69, 52), width=s(2)) for _ in range(12): px = rng.randint(left + s(10), right - s(10)) py = rng.randint(top + s(10), bottom - s(10)) draw.line((px - s(11), py, px + s(13), py + rng.randint(-s(3), s(3))), fill=(89, 84, 62), width=s(1)) continue draw.rectangle((left, top, right, bottom), fill=jitter((55, 52, 45), 5, rng)) for _ in range(24): ax = rng.randint(left - s(8), right - s(12)) ay = rng.randint(top + s(2), bottom - s(8)) points = [ (ax, ay + rng.randint(s(12), s(26))), (ax + rng.randint(s(14), s(31)), ay), (ax + rng.randint(s(32), s(58)), ay + rng.randint(s(18), s(40))), (ax + rng.randint(s(4), s(18)), ay + rng.randint(s(42), s(68))), ] draw.polygon(points, fill=jitter((75, 70, 58), 15, rng)) draw.line(points + [points[0]], fill=(37, 37, 34), width=s(2)) draw.line((left + s(8), top + s(5), right - s(8), bottom - s(8)), fill=(116, 105, 77), width=s(3)) def draw_structures(draw: ImageDraw.ImageDraw, terrain: list[list[str]], rng: random.Random) -> None: for y, row in enumerate(terrain): for x, kind in enumerate(row): if kind not in {"village", "camp", "fort"}: continue left, top, right, bottom = tile_box(x, y) cx, cy = tile_center(x, y) if kind == "village": draw_hut(draw, cx - s(24), cy - s(16), rng) draw_hut(draw, cx + s(24), cy + s(14), rng) draw.line((left + s(12), bottom - s(22), right - s(12), bottom - s(22)), fill=(91, 66, 39), width=s(5)) draw.rectangle((cx - s(7), cy + s(22), cx + s(8), cy + s(45)), fill=(208, 165, 65)) draw.line((cx - s(50), cy + s(45), cx + s(52), cy + s(35)), fill=(79, 61, 42), width=s(2)) continue if kind == "camp": for ox, oy, color in ((-27, 6, (179, 129, 50)), (24, 0, (209, 174, 84))): ox = s(ox) oy = s(oy) draw.polygon([(cx + ox, cy + oy - s(34)), (cx + ox - s(31), cy + oy + s(24)), (cx + ox + s(31), cy + oy + s(24))], fill=color) draw.line((cx + ox, cy + oy - s(34), cx + ox, cy + oy + s(24)), fill=(88, 58, 32), width=s(3)) draw.line((cx + ox - s(31), cy + oy + s(24), cx + ox + s(31), cy + oy + s(24)), fill=(92, 61, 35), width=s(4)) draw.line((cx + ox - s(17), cy + oy + s(10), cx + ox + s(15), cy + oy + s(10)), fill=(225, 189, 92), width=s(2)) draw.line((left + s(12), top + s(20), right - s(12), top + s(10)), fill=(105, 81, 45), width=s(4)) draw_banner(draw, right - s(28), top + s(28), (216, 171, 57)) continue draw.rectangle((left + s(12), top + s(22), right - s(12), bottom - s(18)), outline=(73, 55, 42), width=s(8)) draw.rectangle((left + s(20), top + s(30), right - s(20), bottom - s(26)), outline=(119, 88, 60), width=s(5)) draw.rectangle((cx - s(18), bottom - s(50), cx + s(18), bottom - s(18)), fill=(63, 48, 39)) for tx, ty in ((left + s(16), top + s(20)), (right - s(16), top + s(20)), (left + s(16), bottom - s(18)), (right - s(16), bottom - s(18))): draw.rectangle((tx - s(10), ty - s(10), tx + s(10), ty + s(10)), fill=(93, 70, 52)) draw.line((left + s(18), cy, right - s(18), cy), fill=(83, 62, 48), width=s(3)) draw_banner(draw, cx + s(26), top + s(28), (213, 171, 54)) def draw_hut(draw: ImageDraw.ImageDraw, x: int, y: int, rng: random.Random) -> None: draw.rectangle((x - s(22), y - s(2), x + s(22), y + s(26)), fill=jitter((106, 74, 45), 8, rng)) draw.polygon([(x - s(31), y), (x, y - s(28)), (x + s(31), y)], fill=jitter((151, 106, 48), 8, rng)) draw.line((x - s(30), y, x + s(30), y), fill=(84, 56, 34), width=s(4)) draw.rectangle((x - s(7), y + s(8), x + s(6), y + s(26)), fill=(55, 39, 31)) draw.line((x - s(20), y + s(8), x + s(20), y + s(8)), fill=(137, 93, 52), width=s(2)) def draw_banner(draw: ImageDraw.ImageDraw, x: int, y: int, color: tuple[int, int, int]) -> None: draw.line((x, y - s(22), x, y + s(30)), fill=(68, 48, 31), width=s(3)) draw.polygon([(x, y - s(18)), (x + s(23), y - s(12)), (x, y + s(2))], fill=color) draw.line((x, y - s(18), x + s(23), y - s(12), x, y + s(2)), fill=(96, 66, 28), width=s(1)) def draw_shadows_and_light(image: Image.Image, rng: random.Random) -> Image.Image: width, height = image.size shade = Image.new("RGBA", image.size, (0, 0, 0, 0)) draw = ImageDraw.Draw(shade) for _ in range(260): x = rng.randint(-s(80), width) y = rng.randint(-s(60), height) rx = rng.randint(s(45), s(170)) ry = rng.randint(s(20), s(95)) draw.ellipse((x - rx, y - ry, x + rx, y + ry), fill=(0, 0, 0, rng.randint(5, 16))) shade = shade.filter(ImageFilter.GaussianBlur(s(18))) result = Image.alpha_composite(image.convert("RGBA"), shade) glow = Image.new("RGBA", image.size, (0, 0, 0, 0)) gdraw = ImageDraw.Draw(glow) gdraw.ellipse((-width * 0.22, -height * 0.22, width * 0.82, height * 0.62), fill=(255, 213, 135, 28)) glow = glow.filter(ImageFilter.GaussianBlur(s(80))) result = Image.alpha_composite(result, glow) mask_size = (320, max(1, round(320 * height / width))) vignette_mask = Image.new("L", mask_size, 0) pixels = vignette_mask.load() cx = (mask_size[0] - 1) / 2 cy = (mask_size[1] - 1) / 2 max_distance = math.hypot(cx, cy) for py in range(mask_size[1]): for px in range(mask_size[0]): distance = math.hypot(px - cx, py - cy) / max_distance pixels[px, py] = clamp(round(max(0, distance - 0.45) / 0.55 * 68)) vignette_mask = vignette_mask.resize(image.size, Image.Resampling.BICUBIC).filter(ImageFilter.GaussianBlur(s(18))) vignette = Image.new("RGBA", image.size, (0, 0, 0, 0)) vignette.putalpha(vignette_mask) result = Image.alpha_composite(result, vignette) return result.convert("RGB") def add_tile_seams(image: Image.Image, width: int, height: int) -> Image.Image: overlay = Image.new("RGBA", image.size, (0, 0, 0, 0)) draw = ImageDraw.Draw(overlay) for x in range(1, width): px = x * TILE draw.line((px, 0, px, height * TILE), fill=(14, 19, 14, 10), width=s(1)) for y in range(1, height): py = y * TILE draw.line((0, py, width * TILE, py), fill=(14, 19, 14, 10), width=s(1)) return Image.alpha_composite(image.convert("RGBA"), overlay).convert("RGB") def main() -> None: rng = random.Random(SEED) width, height, terrain = load_first_battle_map() image = draw_base(width, height, terrain, rng) draw = ImageDraw.Draw(image) draw_roads(draw, terrain, rng) draw_rivers(draw, terrain, rng) draw_hills_and_cliffs(draw, terrain, rng) draw_forests(draw, terrain, rng) draw_grass_detail(draw, terrain, rng) draw_structures(draw, terrain, rng) image = draw_shadows_and_light(image, rng) image = add_tile_seams(image, width, height) OUTPUT_PATH.parent.mkdir(parents=True, exist_ok=True) image.save(OUTPUT_PATH, optimize=True) print(f"Generated {OUTPUT_PATH} ({image.width}x{image.height})") if __name__ == "__main__": main()