Files
heros_web/scripts/generate-battle-map.py

403 lines
20 KiB
Python

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()