from __future__ import annotations import ast 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" TILE = 128 SEED = 314159 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 * 12): px = rng.randrange(width * TILE) py = rng.randrange(height * TILE) radius = rng.randint(2, 9) 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(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(20): px = x * TILE + rng.randrange(TILE) py = y * TILE + rng.randrange(TILE) radius = rng.randint(1, 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(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(18): px = x * TILE + rng.randint(4, TILE - 4) py = y * TILE + rng.randint(4, TILE - 4) length = rng.randint(5, 13) color = rng.choice([(70, 116, 55), (126, 116, 54), (62, 94, 47), (148, 130, 68)]) draw.line((px, py, px + rng.randint(-3, 3), py - length), fill=color, width=1) 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(-7, 7) 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 - 28, cy), (cx, cy + 14), (cx + 28, cy - 8)] draw.line(points, fill=shadow, width=42, joint="curve") draw.line(points, fill=road, width=35, joint="curve") draw.line(points, fill=highlight, width=6, joint="curve") for _ in range(20): px = x * TILE + rng.randint(10, TILE - 10) py = y * TILE + rng.randint(10, TILE - 10) draw.ellipse((px - 2, py - 1, px + 2, py + 1), fill=jitter((122, 96, 62), 10, rng)) 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 + 10, top + 10), (right - 8, top + 2), (right - 12, bottom - 8), (left + 4, bottom - 2)] if north: water[0] = (left + 8, top - 8) water[1] = (right - 4, top - 8) if south: water[2] = (right - 10, bottom + 8) water[3] = (left + 8, bottom + 8) draw.polygon(water, fill=(43, 94, 107)) draw.polygon([(left + 3, top + 2), (right - 3, top + 0), (right - 8, top + 18), (left + 8, top + 20)], fill=(69, 117, 119)) for _ in range(10): sx = rng.randint(left + 12, right - 12) sy = rng.randint(top + 15, bottom - 15) draw.arc((sx - 18, sy - 5, sx + 18, sy + 9), 190, 340, fill=(135, 177, 175), width=1) for side, connected in ((left, west), (right, east)): if not connected: draw.line((side, top + 6, side, bottom - 6), fill=(83, 87, 62), width=6) if not north: draw.line((left + 5, top, right - 5, top), fill=(91, 91, 60), width=6) if not south: draw.line((left + 4, bottom, right - 6, bottom), fill=(80, 83, 57), width=7) for _ in range(5): rx = rng.randint(left + 8, right - 8) ry = rng.randint(top + 8, bottom - 8) draw.ellipse((rx - 5, ry - 3, rx + 6, ry + 4), fill=(85, 92, 80)) if not (north or south or east or west): draw.ellipse((cx - 24, cy - 20, cx + 24, cy + 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(13): px = rng.randint(left + 12, right - 12) py = rng.randint(top + 14, bottom - 12) trunk = jitter((78, 54, 34), 8, rng) draw.rectangle((px - 3, py + 7, px + 3, py + 23), fill=trunk) for radius, color in ((22, (36, 74, 39)), (17, (54, 96, 42)), (11, (81, 120, 53))): ox = rng.randint(-4, 4) oy = rng.randint(-5, 4) draw.ellipse((px - radius + ox, py - radius + oy, px + radius + ox, py + radius + oy), fill=jitter(color, 8, rng)) draw.ellipse((px - 14, py - 18, px + 4, py - 2), fill=(103, 132, 56)) for _ in range(10): px = rng.randint(left, right) py = rng.randint(top, bottom) draw.ellipse((px - 3, py - 3, px + 3, py + 3), fill=(24, 54, 31)) 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(5): cx = rng.randint(left + 22, right - 22) cy = rng.randint(top + 22, bottom - 22) rx = rng.randint(20, 38) ry = rng.randint(10, 24) 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=2) draw.arc((cx - rx, cy - ry, cx + rx, cy + ry), 20, 160, fill=(72, 69, 52), width=2) continue draw.rectangle((left, top, right, bottom), fill=jitter((55, 52, 45), 5, rng)) for _ in range(12): ax = rng.randint(left - 8, right - 12) ay = rng.randint(top + 2, bottom - 8) points = [ (ax, ay + rng.randint(12, 26)), (ax + rng.randint(14, 31), ay), (ax + rng.randint(32, 58), ay + rng.randint(18, 40)), (ax + rng.randint(4, 18), ay + rng.randint(42, 68)), ] draw.polygon(points, fill=jitter((75, 70, 58), 15, rng)) draw.line(points + [points[0]], fill=(37, 37, 34), width=2) draw.line((left + 8, top + 5, right - 8, bottom - 8), fill=(116, 105, 77), width=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 - 22, cy - 14, rng) draw_hut(draw, cx + 22, cy + 12, rng) draw.line((left + 12, bottom - 22, right - 12, bottom - 22), fill=(91, 66, 39), width=5) draw.rectangle((cx - 7, cy + 22, cx + 8, cy + 45), fill=(208, 165, 65)) continue if kind == "camp": for ox, oy, color in ((-24, 5, (179, 129, 50)), (22, 0, (209, 174, 84))): draw.polygon([(cx + ox, cy + oy - 32), (cx + ox - 28, cy + oy + 22), (cx + ox + 28, cy + oy + 22)], fill=color) draw.line((cx + ox, cy + oy - 32, cx + ox, cy + oy + 22), fill=(88, 58, 32), width=3) draw.line((cx + ox - 28, cy + oy + 22, cx + ox + 28, cy + oy + 22), fill=(92, 61, 35), width=4) draw.line((left + 12, top + 20, right - 12, top + 10), fill=(105, 81, 45), width=4) draw_banner(draw, right - 28, top + 28, (216, 171, 57)) continue draw.rectangle((left + 12, top + 22, right - 12, bottom - 18), outline=(73, 55, 42), width=8) draw.rectangle((left + 20, top + 30, right - 20, bottom - 26), outline=(119, 88, 60), width=5) draw.rectangle((cx - 18, bottom - 50, cx + 18, bottom - 18), fill=(63, 48, 39)) for tx, ty in ((left + 16, top + 20), (right - 16, top + 20), (left + 16, bottom - 18), (right - 16, bottom - 18)): draw.rectangle((tx - 10, ty - 10, tx + 10, ty + 10), fill=(93, 70, 52)) draw_banner(draw, cx + 26, top + 28, (213, 171, 54)) def draw_hut(draw: ImageDraw.ImageDraw, x: int, y: int, rng: random.Random) -> None: draw.rectangle((x - 22, y - 2, x + 22, y + 26), fill=jitter((106, 74, 45), 8, rng)) draw.polygon([(x - 31, y), (x, y - 28), (x + 31, y)], fill=jitter((151, 106, 48), 8, rng)) draw.line((x - 30, y, x + 30, y), fill=(84, 56, 34), width=4) draw.rectangle((x - 7, y + 8, x + 6, y + 26), fill=(55, 39, 31)) def draw_banner(draw: ImageDraw.ImageDraw, x: int, y: int, color: tuple[int, int, int]) -> None: draw.line((x, y - 22, x, y + 30), fill=(68, 48, 31), width=3) draw.polygon([(x, y - 18), (x + 23, y - 12), (x, y + 2)], fill=color) draw.line((x, y - 18, x + 23, y - 12, x, y + 2), fill=(96, 66, 28), width=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(150): x = rng.randint(-80, width) y = rng.randint(-60, height) rx = rng.randint(45, 150) ry = rng.randint(20, 90) draw.ellipse((x - rx, y - ry, x + rx, y + ry), fill=(0, 0, 0, rng.randint(5, 16))) shade = shade.filter(ImageFilter.GaussianBlur(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(80)) result = Image.alpha_composite(result, glow) vignette = Image.new("RGBA", image.size, (0, 0, 0, 0)) vdraw = ImageDraw.Draw(vignette) for i in range(80): alpha = int((i / 79) ** 2 * 75) vdraw.rectangle((i * width / 160, i * height / 160, width - i * width / 160, height - i * height / 160), outline=(0, 0, 0, alpha), width=4) 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, 5), width=1) for y in range(1, height): py = y * TILE draw.line((0, py, width * TILE, py), fill=(14, 19, 14, 5), width=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()