diff --git a/docs/battle-map-art-direction-first-sample.md b/docs/battle-map-art-direction-first-sample.md new file mode 100644 index 0000000..43d8b25 --- /dev/null +++ b/docs/battle-map-art-direction-first-sample.md @@ -0,0 +1,38 @@ +# Battle Map Art Direction: First Battle Sample + +## Goal + +Raise the first battle map from flat placeholder-like terrain to an original hand-painted tactical RPG battlefield while keeping the existing 20x18 tactical grid, unit positions, movement rules, and battle data unchanged. + +## Readability Priority + +- Unit sprites must read before the map. +- Terrain contrast stays one step below characters, combat UI, and hover/selection feedback. +- Texture detail should add depth without turning into visual noise at PC browser 100% scale. +- Terrain identity must remain clear: road, forest, hill, river, village, camp, fort. + +## Terrain Palette + +- Plain: muted olive grass with subtle yellow-green and brown brush flecks. +- Road: warm dusty tan with irregular edges, wheel marks, and compacted earth strokes. +- Forest: dark desaturated green canopy clusters with soft shadows, not black outlines. +- Hill: ochre stone and dry earth mounds, low-profile shapes that do not compete with units. +- Cliff: darker rock planes with sharper facets, used sparingly as impassable-looking terrain. +- River: subdued blue-green water with horizontal ripple highlights and darker bank shadows. +- Village: small ochre-roof huts, fences, and packed dirt clearings. +- Camp: simple canvas tents and banners, warm tan but low contrast. +- Fort: dark wood palisade and watch platforms with muted yellow flags. + +## Grid And Tactical Form + +- The map art itself should not contain heavy grid lines. +- Tile positions are preserved by rendering each terrain cell at 224px inside the existing 4480x4032 map. +- Organic edges may cross tile boundaries visually, but tactical meaning must still match the terrain array. +- Roads should visually connect from tile center to tile center so route planning is obvious. + +## Sample Evaluation + +- First battle lower-left camp and starting road should guide the eye toward the central route. +- Central village and river should be readable without stealing focus. +- Enemy fort area in the upper-right must imply an enemy line without becoming a large UI-like block. +- At 100% PC browser scale, Liu Bei, Guan Yu, Zhang Fei, and enemy units must remain the strongest silhouettes. diff --git a/docs/battle-map-art-direction-reference.png b/docs/battle-map-art-direction-reference.png new file mode 100644 index 0000000..1007548 Binary files /dev/null and b/docs/battle-map-art-direction-reference.png differ diff --git a/docs/first-battle-map-handpaint-before-after.png b/docs/first-battle-map-handpaint-before-after.png new file mode 100644 index 0000000..5e8265d Binary files /dev/null and b/docs/first-battle-map-handpaint-before-after.png differ diff --git a/docs/first-battle-map-handpaint-local-battle-check.png b/docs/first-battle-map-handpaint-local-battle-check.png new file mode 100644 index 0000000..5b8d102 Binary files /dev/null and b/docs/first-battle-map-handpaint-local-battle-check.png differ diff --git a/docs/first-battle-map-handpaint-local-browser-check.png b/docs/first-battle-map-handpaint-local-browser-check.png new file mode 100644 index 0000000..71a756a Binary files /dev/null and b/docs/first-battle-map-handpaint-local-browser-check.png differ diff --git a/docs/first-battle-map-handpaint-preview.png b/docs/first-battle-map-handpaint-preview.png new file mode 100644 index 0000000..193dd41 Binary files /dev/null and b/docs/first-battle-map-handpaint-preview.png differ diff --git a/docs/first-battle-map-handpaint-prod-battle-check.png b/docs/first-battle-map-handpaint-prod-battle-check.png new file mode 100644 index 0000000..009e0e7 Binary files /dev/null and b/docs/first-battle-map-handpaint-prod-battle-check.png differ diff --git a/docs/first-battle-map-handpaint-prod-browser-check.png b/docs/first-battle-map-handpaint-prod-browser-check.png new file mode 100644 index 0000000..fbca5cd Binary files /dev/null and b/docs/first-battle-map-handpaint-prod-browser-check.png differ diff --git a/scripts/generate-handpaint-map-sample.py b/scripts/generate-handpaint-map-sample.py new file mode 100644 index 0000000..aa9601d --- /dev/null +++ b/scripts/generate-handpaint-map-sample.py @@ -0,0 +1,533 @@ +from __future__ import annotations + +import argparse +import ast +import math +import random +import re +from pathlib import Path + +from PIL import Image, ImageChops, ImageDraw, ImageEnhance, ImageFilter, ImageOps + + +ROOT = Path(__file__).resolve().parents[1] +SCENARIO_FILE = ROOT / "src" / "game" / "data" / "scenario.ts" +FIRST_BATTLE_MAP = ROOT / "src" / "assets" / "images" / "battle" / "first-battle-map.webp" +DOCS_DIR = ROOT / "docs" + +TILE = 224 +RNG = random.Random(240704) + + +PALETTE = { + "plain": (88, 104, 62), + "plain_dark": (59, 75, 44), + "plain_light": (128, 132, 76), + "road": (185, 146, 91), + "road_shadow": (111, 83, 55), + "road_light": (221, 184, 117), + "forest_dark": (31, 70, 35), + "forest": (61, 105, 47), + "forest_light": (105, 144, 67), + "hill": (134, 119, 77), + "hill_dark": (77, 72, 55), + "hill_light": (178, 158, 103), + "cliff": (86, 75, 63), + "cliff_light": (132, 115, 88), + "water": (58, 112, 123), + "water_dark": (37, 77, 91), + "water_light": (122, 171, 174), + "wood": (74, 51, 36), + "wood_light": (127, 86, 48), + "roof": (158, 105, 42), + "canvas": (204, 169, 95), + "flag": (188, 151, 52), +} + + +def clamp(value: int) -> int: + return max(0, min(255, value)) + + +def jitter(color: tuple[int, int, int], amount: int) -> tuple[int, int, int]: + return tuple(clamp(channel + RNG.randint(-amount, amount)) for channel in color) + + +def parse_first_battle_terrain() -> list[list[str]]: + text = SCENARIO_FILE.read_text(encoding="utf-8") + start = text.index("export const firstBattleMap") + terrain_key = text.index("terrain:", start) + bracket_start = text.index("[", terrain_key) + depth = 0 + for index in range(bracket_start, len(text)): + char = text[index] + if char == "[": + depth += 1 + elif char == "]": + depth -= 1 + if depth == 0: + return ast.literal_eval(text[bracket_start : index + 1]) + raise RuntimeError("Could not parse first battle terrain") + + +def parse_first_battle_units() -> list[dict[str, object]]: + text = SCENARIO_FILE.read_text(encoding="utf-8") + block_start = text.index("export const firstBattleUnits") + block_end = text.index("const secondBattleAllyPositions", block_start) + block = text[block_start:block_end] + units: list[dict[str, object]] = [] + for match in re.finditer(r"id: '([^']+)'.*?name: '([^']+)'.*?faction: '([^']+)'.*?x: (\d+),\s*y: (\d+)", block, re.S): + units.append( + { + "id": match.group(1), + "name": match.group(2), + "faction": match.group(3), + "x": int(match.group(4)), + "y": int(match.group(5)), + } + ) + return units + + +def tile_box(x: int, y: int, pad: int = 0) -> tuple[int, int, int, int]: + return (x * TILE - pad, y * TILE - pad, (x + 1) * TILE + pad, (y + 1) * TILE + pad) + + +def center(x: int, y: int) -> tuple[int, int]: + return (x * TILE + TILE // 2, y * TILE + TILE // 2) + + +def organic_ellipse_points(cx: int, cy: int, rx: int, ry: int, count: int = 24, wobble: float = 0.15) -> list[tuple[int, int]]: + points: list[tuple[int, int]] = [] + phase = RNG.random() * math.tau + for index in range(count): + angle = math.tau * index / count + wave = 1 + math.sin(angle * 3 + phase) * wobble * 0.45 + scale = wave + RNG.uniform(-wobble, wobble) + points.append((int(cx + math.cos(angle) * rx * scale), int(cy + math.sin(angle) * ry * scale))) + return points + + +def organic_rect_points(left: int, top: int, right: int, bottom: int, step: int = 72, wobble: int = 18) -> list[tuple[int, int]]: + points: list[tuple[int, int]] = [] + for x in range(left, right + 1, step): + points.append((x + RNG.randint(-wobble, wobble), top + RNG.randint(-wobble, wobble))) + for y in range(top + step, bottom + 1, step): + points.append((right + RNG.randint(-wobble, wobble), y + RNG.randint(-wobble, wobble))) + for x in range(right - step, left - 1, -step): + points.append((x + RNG.randint(-wobble, wobble), bottom + RNG.randint(-wobble, wobble))) + for y in range(bottom - step, top, -step): + points.append((left + RNG.randint(-wobble, wobble), y + RNG.randint(-wobble, wobble))) + return points + + +def add_base_texture(width: int, height: int) -> Image.Image: + base = Image.new("RGB", (width, height), PALETTE["plain"]) + noise = Image.effect_noise((width, height), 28).convert("L") + color_noise = ImageOps.colorize(noise, (54, 66, 42), (126, 123, 73)) + base = Image.blend(base, color_noise, 0.22) + + detail = Image.new("RGBA", (width, height), (0, 0, 0, 0)) + draw = ImageDraw.Draw(detail) + for _ in range(42000): + x = RNG.randrange(width) + y = RNG.randrange(height) + length = RNG.randint(8, 24) + angle = RNG.uniform(-0.8, 0.8) + color = jitter(RNG.choice([PALETTE["plain_light"], PALETTE["plain_dark"], (101, 94, 55)]), 18) + alpha = RNG.randint(20, 54) + draw.line((x, y, x + math.cos(angle) * length, y + math.sin(angle) * length), fill=(*color, alpha), width=RNG.choice([1, 1, 2])) + + wash = Image.new("RGBA", (width, height), (0, 0, 0, 0)) + wash_draw = ImageDraw.Draw(wash) + for _ in range(220): + x = RNG.randint(-200, width + 100) + y = RNG.randint(-200, height + 100) + rx = RNG.randint(160, 520) + ry = RNG.randint(120, 420) + color = RNG.choice([(132, 125, 70, 16), (46, 64, 40, 20), (106, 91, 54, 14)]) + wash_draw.ellipse((x - rx, y - ry, x + rx, y + ry), fill=color) + detail = Image.alpha_composite(detail, wash.filter(ImageFilter.GaussianBlur(46))) + + return Image.alpha_composite(base.convert("RGBA"), detail) + + +def draw_shadow(draw: ImageDraw.ImageDraw, points: list[tuple[int, int]], alpha: int = 46) -> None: + offset = [(x + 12, y + 16) for x, y in points] + draw.polygon(offset, fill=(22, 25, 20, alpha)) + + +def paint_water(canvas: Image.Image, terrain: list[list[str]]) -> None: + layer = Image.new("RGBA", canvas.size, (0, 0, 0, 0)) + draw = ImageDraw.Draw(layer) + visited: set[tuple[int, int]] = set() + + for sy, row in enumerate(terrain): + for sx, kind in enumerate(row): + if kind != "river" or (sx, sy) in visited: + continue + stack = [(sx, sy)] + component: list[tuple[int, int]] = [] + visited.add((sx, sy)) + while stack: + x, y = stack.pop() + component.append((x, y)) + for dx, dy in ((1, 0), (-1, 0), (0, 1), (0, -1)): + nx, ny = x + dx, y + dy + if ( + 0 <= ny < len(terrain) + and 0 <= nx < len(terrain[0]) + and terrain[ny][nx] == "river" + and (nx, ny) not in visited + ): + visited.add((nx, ny)) + stack.append((nx, ny)) + + min_x = min(x for x, _ in component) + max_x = max(x for x, _ in component) + min_y = min(y for _, y in component) + max_y = max(y for _, y in component) + left = min_x * TILE + 20 + top = min_y * TILE + 18 + right = (max_x + 1) * TILE - 20 + bottom = (max_y + 1) * TILE - 18 + shadow = organic_rect_points(left - 8, top - 6, right + 10, bottom + 14, step=86, wobble=20) + bank = organic_rect_points(left - 3, top - 3, right + 3, bottom + 4, step=80, wobble=17) + inner = organic_rect_points(left + 13, top + 13, right - 13, bottom - 13, step=70, wobble=13) + draw.polygon(shadow, fill=(*PALETTE["water_dark"], 78)) + draw.polygon(bank, fill=(62, 76, 49, 88)) + draw.polygon(inner, fill=(*jitter(PALETTE["water"], 8), 214)) + + for x, y in component: + tile_left, tile_top, tile_right, tile_bottom = tile_box(x, y) + for _ in range(18): + rx = RNG.randint(tile_left + 28, tile_right - 56) + ry = RNG.randint(tile_top + 26, tile_bottom - 30) + span = RNG.randint(30, 84) + color = (*jitter(PALETTE["water_light"], 16), RNG.randint(58, 104)) + draw.arc((rx, ry, rx + span, ry + RNG.randint(10, 30)), 190, 350, fill=color, width=RNG.choice([2, 3])) + if RNG.random() < 0.45: + mx = RNG.randint(tile_left + 35, tile_right - 35) + my = RNG.randint(tile_top + 35, tile_bottom - 35) + draw.polygon(organic_ellipse_points(mx, my, RNG.randint(12, 30), RNG.randint(8, 18), 12, 0.22), fill=(67, 86, 50, 44)) + canvas.alpha_composite(layer.filter(ImageFilter.GaussianBlur(0.35))) + + +def road_neighbors(terrain: list[list[str]], x: int, y: int) -> list[tuple[int, int]]: + result = [] + for dx, dy in ((1, 0), (-1, 0), (0, 1), (0, -1)): + nx, ny = x + dx, y + dy + if 0 <= ny < len(terrain) and 0 <= nx < len(terrain[0]) and terrain[ny][nx] in {"road", "camp", "village"}: + result.append((nx, ny)) + return result + + +def paint_path_segment( + draw: ImageDraw.ImageDraw, + start: tuple[int, int], + end: tuple[int, int], + width: int, + color: tuple[int, int, int], + alpha: int, + wobble: int, +) -> None: + sx, sy = start + ex, ey = end + dx = ex - sx + dy = ey - sy + distance = math.hypot(dx, dy) + if distance == 0: + return + nx = -dy / distance + ny = dx / distance + count = max(5, int(distance // 58)) + centers: list[tuple[float, float]] = [] + for index in range(count + 1): + t = index / count + taper = math.sin(t * math.pi) + px = sx + dx * t + nx * RNG.randint(-wobble, wobble) * taper + py = sy + dy * t + ny * RNG.randint(-wobble, wobble) * taper + centers.append((px, py)) + + left_side: list[tuple[int, int]] = [] + right_side: list[tuple[int, int]] = [] + for px, py in centers: + local_width = width * (0.92 + RNG.random() * 0.2) + edge_jitter = RNG.randint(-wobble, wobble) + left_side.append((int(px + nx * (local_width / 2 + edge_jitter)), int(py + ny * (local_width / 2 + edge_jitter)))) + edge_jitter = RNG.randint(-wobble, wobble) + right_side.append((int(px - nx * (local_width / 2 + edge_jitter)), int(py - ny * (local_width / 2 + edge_jitter)))) + + draw.polygon(left_side + right_side[::-1], fill=(*color, alpha)) + + +def paint_roads(canvas: Image.Image, terrain: list[list[str]]) -> None: + shadow = Image.new("RGBA", canvas.size, (0, 0, 0, 0)) + road = Image.new("RGBA", canvas.size, (0, 0, 0, 0)) + shadow_draw = ImageDraw.Draw(shadow) + road_draw = ImageDraw.Draw(road) + + drawn_edges: set[tuple[tuple[int, int], tuple[int, int]]] = set() + for y, row in enumerate(terrain): + for x, kind in enumerate(row): + if kind != "road": + continue + cx, cy = center(x, y) + for nx, ny in road_neighbors(terrain, x, y): + edge = tuple(sorted(((x, y), (nx, ny)))) + if edge in drawn_edges: + continue + drawn_edges.add(edge) + ncx, ncy = center(nx, ny) + paint_path_segment(shadow_draw, (cx + 10, cy + 14), (ncx + 10, ncy + 14), 100, PALETTE["road_shadow"], 54, 13) + paint_path_segment(road_draw, (cx, cy), (ncx, ncy), 96, jitter(PALETTE["road_shadow"], 8), 76, 15) + paint_path_segment(road_draw, (cx, cy), (ncx, ncy), RNG.randint(74, 86), jitter(PALETTE["road"], 9), 146, 18) + paint_path_segment(road_draw, (cx, cy), (ncx, ncy), RNG.randint(38, 48), jitter(PALETTE["road_light"], 10), 38, 10) + node_rx = RNG.randint(44, 59) + node_ry = RNG.randint(37, 53) + shadow_draw.polygon(organic_ellipse_points(cx + 10, cy + 14, node_rx + 8, node_ry + 7, 18, 0.18), fill=(*PALETTE["road_shadow"], 42)) + road_draw.polygon(organic_ellipse_points(cx, cy, node_rx, node_ry, 18, 0.2), fill=(*jitter(PALETTE["road"], 8), 118)) + + for y, row in enumerate(terrain): + for x, kind in enumerate(row): + if kind != "road": + continue + left, top, right, bottom = tile_box(x, y) + for _ in range(17): + px = RNG.randint(left + 20, right - 20) + py = RNG.randint(top + 20, bottom - 20) + road_draw.line((px, py, px + RNG.randint(-34, 42), py + RNG.randint(-14, 14)), fill=(*PALETTE["road_shadow"], RNG.randint(25, 58)), width=RNG.choice([2, 3])) + for _ in range(8): + px = RNG.randint(left + 25, right - 25) + py = RNG.randint(top + 25, bottom - 25) + road_draw.ellipse((px - 3, py - 2, px + 3, py + 2), fill=(*PALETTE["road_light"], 62)) + + canvas.alpha_composite(shadow.filter(ImageFilter.GaussianBlur(2.2))) + canvas.alpha_composite(road.filter(ImageFilter.GaussianBlur(0.35))) + + +def paint_hill(draw: ImageDraw.ImageDraw, x: int, y: int, kind: str) -> None: + left, top, right, bottom = tile_box(x, y) + cx, cy = center(x, y) + rock_count = 6 if kind == "hill" else 8 + for _ in range(rock_count): + rx = cx + RNG.randint(-62, 58) + ry = cy + RNG.randint(-54, 52) + rw = RNG.randint(42, 92) + rh = RNG.randint(24, 58) + color = jitter(PALETTE["hill"] if kind == "hill" else PALETTE["cliff"], 16) + draw.ellipse((rx - rw // 2 + 7, ry - rh // 2 + 10, rx + rw // 2 + 7, ry + rh // 2 + 10), fill=(*PALETTE["hill_dark"], 45)) + draw.ellipse((rx - rw // 2, ry - rh // 2, rx + rw // 2, ry + rh // 2), fill=(*color, 168)) + draw.arc((rx - rw // 2 + 6, ry - rh // 2 + 5, rx + rw // 2 - 5, ry + rh // 2), 190, 315, fill=(*PALETTE["hill_light"], 82), width=3) + if kind == "cliff": + for _ in range(4): + points = [ + (RNG.randint(left + 20, right - 30), RNG.randint(top + 18, bottom - 20)), + (RNG.randint(left + 40, right - 20), RNG.randint(top + 20, bottom - 20)), + (RNG.randint(left + 20, right - 20), RNG.randint(top + 45, bottom - 16)), + ] + draw_shadow(draw, points, 44) + draw.polygon(points, fill=(*jitter(PALETTE["cliff_light"], 12), 135)) + + +def paint_forest(draw: ImageDraw.ImageDraw, x: int, y: int) -> None: + left, top, right, bottom = tile_box(x, y) + cx, cy = center(x, y) + draw.ellipse((left + 20, top + 28, right - 12, bottom - 4), fill=(17, 38, 24, 86)) + blob_count = RNG.randint(13, 19) + for _ in range(blob_count): + bx = cx + RNG.randint(-76, 75) + by = cy + RNG.randint(-72, 64) + radius = RNG.randint(24, 47) + color = jitter(RNG.choice([PALETTE["forest"], PALETTE["forest_light"], PALETTE["forest_dark"]]), 14) + draw.ellipse((bx - radius + 5, by - radius + 8, bx + radius + 5, by + radius + 8), fill=(10, 30, 18, 72)) + draw.ellipse((bx - radius, by - radius, bx + radius, by + radius), fill=(*color, 218)) + draw.arc((bx - radius + 7, by - radius + 6, bx + radius - 7, by + radius - 7), 195, 322, fill=(142, 165, 76, 84), width=3) + for _ in range(7): + tx = RNG.randint(left + 28, right - 28) + ty = RNG.randint(top + 62, bottom - 18) + draw.line((tx, ty, tx + RNG.randint(-6, 6), ty + RNG.randint(13, 27)), fill=(54, 39, 24, 92), width=RNG.randint(4, 7)) + + +def paint_structure(draw: ImageDraw.ImageDraw, x: int, y: int, kind: str) -> None: + left, top, right, bottom = tile_box(x, y) + cx, cy = center(x, y) + if kind == "village": + draw.ellipse((left + 34, top + 48, right - 28, bottom - 20), fill=(*PALETTE["road_shadow"], 52)) + for ox, oy, scale in [(-34, -8, 1.0), (28, 12, 0.86)]: + bx, by = cx + ox, cy + oy + w, h = int(56 * scale), int(44 * scale) + draw.rectangle((bx - w // 2, by - 4, bx + w // 2, by + h), fill=(*jitter((142, 102, 58), 12), 220)) + roof = [(bx - w // 2 - 10, by - 2), (bx, by - 44), (bx + w // 2 + 10, by - 2)] + draw.polygon([(px + 6, py + 9) for px, py in roof], fill=(48, 34, 24, 68)) + draw.polygon(roof, fill=(*jitter(PALETTE["roof"], 10), 235)) + draw.line((bx - w // 2 - 5, by, bx + w // 2 + 5, by), fill=(230, 170, 74, 72), width=3) + for _ in range(5): + px = RNG.randint(left + 30, right - 30) + py = RNG.randint(top + 120, bottom - 20) + draw.line((px, py, px + RNG.randint(18, 34), py + RNG.randint(-4, 5)), fill=(*PALETTE["wood"], 120), width=3) + elif kind == "camp": + draw.ellipse((left + 24, top + 34, right - 18, bottom - 10), fill=(*PALETTE["road_shadow"], 54)) + for ox, oy in [(-32, -18), (34, 8), (-8, 36)]: + bx, by = cx + ox, cy + oy + tent = [(bx - 38, by + 34), (bx, by - 38), (bx + 42, by + 34)] + draw.polygon([(px + 5, py + 9) for px, py in tent], fill=(48, 34, 24, 70)) + draw.polygon(tent, fill=(*jitter(PALETTE["canvas"], 12), 232)) + draw.line((bx, by - 35, bx, by + 32), fill=(*PALETTE["wood"], 140), width=4) + draw.polygon([(bx + 5, by - 35), (bx + 42, by - 24), (bx + 5, by - 14)], fill=(*PALETTE["flag"], 210)) + elif kind == "fort": + draw.ellipse((left + 22, top + 34, right - 16, bottom - 14), fill=(*PALETTE["road_shadow"], 62)) + posts = [(left + 48, top + 52), (right - 52, top + 48), (left + 50, bottom - 60), (right - 54, bottom - 64)] + for px, py in posts: + draw.rectangle((px - 9, py - 36, px + 9, py + 38), fill=(*jitter(PALETTE["wood"], 8), 230)) + for yline in (top + 72, bottom - 78): + draw.line((left + 40, yline, right - 44, yline + RNG.randint(-5, 5)), fill=(*jitter(PALETTE["wood_light"], 12), 224), width=12) + draw.line((left + 52, yline + 20, right - 56, yline + 17), fill=(*jitter(PALETTE["wood"], 8), 220), width=9) + draw.rectangle((cx - 42, cy - 28, cx + 38, cy + 35), fill=(*jitter((92, 65, 42), 12), 220)) + draw.polygon([(cx - 56, cy - 25), (cx, cy - 68), (cx + 54, cy - 25)], fill=(*jitter(PALETTE["roof"], 9), 225)) + draw.polygon([(cx + 18, cy - 69), (cx + 61, cy - 56), (cx + 18, cy - 40)], fill=(*PALETTE["flag"], 210)) + + +def paint_tiles(canvas: Image.Image, terrain: list[list[str]]) -> None: + layer = Image.new("RGBA", canvas.size, (0, 0, 0, 0)) + draw = ImageDraw.Draw(layer) + for y, row in enumerate(terrain): + for x, kind in enumerate(row): + if kind in {"hill", "cliff"}: + paint_hill(draw, x, y, kind) + elif kind == "forest": + paint_forest(draw, x, y) + canvas.alpha_composite(layer.filter(ImageFilter.GaussianBlur(0.25))) + + structure = Image.new("RGBA", canvas.size, (0, 0, 0, 0)) + structure_draw = ImageDraw.Draw(structure) + for y, row in enumerate(terrain): + for x, kind in enumerate(row): + if kind in {"village", "camp", "fort"}: + paint_structure(structure_draw, x, y, kind) + canvas.alpha_composite(structure.filter(ImageFilter.GaussianBlur(0.15))) + + +def add_route_and_battlefield_details(canvas: Image.Image, terrain: list[list[str]], units: list[dict[str, object]]) -> None: + details = Image.new("RGBA", canvas.size, (0, 0, 0, 0)) + draw = ImageDraw.Draw(details) + + for unit in units: + x, y = int(unit["x"]), int(unit["y"]) + cx, cy = center(x, y) + if unit["faction"] == "enemy": + color = (117, 76, 38, 72) + if "cavalry" in str(unit["id"]): + color = (94, 67, 38, 58) + else: + color = (90, 80, 42, 38) + draw.ellipse((cx - 54, cy - 30, cx + 54, cy + 30), fill=color) + if unit["faction"] == "enemy": + for _ in range(3): + px = cx + RNG.randint(-60, 60) + py = cy + RNG.randint(-44, 44) + draw.line((px, py, px + RNG.randint(-22, 25), py + RNG.randint(-6, 8)), fill=(92, 58, 34, 58), width=3) + + width, height = canvas.size + for _ in range(2400): + x = RNG.randrange(width) + y = RNG.randrange(height) + color = RNG.choice([(137, 100, 52, 40), (188, 139, 70, 35), (58, 73, 43, 32)]) + draw.ellipse((x - 2, y - 1, x + 2, y + 1), fill=color) + + canvas.alpha_composite(details.filter(ImageFilter.GaussianBlur(0.15))) + + +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)) + draw = ImageDraw.Draw(overlay) + for x in range(1, len(terrain[0])): + alpha = 11 if x % 2 else 8 + draw.line((x * TILE, 0, x * TILE, height), fill=(19, 26, 19, alpha), width=2) + for y in range(1, len(terrain)): + alpha = 11 if y % 2 else 8 + draw.line((0, y * TILE, width, y * TILE), fill=(19, 26, 19, alpha), width=2) + + vignette = Image.new("L", (width, height), 0) + vdraw = ImageDraw.Draw(vignette) + margin = int(min(width, height) * 0.04) + vdraw.rounded_rectangle((margin, margin, width - margin, height - margin), radius=260, fill=255) + vignette = vignette.filter(ImageFilter.GaussianBlur(360)) + dark = Image.new("RGBA", (width, height), (18, 20, 14, 58)) + overlay = Image.composite(Image.new("RGBA", (width, height), (0, 0, 0, 0)), dark, vignette) + canvas.alpha_composite(overlay) + + graded = ImageEnhance.Color(canvas.convert("RGB")).enhance(0.88) + graded = ImageEnhance.Contrast(graded).enhance(0.96) + graded = ImageEnhance.Brightness(graded).enhance(0.96) + return graded + + +def build_map() -> Image.Image: + terrain = parse_first_battle_terrain() + units = parse_first_battle_units() + width = len(terrain[0]) * TILE + height = len(terrain) * TILE + canvas = add_base_texture(width, height) + paint_water(canvas, terrain) + paint_roads(canvas, terrain) + paint_tiles(canvas, terrain) + add_route_and_battlefield_details(canvas, terrain, units) + return add_subtle_grid_and_grade(canvas, terrain) + + +def make_comparison(before: Image.Image, after: Image.Image, out_path: Path) -> None: + preview_width = 1400 + before_small = before.resize((preview_width, int(before.height * preview_width / before.width)), Image.Resampling.LANCZOS) + after_small = after.resize(before_small.size, Image.Resampling.LANCZOS) + padding = 36 + label_height = 58 + combined = Image.new("RGB", (before_small.width * 2 + padding * 3, before_small.height + padding * 2 + label_height), (20, 24, 21)) + combined.paste(before_small, (padding, padding + label_height)) + combined.paste(after_small, (padding * 2 + before_small.width, padding + label_height)) + draw = ImageDraw.Draw(combined) + draw.text((padding, padding + 10), "Before", fill=(232, 219, 178)) + draw.text((padding * 2 + before_small.width, padding + 10), "After: hand-painted tactical sample", fill=(232, 219, 178)) + out_path.parent.mkdir(parents=True, exist_ok=True) + combined.save(out_path, quality=92) + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--out", type=Path, default=FIRST_BATTLE_MAP) + parser.add_argument("--before", type=Path, default=FIRST_BATTLE_MAP) + parser.add_argument("--comparison", type=Path, default=DOCS_DIR / "first-battle-map-handpaint-before-after.png") + parser.add_argument("--preview", type=Path, default=DOCS_DIR / "first-battle-map-handpaint-preview.png") + parser.add_argument("--webp-quality", type=int, default=90) + args = parser.parse_args() + + before = Image.open(args.before).convert("RGB") + after = build_map() + if before.size != after.size: + raise RuntimeError(f"Map size changed from {before.size} to {after.size}") + + args.out.parent.mkdir(parents=True, exist_ok=True) + if args.out.suffix.lower() == ".webp": + after.save(args.out, "WEBP", quality=args.webp_quality, method=6) + else: + output = after.quantize(colors=224, method=Image.Quantize.MEDIANCUT, dither=Image.Dither.NONE) + output.save(args.out, optimize=True, compress_level=9) + make_comparison(before, after, args.comparison) + args.preview.parent.mkdir(parents=True, exist_ok=True) + after.resize((1400, int(after.height * 1400 / after.width)), Image.Resampling.LANCZOS).save(args.preview, quality=92) + + diff = ImageChops.difference(before.resize((400, 360)), after.resize((400, 360))).convert("L") + print( + { + "out": str(args.out), + "size": after.size, + "comparison": str(args.comparison), + "preview": str(args.preview), + "avg_preview_delta": round(sum(diff.tobytes()) / (400 * 360), 2), + } + ) + + +if __name__ == "__main__": + main() diff --git a/src/assets/images/battle/first-battle-map.png b/src/assets/images/battle/first-battle-map.png deleted file mode 100644 index 3a6df29..0000000 Binary files a/src/assets/images/battle/first-battle-map.png and /dev/null differ diff --git a/src/assets/images/battle/first-battle-map.webp b/src/assets/images/battle/first-battle-map.webp new file mode 100644 index 0000000..3c76c66 Binary files /dev/null and b/src/assets/images/battle/first-battle-map.webp differ diff --git a/src/game/data/battleMapAssets.ts b/src/game/data/battleMapAssets.ts index 6294fc2..f4b0d37 100644 --- a/src/game/data/battleMapAssets.ts +++ b/src/game/data/battleMapAssets.ts @@ -20,7 +20,7 @@ import fiftySeventhBattleMapUrl from '../../assets/images/battle/fifty-seventh-b import fiftySixthBattleMapUrl from '../../assets/images/battle/fifty-sixth-battle-map.svg'; import fifthBattleMapUrl from '../../assets/images/battle/fifth-battle-map.svg'; import fifteenthBattleMapUrl from '../../assets/images/battle/fifteenth-battle-map.svg'; -import firstBattleMapUrl from '../../assets/images/battle/first-battle-map.png'; +import firstBattleMapUrl from '../../assets/images/battle/first-battle-map.webp'; import fortiethBattleMapUrl from '../../assets/images/battle/fortieth-battle-map.svg'; import fortyEighthBattleMapUrl from '../../assets/images/battle/forty-eighth-battle-map.svg'; import fortyFifthBattleMapUrl from '../../assets/images/battle/forty-fifth-battle-map.svg';