403 lines
20 KiB
Python
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()
|