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';