Improve sixty-fourth battle map detail

This commit is contained in:
2026-07-10 03:17:51 +09:00
parent 842d372e9d
commit c86c8c9a93
12 changed files with 247 additions and 13 deletions

View File

@@ -25,6 +25,7 @@ COLOSSAL_LARGE_MAP_TILE_SIZE = 136
GIGANTIC_LARGE_MAP_TILE_SIZE = 128
IMMENSE_LARGE_MAP_TILE_SIZE = 120
VAST_LARGE_MAP_TILE_SIZE = 112
DESKTOP_RUNTIME_MAP_TILE_SIZE = 64
TILE = DEFAULT_TILE_SIZE
RNG = random.Random(240704)
@@ -643,6 +644,8 @@ MAP_CONFIGS = {
"before": BATTLE_IMAGE_DIR / "sixty-fourth-battle-map.svg",
"seed": 241334,
"tile_size": VAST_LARGE_MAP_TILE_SIZE,
"output_tile_size": DESKTOP_RUNTIME_MAP_TILE_SIZE,
"detail_profile": "weishui-camps",
},
"sixty-fifth": {
"slug": "sixty-fifth-battle-map",
@@ -1197,6 +1200,179 @@ def add_route_and_battlefield_details(canvas: Image.Image, terrain: list[list[st
canvas.alpha_composite(details.filter(ImageFilter.GaussianBlur(0.15)))
def terrain_at(terrain: list[list[str]], x: int, y: int) -> str | None:
if 0 <= y < len(terrain) and 0 <= x < len(terrain[0]):
return terrain[y][x]
return None
def draw_weishui_beacon(draw: ImageDraw.ImageDraw, x: int, y: int) -> None:
cx, cy = center(x, y)
base_y = cy + int(TILE * 0.24)
tower_top = cy - int(TILE * 0.26)
left = cx - int(TILE * 0.18)
right = cx + int(TILE * 0.18)
draw.line((left, base_y, cx - 6, tower_top), fill=(67, 45, 28, 150), width=max(4, TILE // 28))
draw.line((right, base_y, cx + 6, tower_top), fill=(67, 45, 28, 150), width=max(4, TILE // 28))
draw.line((left - 8, cy, right + 8, cy - 5), fill=(127, 86, 48, 142), width=max(4, TILE // 32))
draw.rectangle((cx - 18, tower_top - 6, cx + 18, tower_top + 14), fill=(88, 55, 32, 172))
flame = [
(cx - 16, tower_top - 6),
(cx - 4, tower_top - 40),
(cx + 6, tower_top - 14),
(cx + 18, tower_top - 46),
(cx + 15, tower_top - 3),
]
draw.polygon([(px + 5, py + 7) for px, py in flame], fill=(64, 38, 23, 70))
draw.polygon(flame, fill=(218, 101, 34, 176))
draw.polygon([(cx - 6, tower_top - 5), (cx + 2, tower_top - 25), (cx + 8, tower_top - 5)], fill=(245, 181, 72, 154))
for offset in (-24, 0, 22):
draw.arc(
(cx + offset - 20, tower_top - 76, cx + offset + 44, tower_top - 18),
210,
340,
fill=(72, 68, 54, 50),
width=max(2, TILE // 50),
)
def draw_weishui_supply_marker(draw: ImageDraw.ImageDraw, x: int, y: int) -> None:
cx, cy = center(x, y)
tent_w = int(TILE * 0.5)
tent_h = int(TILE * 0.42)
base_y = cy + int(TILE * 0.2)
draw.polygon(
[(cx - tent_w, base_y), (cx, cy - tent_h), (cx + tent_w, base_y)],
fill=(112, 82, 43, 142),
)
draw.polygon(
[(cx - int(tent_w * 0.72), base_y - 2), (cx, cy - int(tent_h * 0.72)), (cx + int(tent_w * 0.16), base_y - 2)],
fill=(189, 147, 73, 132),
)
draw.line((cx - tent_w, base_y, cx + tent_w, base_y), fill=(58, 39, 25, 126), width=max(3, TILE // 34))
for offset in (-int(TILE * 0.34), int(TILE * 0.38)):
px = cx + offset
py = cy + int(TILE * 0.33)
draw.rectangle((px - int(TILE * 0.14), py - int(TILE * 0.1), px + int(TILE * 0.14), py + int(TILE * 0.1)), fill=(139, 90, 44, 132))
draw.line((px - int(TILE * 0.16), py - int(TILE * 0.11), px + int(TILE * 0.16), py + int(TILE * 0.1)), fill=(216, 174, 91, 82), width=2)
pole_x = cx + int(TILE * 0.48)
pole_y = cy - int(TILE * 0.06)
draw.line((pole_x, pole_y + int(TILE * 0.44), pole_x, pole_y - int(TILE * 0.38)), fill=(63, 42, 27, 150), width=max(3, TILE // 40))
draw.polygon(
[(pole_x + 3, pole_y - int(TILE * 0.38)), (pole_x + int(TILE * 0.38), pole_y - int(TILE * 0.26)), (pole_x + 3, pole_y - int(TILE * 0.12))],
fill=(188, 59, 44, 136),
)
def add_weishui_camps_details(canvas: Image.Image, terrain: list[list[str]]) -> None:
details = Image.new("RGBA", canvas.size, (0, 0, 0, 0))
draw = ImageDraw.Draw(details)
sharp = Image.new("RGBA", canvas.size, (0, 0, 0, 0))
sharp_draw = ImageDraw.Draw(sharp)
for y, row in enumerate(terrain):
for x, kind in enumerate(row):
left, top, right, bottom = tile_box(x, y)
cx, cy = center(x, y)
if kind == "river":
for _ in range(5):
sx = RNG.randint(left + 12, max(left + 13, right - 70))
sy = RNG.randint(top + 16, bottom - 16)
span = RNG.randint(max(34, TILE // 3), max(42, TILE))
draw.arc(
(sx, sy - RNG.randint(8, 18), sx + span, sy + RNG.randint(10, 24)),
190,
352,
fill=(*jitter(PALETTE["water_light"], 10), RNG.randint(38, 82)),
width=RNG.choice([2, 2, 3]),
)
if terrain_at(terrain, x, y - 1) != "river":
bank_y = top + RNG.randint(6, 18)
for _ in range(4):
px = RNG.randint(left + 8, right - 8)
reed = RNG.choice([(87, 111, 60, 80), (151, 135, 78, 72), (42, 83, 51, 72)])
draw.line((px, bank_y + RNG.randint(-2, 8), px + RNG.randint(-8, 9), bank_y - RNG.randint(8, 22)), fill=reed, width=2)
if terrain_at(terrain, x, y + 1) != "river":
bank_y = bottom - RNG.randint(7, 18)
for _ in range(4):
px = RNG.randint(left + 8, right - 8)
reed = RNG.choice([(89, 112, 61, 82), (154, 134, 78, 70), (40, 82, 50, 70)])
draw.line((px, bank_y + RNG.randint(-6, 2), px + RNG.randint(-9, 8), bank_y - RNG.randint(10, 24)), fill=reed, width=2)
if kind == "forest" and (x >= 92 or y >= 65 or RNG.random() < 0.22):
for _ in range(3):
bx = cx + RNG.randint(-54, 56)
by = cy + RNG.randint(-48, 48)
rx = RNG.randint(18, 42)
ry = RNG.randint(12, 30)
draw.polygon(organic_ellipse_points(bx, by, rx, ry, 14, 0.34), fill=(13, 42, 27, RNG.randint(44, 76)))
for _ in range(5):
sx = RNG.randint(left + 18, right - 18)
sy = RNG.randint(top + 24, bottom - 22)
draw.arc(
(sx - 30, sy - 18, sx + 46, sy + 22),
RNG.randint(190, 230),
RNG.randint(300, 350),
fill=(*jitter(PALETTE["forest_light"], 12), RNG.randint(46, 78)),
width=RNG.choice([2, 3]),
)
if RNG.random() < 0.42:
draw.line(
(cx + RNG.randint(-24, 24), cy + RNG.randint(10, 42), cx + RNG.randint(-20, 28), cy + RNG.randint(38, 62)),
fill=(66, 44, 26, 92),
width=RNG.randint(4, 7),
)
if kind == "road" and (y >= 70 or x >= 82) and RNG.random() < 0.55:
for _ in range(2):
py = cy + RNG.randint(-28, 28)
draw.line(
(left + RNG.randint(8, 28), py, right - RNG.randint(8, 28), py + RNG.randint(-8, 8)),
fill=(103, 73, 42, RNG.randint(34, 58)),
width=RNG.choice([2, 3]),
)
if kind == "camp" and RNG.random() < 0.72:
cart_x = cx + RNG.randint(-34, 30)
cart_y = cy + RNG.randint(-14, 34)
draw.rectangle((cart_x - 26, cart_y - 13, cart_x + 24, cart_y + 14), fill=(111, 73, 40, 118))
draw.line((cart_x - 32, cart_y - 17, cart_x + 28, cart_y + 18), fill=(194, 156, 91, 72), width=3)
for wx in (cart_x - 22, cart_x + 20):
draw.ellipse((wx - 8, cart_y + 10, wx + 8, cart_y + 26), outline=(47, 32, 22, 118), width=3)
for _ in range(3):
px = RNG.randint(left + 24, right - 26)
py = RNG.randint(top + 28, bottom - 24)
draw.rectangle((px - 9, py - 7, px + 11, py + 8), fill=(146, 96, 48, RNG.randint(90, 132)))
if kind in {"camp", "fort"} and (x + y) % 9 == 0:
pole_x = cx + RNG.randint(-44, 44)
pole_y = cy + RNG.randint(-40, 30)
sharp_draw.line((pole_x, pole_y + 36, pole_x, pole_y - 38), fill=(80, 52, 31, 160), width=max(3, TILE // 38))
sharp_draw.polygon(
[(pole_x + 4, pole_y - 37), (pole_x + 44, pole_y - 25), (pole_x + 4, pole_y - 13)],
fill=(190, 150, 49, 158),
)
for start, end, width in [
((96, 34), (118, 34), 126),
((99, 46), (124, 46), 118),
((63, 74), (80, 78), 112),
((83, 68), (101, 70), 104),
]:
paint_path_segment(draw, center(*start), center(*end), width, (204, 170, 103), 54, 15)
paint_path_segment(draw, center(*start), center(*end), max(36, width // 2), (229, 196, 133), 34, 10)
for marker in [(59, 62), (72, 63), (101, 34), (115, 35), (70, 58)]:
draw_weishui_supply_marker(sharp_draw, *marker)
for beacon in [(118, 72), (121, 54), (124, 33), (120, 45), (101, 61)]:
draw_weishui_beacon(sharp_draw, *beacon)
canvas.alpha_composite(details.filter(ImageFilter.GaussianBlur(0.18)))
canvas.alpha_composite(sharp.filter(ImageFilter.GaussianBlur(0.04)))
def add_subtle_grid_and_grade(canvas: Image.Image, terrain: list[list[str]]) -> Image.Image:
width, height = canvas.size
overlay = Image.new("RGBA", canvas.size, (0, 0, 0, 0))
@@ -1237,9 +1413,21 @@ def build_map(config: dict[str, object]) -> Image.Image:
paint_roads(canvas, terrain)
paint_tiles(canvas, terrain)
add_route_and_battlefield_details(canvas, terrain, units)
if config.get("detail_profile") == "weishui-camps":
add_weishui_camps_details(canvas, terrain)
return add_subtle_grid_and_grade(canvas, terrain)
def resize_for_runtime_if_needed(after: Image.Image, terrain: list[list[str]], config: dict[str, object]) -> Image.Image:
output_tile_size = config.get("output_tile_size")
if output_tile_size is None or int(output_tile_size) == TILE:
return after
width = len(terrain[0]) * int(output_tile_size)
height = len(terrain) * int(output_tile_size)
return after.resize((width, height), Image.Resampling.LANCZOS)
def make_flat_legacy_preview(terrain: list[list[str]], size: tuple[int, int]) -> Image.Image:
colors = {
"plain": (97, 116, 75),
@@ -1301,6 +1489,7 @@ def main() -> None:
parser.add_argument("--preview", type=Path)
parser.add_argument("--tile-size", type=int)
parser.add_argument("--webp-quality", type=int, default=90)
parser.add_argument("--webp-method", type=int, default=6)
args = parser.parse_args()
config = dict(MAP_CONFIGS[args.map])
@@ -1315,28 +1504,30 @@ def main() -> None:
terrain = parse_battle_terrain(str(config["terrain_export"]))
after = build_map(config)
before = load_before_image(before_path, terrain, after.size)
if before.size != after.size:
before = before.resize(after.size, Image.Resampling.LANCZOS)
runtime_after = resize_for_runtime_if_needed(after, terrain, config)
before = load_before_image(before_path, terrain, runtime_after.size)
if before.size != runtime_after.size:
before = before.resize(runtime_after.size, Image.Resampling.LANCZOS)
out_path.parent.mkdir(parents=True, exist_ok=True)
if out_path.suffix.lower() == ".webp":
after.save(out_path, "WEBP", quality=args.webp_quality, method=6)
runtime_after.save(out_path, "WEBP", quality=args.webp_quality, method=args.webp_method)
else:
output = after.quantize(colors=224, method=Image.Quantize.MEDIANCUT, dither=Image.Dither.NONE)
output = runtime_after.quantize(colors=224, method=Image.Quantize.MEDIANCUT, dither=Image.Dither.NONE)
output.save(out_path, optimize=True, compress_level=9)
make_comparison(before, after, comparison_path)
make_comparison(before, runtime_after, comparison_path)
preview_path.parent.mkdir(parents=True, exist_ok=True)
after.resize((1400, int(after.height * 1400 / after.width)), Image.Resampling.LANCZOS).save(preview_path, quality=92)
runtime_after.resize((1400, int(runtime_after.height * 1400 / runtime_after.width)), Image.Resampling.LANCZOS).save(preview_path, quality=92)
diff_size = (400, int(after.height * 400 / after.width))
diff = ImageChops.difference(before.resize(diff_size), after.resize(diff_size)).convert("L")
diff_size = (400, int(runtime_after.height * 400 / runtime_after.width))
diff = ImageChops.difference(before.resize(diff_size), runtime_after.resize(diff_size)).convert("L")
print(
{
"map": args.map,
"tile_size": TILE,
"output_tile_size": config.get("output_tile_size", TILE),
"out": str(out_path),
"size": after.size,
"size": runtime_after.size,
"comparison": str(comparison_path),
"preview": str(preview_path),
"avg_preview_delta": round(sum(diff.tobytes()) / (diff_size[0] * diff_size[1]), 2),