diff --git a/docs/battle-map-terrain-atlas-pipeline.md b/docs/battle-map-terrain-atlas-pipeline.md new file mode 100644 index 0000000..b874fb5 --- /dev/null +++ b/docs/battle-map-terrain-atlas-pipeline.md @@ -0,0 +1,38 @@ +# Hand-Painted Tactical Map Pipeline + +## Scope + +The first three campaign battles now use the same hand-painted tactical map pipeline. The renderer still reads the original scenario tile coordinates and battle logic; only the map texture asset changes from flat/vector map art to low-contrast raster terrain. + +## Art Direction + +- Units must read before the terrain at 100% desktop browser scale. +- Terrain uses muted greens, ochre roads, dark blue-green water, and compact structure markers. +- Roads, forests, rivers, camps, villages, cliffs, and forts should be recognizable by silhouette and texture, not by high saturation. +- The grid remains subtle and embedded in the paint grade, so it helps targeting without becoming the main visual layer. + +## Generator + +`scripts/generate-handpaint-map-sample.py` reads terrain and unit positions from `src/game/data/scenario.ts`, then outputs a WebP map and documentation previews. + +Example: + +```powershell +python scripts/generate-handpaint-map-sample.py --map second --webp-quality 90 +python scripts/generate-handpaint-map-sample.py --map third --webp-quality 90 +``` + +## Current Assets + +- `src/assets/images/battle/first-battle-map.webp` +- `src/assets/images/battle/second-battle-map.webp` +- `src/assets/images/battle/third-battle-map.webp` + +## QA Outputs + +- `docs/second-battle-map-handpaint-before-after.png` +- `docs/second-battle-map-handpaint-preview.png` +- `docs/third-battle-map-handpaint-before-after.png` +- `docs/third-battle-map-handpaint-preview.png` + +Browser screenshots are saved per verification run after build/deploy. diff --git a/docs/second-battle-map-handpaint-before-after.png b/docs/second-battle-map-handpaint-before-after.png new file mode 100644 index 0000000..769d58b Binary files /dev/null and b/docs/second-battle-map-handpaint-before-after.png differ diff --git a/docs/second-battle-map-handpaint-local-active-check.png b/docs/second-battle-map-handpaint-local-active-check.png new file mode 100644 index 0000000..eeb36b6 Binary files /dev/null and b/docs/second-battle-map-handpaint-local-active-check.png differ diff --git a/docs/second-battle-map-handpaint-local-battle-check.png b/docs/second-battle-map-handpaint-local-battle-check.png new file mode 100644 index 0000000..1baa450 Binary files /dev/null and b/docs/second-battle-map-handpaint-local-battle-check.png differ diff --git a/docs/second-battle-map-handpaint-preview.png b/docs/second-battle-map-handpaint-preview.png new file mode 100644 index 0000000..32b5a98 Binary files /dev/null and b/docs/second-battle-map-handpaint-preview.png differ diff --git a/docs/second-battle-map-handpaint-prod-active-check.png b/docs/second-battle-map-handpaint-prod-active-check.png new file mode 100644 index 0000000..f08cdae Binary files /dev/null and b/docs/second-battle-map-handpaint-prod-active-check.png differ diff --git a/docs/third-battle-map-handpaint-before-after.png b/docs/third-battle-map-handpaint-before-after.png new file mode 100644 index 0000000..9207245 Binary files /dev/null and b/docs/third-battle-map-handpaint-before-after.png differ diff --git a/docs/third-battle-map-handpaint-local-active-check.png b/docs/third-battle-map-handpaint-local-active-check.png new file mode 100644 index 0000000..2cea418 Binary files /dev/null and b/docs/third-battle-map-handpaint-local-active-check.png differ diff --git a/docs/third-battle-map-handpaint-local-battle-check.png b/docs/third-battle-map-handpaint-local-battle-check.png new file mode 100644 index 0000000..a238830 Binary files /dev/null and b/docs/third-battle-map-handpaint-local-battle-check.png differ diff --git a/docs/third-battle-map-handpaint-preview.png b/docs/third-battle-map-handpaint-preview.png new file mode 100644 index 0000000..b3990ee Binary files /dev/null and b/docs/third-battle-map-handpaint-preview.png differ diff --git a/docs/third-battle-map-handpaint-prod-active-check.png b/docs/third-battle-map-handpaint-prod-active-check.png new file mode 100644 index 0000000..9db5351 Binary files /dev/null and b/docs/third-battle-map-handpaint-prod-active-check.png differ diff --git a/scripts/generate-handpaint-map-sample.py b/scripts/generate-handpaint-map-sample.py index aa9601d..4f9fc94 100644 --- a/scripts/generate-handpaint-map-sample.py +++ b/scripts/generate-handpaint-map-sample.py @@ -12,12 +12,42 @@ from PIL import Image, ImageChops, ImageDraw, ImageEnhance, ImageFilter, ImageOp 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" +BATTLE_IMAGE_DIR = ROOT / "src" / "assets" / "images" / "battle" DOCS_DIR = ROOT / "docs" TILE = 224 RNG = random.Random(240704) +MAP_CONFIGS = { + "first": { + "slug": "first-battle-map", + "terrain_export": "firstBattleMap", + "units_export": "firstBattleUnits", + "ally_positions_export": None, + "out": BATTLE_IMAGE_DIR / "first-battle-map.webp", + "before": BATTLE_IMAGE_DIR / "first-battle-map.webp", + "seed": 240704, + }, + "second": { + "slug": "second-battle-map", + "terrain_export": "secondBattleMap", + "units_export": "secondBattleUnits", + "ally_positions_export": "secondBattleAllyPositions", + "out": BATTLE_IMAGE_DIR / "second-battle-map.webp", + "before": BATTLE_IMAGE_DIR / "second-battle-map.svg", + "seed": 240714, + }, + "third": { + "slug": "third-battle-map", + "terrain_export": "thirdBattleMap", + "units_export": "thirdBattleUnits", + "ally_positions_export": "thirdBattleAllyPositions", + "out": BATTLE_IMAGE_DIR / "third-battle-map.webp", + "before": BATTLE_IMAGE_DIR / "third-battle-map.svg", + "seed": 240724, + }, +} + PALETTE = { "plain": (88, 104, 62), @@ -53,33 +83,76 @@ 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) +def extract_balanced(text: str, start: int, open_char: str, close_char: str) -> str: depth = 0 - for index in range(bracket_start, len(text)): + for index in range(start, len(text)): char = text[index] - if char == "[": + if char == open_char: depth += 1 - elif char == "]": + elif char == close_char: depth -= 1 if depth == 0: - return ast.literal_eval(text[bracket_start : index + 1]) - raise RuntimeError("Could not parse first battle terrain") + return text[start : index + 1] + raise RuntimeError(f"Could not find balanced block starting at {start}") -def parse_first_battle_units() -> list[dict[str, object]]: +def parse_battle_terrain(export_name: str) -> list[list[str]]: 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] + start = text.index(f"export const {export_name}") + terrain_key = text.index("terrain:", start) + bracket_start = text.index("[", terrain_key) + return ast.literal_eval(extract_balanced(text, bracket_start, "[", "]")) + + +def parse_export_array_source(export_name: str) -> str: + text = SCENARIO_FILE.read_text(encoding="utf-8") + start = text.index(f"export const {export_name}") + bracket_start = text.index("[", text.index("=", start)) + return extract_balanced(text, bracket_start, "[", "]") + + +def parse_ally_positions(export_name: str | None) -> list[dict[str, object]]: + if export_name is None: + return [] + text = SCENARIO_FILE.read_text(encoding="utf-8") + marker = f"const {export_name}" + if marker not in text: + return [] + start = text.index(marker) + brace_start = text.index("{", text.index("=", start)) + block = extract_balanced(text, brace_start, "{", "}") + display_names = { + "liu-bei": "Liu Bei", + "guan-yu": "Guan Yu", + "zhang-fei": "Zhang Fei", + } units: list[dict[str, object]] = [] - for match in re.finditer(r"id: '([^']+)'.*?name: '([^']+)'.*?faction: '([^']+)'.*?x: (\d+),\s*y: (\d+)", block, re.S): + for match in re.finditer(r"'([^']+)':\s*\{\s*x:\s*(\d+),\s*y:\s*(\d+)\s*\}", block): + unit_id = match.group(1) units.append( { - "id": match.group(1), + "id": unit_id, + "name": display_names.get(unit_id, unit_id), + "faction": "ally", + "x": int(match.group(2)), + "y": int(match.group(3)), + } + ) + return units + + +def parse_battle_units(units_export: str, ally_positions_export: str | None) -> list[dict[str, object]]: + block = parse_export_array_source(units_export) + units = parse_ally_positions(ally_positions_export) + seen = {str(unit["id"]) for unit in units} + for match in re.finditer(r"id: '([^']+)'.*?name: '([^']+)'.*?faction: '([^']+)'.*?x: (\d+),\s*y: (\d+)", block, re.S): + unit_id = match.group(1) + if unit_id in seen: + continue + seen.add(unit_id) + units.append( + { + "id": unit_id, "name": match.group(2), "faction": match.group(3), "x": int(match.group(4)), @@ -464,9 +537,12 @@ def add_subtle_grid_and_grade(canvas: Image.Image, terrain: list[list[str]]) -> return graded -def build_map() -> Image.Image: - terrain = parse_first_battle_terrain() - units = parse_first_battle_units() +def build_map(config: dict[str, object]) -> Image.Image: + global RNG + RNG = random.Random(int(config["seed"])) + terrain = parse_battle_terrain(str(config["terrain_export"])) + ally_positions_export = config["ally_positions_export"] + units = parse_battle_units(str(config["units_export"]), None if ally_positions_export is None else str(ally_positions_export)) width = len(terrain[0]) * TILE height = len(terrain) * TILE canvas = add_base_texture(width, height) @@ -477,6 +553,42 @@ def build_map() -> Image.Image: return add_subtle_grid_and_grade(canvas, terrain) +def make_flat_legacy_preview(terrain: list[list[str]], size: tuple[int, int]) -> Image.Image: + colors = { + "plain": (97, 116, 75), + "road": (161, 124, 72), + "forest": (50, 78, 42), + "hill": (119, 105, 72), + "cliff": (76, 68, 57), + "river": (46, 93, 113), + "village": (134, 92, 48), + "camp": (171, 137, 74), + "fort": (94, 72, 48), + } + width = len(terrain[0]) * 96 + height = len(terrain) * 96 + image = Image.new("RGB", (width, height), colors["plain"]) + draw = ImageDraw.Draw(image) + for y, row in enumerate(terrain): + for x, kind in enumerate(row): + left, top = x * 96, y * 96 + draw.rectangle((left, top, left + 96, top + 96), fill=colors.get(kind, colors["plain"])) + if kind == "road": + draw.line((left, top + 52, left + 96, top + 44), fill=(199, 159, 93), width=9) + elif kind == "forest": + for ox, oy in ((28, 30), (54, 38), (40, 60)): + draw.ellipse((left + ox - 16, top + oy - 16, left + ox + 16, top + oy + 16), fill=(73, 111, 58)) + elif kind == "river": + draw.arc((left + 8, top + 20, left + 90, top + 78), 190, 350, fill=(111, 165, 176), width=4) + return image.resize(size, Image.Resampling.LANCZOS) + + +def load_before_image(path: Path, terrain: list[list[str]], target_size: tuple[int, int]) -> Image.Image: + if path.exists() and path.suffix.lower() != ".svg": + return Image.open(path).convert("RGB") + return make_flat_legacy_preview(terrain, target_size) + + 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) @@ -488,43 +600,53 @@ def make_comparison(before: Image.Image, after: Image.Image, out_path: Path) -> 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)) + draw.text((padding * 2 + before_small.width, padding + 10), "After: hand-painted tactical map", 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("--map", choices=sorted(MAP_CONFIGS), default="first") + parser.add_argument("--out", type=Path) + parser.add_argument("--before", type=Path) + parser.add_argument("--comparison", type=Path) + parser.add_argument("--preview", type=Path) 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}") + config = MAP_CONFIGS[args.map] + out_path = args.out or Path(config["out"]) + before_path = args.before or Path(config["before"]) + comparison_path = args.comparison or (DOCS_DIR / f"{config['slug']}-handpaint-before-after.png") + preview_path = args.preview or (DOCS_DIR / f"{config['slug']}-handpaint-preview.png") - 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) + 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) + + 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) 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) + output.save(out_path, optimize=True, compress_level=9) + make_comparison(before, 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) - diff = ImageChops.difference(before.resize((400, 360)), after.resize((400, 360))).convert("L") + diff_size = (400, int(after.height * 400 / after.width)) + diff = ImageChops.difference(before.resize(diff_size), after.resize(diff_size)).convert("L") print( { - "out": str(args.out), + "map": args.map, + "out": str(out_path), "size": after.size, - "comparison": str(args.comparison), - "preview": str(args.preview), - "avg_preview_delta": round(sum(diff.tobytes()) / (400 * 360), 2), + "comparison": str(comparison_path), + "preview": str(preview_path), + "avg_preview_delta": round(sum(diff.tobytes()) / (diff_size[0] * diff_size[1]), 2), } ) diff --git a/src/assets/images/battle/second-battle-map.svg b/src/assets/images/battle/second-battle-map.svg deleted file mode 100644 index 5b551c9..0000000 --- a/src/assets/images/battle/second-battle-map.svg +++ /dev/null @@ -1,101 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/assets/images/battle/second-battle-map.webp b/src/assets/images/battle/second-battle-map.webp new file mode 100644 index 0000000..e119083 Binary files /dev/null and b/src/assets/images/battle/second-battle-map.webp differ diff --git a/src/assets/images/battle/third-battle-map.svg b/src/assets/images/battle/third-battle-map.svg deleted file mode 100644 index 98d3633..0000000 --- a/src/assets/images/battle/third-battle-map.svg +++ /dev/null @@ -1,81 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/assets/images/battle/third-battle-map.webp b/src/assets/images/battle/third-battle-map.webp new file mode 100644 index 0000000..56e8921 Binary files /dev/null and b/src/assets/images/battle/third-battle-map.webp differ diff --git a/src/game/data/battleMapAssets.ts b/src/game/data/battleMapAssets.ts index f4b0d37..dd2dc47 100644 --- a/src/game/data/battleMapAssets.ts +++ b/src/game/data/battleMapAssets.ts @@ -35,7 +35,7 @@ import fourteenthBattleMapUrl from '../../assets/images/battle/fourteenth-battle import fourthBattleMapUrl from '../../assets/images/battle/fourth-battle-map.svg'; import nineteenthBattleMapUrl from '../../assets/images/battle/nineteenth-battle-map.svg'; import ninthBattleMapUrl from '../../assets/images/battle/ninth-battle-map.svg'; -import secondBattleMapUrl from '../../assets/images/battle/second-battle-map.svg'; +import secondBattleMapUrl from '../../assets/images/battle/second-battle-map.webp'; import seventhBattleMapUrl from '../../assets/images/battle/seventh-battle-map.svg'; import seventeenthBattleMapUrl from '../../assets/images/battle/seventeenth-battle-map.svg'; import sixteenthBattleMapUrl from '../../assets/images/battle/sixteenth-battle-map.svg'; @@ -52,7 +52,7 @@ import thirtySecondBattleMapUrl from '../../assets/images/battle/thirty-second-b import thirtySeventhBattleMapUrl from '../../assets/images/battle/thirty-seventh-battle-map.svg'; import thirtySixthBattleMapUrl from '../../assets/images/battle/thirty-sixth-battle-map.svg'; import thirtyThirdBattleMapUrl from '../../assets/images/battle/thirty-third-battle-map.svg'; -import thirdBattleMapUrl from '../../assets/images/battle/third-battle-map.svg'; +import thirdBattleMapUrl from '../../assets/images/battle/third-battle-map.webp'; import twentiethBattleMapUrl from '../../assets/images/battle/twentieth-battle-map.svg'; import twentyEighthBattleMapUrl from '../../assets/images/battle/twenty-eighth-battle-map.svg'; import twentyFifthBattleMapUrl from '../../assets/images/battle/twenty-fifth-battle-map.svg';