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 Image.MAX_IMAGE_PIXELS = None ROOT = Path(__file__).resolve().parents[1] SCENARIO_FILE = ROOT / "src" / "game" / "data" / "scenario.ts" BATTLE_IMAGE_DIR = ROOT / "src" / "assets" / "images" / "battle" DOCS_DIR = ROOT / "docs" DEFAULT_TILE_SIZE = 224 LARGE_MAP_TILE_SIZE = 192 VERY_LARGE_MAP_TILE_SIZE = 176 ULTRA_LARGE_MAP_TILE_SIZE = 160 EXTREME_LARGE_MAP_TILE_SIZE = 152 MASSIVE_LARGE_MAP_TILE_SIZE = 144 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) 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, }, "fourth": { "slug": "fourth-battle-map", "terrain_export": "fourthBattleMap", "units_export": "fourthBattleUnits", "ally_positions_export": "fourthBattleAllyPositions", "out": BATTLE_IMAGE_DIR / "fourth-battle-map.webp", "before": BATTLE_IMAGE_DIR / "fourth-battle-map.svg", "seed": 240734, }, "fifth": { "slug": "fifth-battle-map", "terrain_export": "fifthBattleMap", "units_export": "fifthBattleUnits", "ally_positions_export": "fifthBattleAllyPositions", "out": BATTLE_IMAGE_DIR / "fifth-battle-map.webp", "before": BATTLE_IMAGE_DIR / "fifth-battle-map.svg", "seed": 240744, }, "sixth": { "slug": "sixth-battle-map", "terrain_export": "sixthBattleMap", "units_export": "sixthBattleUnits", "ally_positions_export": "sixthBattleAllyPositions", "out": BATTLE_IMAGE_DIR / "sixth-battle-map.webp", "before": BATTLE_IMAGE_DIR / "sixth-battle-map.svg", "seed": 240754, }, "seventh": { "slug": "seventh-battle-map", "terrain_export": "seventhBattleMap", "units_export": "seventhBattleUnits", "ally_positions_export": "seventhBattleAllyPositions", "out": BATTLE_IMAGE_DIR / "seventh-battle-map.webp", "before": BATTLE_IMAGE_DIR / "seventh-battle-map.svg", "seed": 240764, }, "eighth": { "slug": "eighth-battle-map", "terrain_export": "eighthBattleMap", "units_export": "eighthBattleUnits", "ally_positions_export": "eighthBattleAllyPositions", "out": BATTLE_IMAGE_DIR / "eighth-battle-map.webp", "before": BATTLE_IMAGE_DIR / "eighth-battle-map.svg", "seed": 240774, }, "ninth": { "slug": "ninth-battle-map", "terrain_export": "ninthBattleMap", "units_export": "ninthBattleUnits", "ally_positions_export": "ninthBattleAllyPositions", "out": BATTLE_IMAGE_DIR / "ninth-battle-map.webp", "before": BATTLE_IMAGE_DIR / "ninth-battle-map.svg", "seed": 240784, }, "tenth": { "slug": "tenth-battle-map", "terrain_export": "tenthBattleMap", "units_export": "tenthBattleUnits", "ally_positions_export": "tenthBattleAllyPositions", "out": BATTLE_IMAGE_DIR / "tenth-battle-map.webp", "before": BATTLE_IMAGE_DIR / "tenth-battle-map.svg", "seed": 240794, }, "eleventh": { "slug": "eleventh-battle-map", "terrain_export": "eleventhBattleMap", "units_export": "eleventhBattleUnits", "ally_positions_export": "eleventhBattleAllyPositions", "out": BATTLE_IMAGE_DIR / "eleventh-battle-map.webp", "before": BATTLE_IMAGE_DIR / "eleventh-battle-map.svg", "seed": 240804, }, "twelfth": { "slug": "twelfth-battle-map", "terrain_export": "twelfthBattleMap", "units_export": "twelfthBattleUnits", "ally_positions_export": "twelfthBattleAllyPositions", "out": BATTLE_IMAGE_DIR / "twelfth-battle-map.webp", "before": BATTLE_IMAGE_DIR / "twelfth-battle-map.svg", "seed": 240814, }, "thirteenth": { "slug": "thirteenth-battle-map", "terrain_export": "thirteenthBattleMap", "units_export": "thirteenthBattleUnits", "ally_positions_export": "thirteenthBattleAllyPositions", "out": BATTLE_IMAGE_DIR / "thirteenth-battle-map.webp", "before": BATTLE_IMAGE_DIR / "thirteenth-battle-map.svg", "seed": 240824, }, "fourteenth": { "slug": "fourteenth-battle-map", "terrain_export": "fourteenthBattleMap", "units_export": "fourteenthBattleUnits", "ally_positions_export": "fourteenthBattleAllyPositions", "out": BATTLE_IMAGE_DIR / "fourteenth-battle-map.webp", "before": BATTLE_IMAGE_DIR / "fourteenth-battle-map.svg", "seed": 240834, }, "fifteenth": { "slug": "fifteenth-battle-map", "terrain_export": "fifteenthBattleMap", "units_export": "fifteenthBattleUnits", "ally_positions_export": "fifteenthBattleAllyPositions", "out": BATTLE_IMAGE_DIR / "fifteenth-battle-map.webp", "before": BATTLE_IMAGE_DIR / "fifteenth-battle-map.svg", "seed": 240844, }, "sixteenth": { "slug": "sixteenth-battle-map", "terrain_export": "sixteenthBattleMap", "units_export": "sixteenthBattleUnits", "ally_positions_export": "sixteenthBattleAllyPositions", "out": BATTLE_IMAGE_DIR / "sixteenth-battle-map.webp", "before": BATTLE_IMAGE_DIR / "sixteenth-battle-map.svg", "seed": 240854, }, "seventeenth": { "slug": "seventeenth-battle-map", "terrain_export": "seventeenthBattleMap", "units_export": "seventeenthBattleUnits", "ally_positions_export": "seventeenthBattleAllyPositions", "out": BATTLE_IMAGE_DIR / "seventeenth-battle-map.webp", "before": BATTLE_IMAGE_DIR / "seventeenth-battle-map.svg", "seed": 240864, }, "eighteenth": { "slug": "eighteenth-battle-map", "terrain_export": "eighteenthBattleMap", "units_export": "eighteenthBattleUnits", "ally_positions_export": "eighteenthBattleAllyPositions", "out": BATTLE_IMAGE_DIR / "eighteenth-battle-map.webp", "before": BATTLE_IMAGE_DIR / "eighteenth-battle-map.svg", "seed": 240874, }, "nineteenth": { "slug": "nineteenth-battle-map", "terrain_export": "nineteenthBattleMap", "units_export": "nineteenthBattleUnits", "ally_positions_export": "nineteenthBattleAllyPositions", "out": BATTLE_IMAGE_DIR / "nineteenth-battle-map.webp", "before": BATTLE_IMAGE_DIR / "nineteenth-battle-map.svg", "seed": 240884, }, "twentieth": { "slug": "twentieth-battle-map", "terrain_export": "twentiethBattleMap", "units_export": "twentiethBattleUnits", "ally_positions_export": "twentiethBattleAllyPositions", "out": BATTLE_IMAGE_DIR / "twentieth-battle-map.webp", "before": BATTLE_IMAGE_DIR / "twentieth-battle-map.svg", "seed": 240894, }, "twenty-first": { "slug": "twenty-first-battle-map", "terrain_export": "twentyFirstBattleMap", "units_export": "twentyFirstBattleUnits", "ally_positions_export": "twentyFirstBattleAllyPositions", "out": BATTLE_IMAGE_DIR / "twenty-first-battle-map.webp", "before": BATTLE_IMAGE_DIR / "twenty-first-battle-map.svg", "seed": 240904, }, "twenty-second": { "slug": "twenty-second-battle-map", "terrain_export": "twentySecondBattleMap", "units_export": "twentySecondBattleUnits", "ally_positions_export": "twentySecondBattleAllyPositions", "out": BATTLE_IMAGE_DIR / "twenty-second-battle-map.webp", "before": BATTLE_IMAGE_DIR / "twenty-second-battle-map.svg", "seed": 240914, }, "twenty-third": { "slug": "twenty-third-battle-map", "terrain_export": "twentyThirdBattleMap", "units_export": "twentyThirdBattleUnits", "ally_positions_export": "twentyThirdBattleAllyPositions", "out": BATTLE_IMAGE_DIR / "twenty-third-battle-map.webp", "before": BATTLE_IMAGE_DIR / "twenty-third-battle-map.svg", "seed": 240924, }, "twenty-fourth": { "slug": "twenty-fourth-battle-map", "terrain_export": "twentyFourthBattleMap", "units_export": "twentyFourthBattleUnits", "ally_positions_export": "twentyFourthBattleAllyPositions", "out": BATTLE_IMAGE_DIR / "twenty-fourth-battle-map.webp", "before": BATTLE_IMAGE_DIR / "twenty-fourth-battle-map.svg", "seed": 240934, }, "twenty-fifth": { "slug": "twenty-fifth-battle-map", "terrain_export": "twentyFifthBattleMap", "units_export": "twentyFifthBattleUnits", "ally_positions_export": "twentyFifthBattleAllyPositions", "out": BATTLE_IMAGE_DIR / "twenty-fifth-battle-map.webp", "before": BATTLE_IMAGE_DIR / "twenty-fifth-battle-map.svg", "seed": 240944, }, "twenty-sixth": { "slug": "twenty-sixth-battle-map", "terrain_export": "twentySixthBattleMap", "units_export": "twentySixthBattleUnits", "ally_positions_export": "twentySixthBattleAllyPositions", "out": BATTLE_IMAGE_DIR / "twenty-sixth-battle-map.webp", "before": BATTLE_IMAGE_DIR / "twenty-sixth-battle-map.svg", "seed": 240954, "tile_size": LARGE_MAP_TILE_SIZE, }, "twenty-seventh": { "slug": "twenty-seventh-battle-map", "terrain_export": "twentySeventhBattleMap", "units_export": "twentySeventhBattleUnits", "ally_positions_export": "twentySeventhBattleAllyPositions", "out": BATTLE_IMAGE_DIR / "twenty-seventh-battle-map.webp", "before": BATTLE_IMAGE_DIR / "twenty-seventh-battle-map.svg", "seed": 240964, "tile_size": LARGE_MAP_TILE_SIZE, }, "twenty-eighth": { "slug": "twenty-eighth-battle-map", "terrain_export": "twentyEighthBattleMap", "units_export": "twentyEighthBattleUnits", "ally_positions_export": "twentyEighthBattleAllyPositions", "out": BATTLE_IMAGE_DIR / "twenty-eighth-battle-map.webp", "before": BATTLE_IMAGE_DIR / "twenty-eighth-battle-map.svg", "seed": 240974, "tile_size": LARGE_MAP_TILE_SIZE, }, "twenty-ninth": { "slug": "twenty-ninth-battle-map", "terrain_export": "twentyNinthBattleMap", "units_export": "twentyNinthBattleUnits", "ally_positions_export": "twentyNinthBattleAllyPositions", "out": BATTLE_IMAGE_DIR / "twenty-ninth-battle-map.webp", "before": BATTLE_IMAGE_DIR / "twenty-ninth-battle-map.svg", "seed": 240984, "tile_size": LARGE_MAP_TILE_SIZE, }, "thirtieth": { "slug": "thirtieth-battle-map", "terrain_export": "thirtiethBattleMap", "units_export": "thirtiethBattleUnits", "ally_positions_export": "thirtiethBattleAllyPositions", "out": BATTLE_IMAGE_DIR / "thirtieth-battle-map.webp", "before": BATTLE_IMAGE_DIR / "thirtieth-battle-map.svg", "seed": 240994, "tile_size": LARGE_MAP_TILE_SIZE, }, "thirty-first": { "slug": "thirty-first-battle-map", "terrain_export": "thirtyFirstBattleMap", "units_export": "thirtyFirstBattleUnits", "ally_positions_export": "thirtyFirstBattleAllyPositions", "out": BATTLE_IMAGE_DIR / "thirty-first-battle-map.webp", "before": BATTLE_IMAGE_DIR / "thirty-first-battle-map.svg", "seed": 241004, "tile_size": LARGE_MAP_TILE_SIZE, }, "thirty-second": { "slug": "thirty-second-battle-map", "terrain_export": "thirtySecondBattleMap", "units_export": "thirtySecondBattleUnits", "ally_positions_export": "thirtySecondBattleAllyPositions", "out": BATTLE_IMAGE_DIR / "thirty-second-battle-map.webp", "before": BATTLE_IMAGE_DIR / "thirty-second-battle-map.svg", "seed": 241014, "tile_size": LARGE_MAP_TILE_SIZE, }, "thirty-third": { "slug": "thirty-third-battle-map", "terrain_export": "thirtyThirdBattleMap", "units_export": "thirtyThirdBattleUnits", "ally_positions_export": "thirtyThirdBattleAllyPositions", "out": BATTLE_IMAGE_DIR / "thirty-third-battle-map.webp", "before": BATTLE_IMAGE_DIR / "thirty-third-battle-map.svg", "seed": 241024, "tile_size": LARGE_MAP_TILE_SIZE, }, "thirty-fourth": { "slug": "thirty-fourth-battle-map", "terrain_export": "thirtyFourthBattleMap", "units_export": "thirtyFourthBattleUnits", "ally_positions_export": "thirtyFourthBattleAllyPositions", "out": BATTLE_IMAGE_DIR / "thirty-fourth-battle-map.webp", "before": BATTLE_IMAGE_DIR / "thirty-fourth-battle-map.svg", "seed": 241034, "tile_size": LARGE_MAP_TILE_SIZE, }, "thirty-fifth": { "slug": "thirty-fifth-battle-map", "terrain_export": "thirtyFifthBattleMap", "units_export": "thirtyFifthBattleUnits", "ally_positions_export": "thirtyFifthBattleAllyPositions", "out": BATTLE_IMAGE_DIR / "thirty-fifth-battle-map.webp", "before": BATTLE_IMAGE_DIR / "thirty-fifth-battle-map.svg", "seed": 241044, "tile_size": LARGE_MAP_TILE_SIZE, }, "thirty-sixth": { "slug": "thirty-sixth-battle-map", "terrain_export": "thirtySixthBattleMap", "units_export": "thirtySixthBattleUnits", "ally_positions_export": "thirtySixthBattleAllyPositions", "out": BATTLE_IMAGE_DIR / "thirty-sixth-battle-map.webp", "before": BATTLE_IMAGE_DIR / "thirty-sixth-battle-map.svg", "seed": 241054, "tile_size": LARGE_MAP_TILE_SIZE, }, "thirty-seventh": { "slug": "thirty-seventh-battle-map", "terrain_export": "thirtySeventhBattleMap", "units_export": "thirtySeventhBattleUnits", "ally_positions_export": "thirtySeventhBattleAllyPositions", "out": BATTLE_IMAGE_DIR / "thirty-seventh-battle-map.webp", "before": BATTLE_IMAGE_DIR / "thirty-seventh-battle-map.svg", "seed": 241064, "tile_size": LARGE_MAP_TILE_SIZE, }, "thirty-eighth": { "slug": "thirty-eighth-battle-map", "terrain_export": "thirtyEighthBattleMap", "units_export": "thirtyEighthBattleUnits", "ally_positions_export": "thirtyEighthBattleAllyPositions", "out": BATTLE_IMAGE_DIR / "thirty-eighth-battle-map.webp", "before": BATTLE_IMAGE_DIR / "thirty-eighth-battle-map.svg", "seed": 241074, "tile_size": LARGE_MAP_TILE_SIZE, }, "thirty-ninth": { "slug": "thirty-ninth-battle-map", "terrain_export": "thirtyNinthBattleMap", "units_export": "thirtyNinthBattleUnits", "ally_positions_export": "thirtyNinthBattleAllyPositions", "out": BATTLE_IMAGE_DIR / "thirty-ninth-battle-map.webp", "before": BATTLE_IMAGE_DIR / "thirty-ninth-battle-map.svg", "seed": 241084, "tile_size": LARGE_MAP_TILE_SIZE, }, "fortieth": { "slug": "fortieth-battle-map", "terrain_export": "fortiethBattleMap", "units_export": "fortiethBattleUnits", "ally_positions_export": "fortiethBattleAllyPositions", "out": BATTLE_IMAGE_DIR / "fortieth-battle-map.webp", "before": BATTLE_IMAGE_DIR / "fortieth-battle-map.svg", "seed": 241094, "tile_size": LARGE_MAP_TILE_SIZE, }, "forty-first": { "slug": "forty-first-battle-map", "terrain_export": "fortyFirstBattleMap", "units_export": "fortyFirstBattleUnits", "ally_positions_export": "fortyFirstBattleAllyPositions", "out": BATTLE_IMAGE_DIR / "forty-first-battle-map.webp", "before": BATTLE_IMAGE_DIR / "forty-first-battle-map.svg", "seed": 241104, "tile_size": LARGE_MAP_TILE_SIZE, }, "forty-second": { "slug": "forty-second-battle-map", "terrain_export": "fortySecondBattleMap", "units_export": "fortySecondBattleUnits", "ally_positions_export": "fortySecondBattleAllyPositions", "out": BATTLE_IMAGE_DIR / "forty-second-battle-map.webp", "before": BATTLE_IMAGE_DIR / "forty-second-battle-map.svg", "seed": 241114, "tile_size": VERY_LARGE_MAP_TILE_SIZE, }, "forty-third": { "slug": "forty-third-battle-map", "terrain_export": "fortyThirdBattleMap", "units_export": "fortyThirdBattleUnits", "ally_positions_export": "fortyThirdBattleAllyPositions", "out": BATTLE_IMAGE_DIR / "forty-third-battle-map.webp", "before": BATTLE_IMAGE_DIR / "forty-third-battle-map.svg", "seed": 241124, "tile_size": VERY_LARGE_MAP_TILE_SIZE, }, "forty-fourth": { "slug": "forty-fourth-battle-map", "terrain_export": "fortyFourthBattleMap", "units_export": "fortyFourthBattleUnits", "ally_positions_export": "fortyFourthBattleAllyPositions", "out": BATTLE_IMAGE_DIR / "forty-fourth-battle-map.webp", "before": BATTLE_IMAGE_DIR / "forty-fourth-battle-map.svg", "seed": 241134, "tile_size": VERY_LARGE_MAP_TILE_SIZE, }, "forty-fifth": { "slug": "forty-fifth-battle-map", "terrain_export": "fortyFifthBattleMap", "units_export": "fortyFifthBattleUnits", "ally_positions_export": "fortyFifthBattleAllyPositions", "out": BATTLE_IMAGE_DIR / "forty-fifth-battle-map.webp", "before": BATTLE_IMAGE_DIR / "forty-fifth-battle-map.svg", "seed": 241144, "tile_size": ULTRA_LARGE_MAP_TILE_SIZE, }, "forty-sixth": { "slug": "forty-sixth-battle-map", "terrain_export": "fortySixthBattleMap", "units_export": "fortySixthBattleUnits", "ally_positions_export": "fortySixthBattleAllyPositions", "out": BATTLE_IMAGE_DIR / "forty-sixth-battle-map.webp", "before": BATTLE_IMAGE_DIR / "forty-sixth-battle-map.svg", "seed": 241154, "tile_size": ULTRA_LARGE_MAP_TILE_SIZE, }, "forty-seventh": { "slug": "forty-seventh-battle-map", "terrain_export": "fortySeventhBattleMap", "units_export": "fortySeventhBattleUnits", "ally_positions_export": "fortySeventhBattleAllyPositions", "out": BATTLE_IMAGE_DIR / "forty-seventh-battle-map.webp", "before": BATTLE_IMAGE_DIR / "forty-seventh-battle-map.svg", "seed": 241164, "tile_size": ULTRA_LARGE_MAP_TILE_SIZE, }, "forty-eighth": { "slug": "forty-eighth-battle-map", "terrain_export": "fortyEighthBattleMap", "units_export": "fortyEighthBattleUnits", "ally_positions_export": "fortyEighthBattleAllyPositions", "out": BATTLE_IMAGE_DIR / "forty-eighth-battle-map.webp", "before": BATTLE_IMAGE_DIR / "forty-eighth-battle-map.svg", "seed": 241174, "tile_size": ULTRA_LARGE_MAP_TILE_SIZE, }, "forty-ninth": { "slug": "forty-ninth-battle-map", "terrain_export": "fortyNinthBattleMap", "units_export": "fortyNinthBattleUnits", "ally_positions_export": "fortyNinthBattleAllyPositions", "out": BATTLE_IMAGE_DIR / "forty-ninth-battle-map.webp", "before": BATTLE_IMAGE_DIR / "forty-ninth-battle-map.svg", "seed": 241184, "tile_size": EXTREME_LARGE_MAP_TILE_SIZE, }, "fiftieth": { "slug": "fiftieth-battle-map", "terrain_export": "fiftiethBattleMap", "units_export": "fiftiethBattleUnits", "ally_positions_export": "fiftiethBattleAllyPositions", "out": BATTLE_IMAGE_DIR / "fiftieth-battle-map.webp", "before": BATTLE_IMAGE_DIR / "fiftieth-battle-map.svg", "seed": 241194, "tile_size": EXTREME_LARGE_MAP_TILE_SIZE, }, "fifty-first": { "slug": "fifty-first-battle-map", "terrain_export": "fiftyFirstBattleMap", "units_export": "fiftyFirstBattleUnits", "ally_positions_export": "fiftyFirstBattleAllyPositions", "out": BATTLE_IMAGE_DIR / "fifty-first-battle-map.webp", "before": BATTLE_IMAGE_DIR / "fifty-first-battle-map.svg", "seed": 241204, "tile_size": MASSIVE_LARGE_MAP_TILE_SIZE, }, "fifty-second": { "slug": "fifty-second-battle-map", "terrain_export": "fiftySecondBattleMap", "units_export": "fiftySecondBattleUnits", "ally_positions_export": "fiftySecondBattleAllyPositions", "out": BATTLE_IMAGE_DIR / "fifty-second-battle-map.webp", "before": BATTLE_IMAGE_DIR / "fifty-second-battle-map.svg", "seed": 241214, "tile_size": MASSIVE_LARGE_MAP_TILE_SIZE, }, "fifty-third": { "slug": "fifty-third-battle-map", "terrain_export": "fiftyThirdBattleMap", "units_export": "fiftyThirdBattleUnits", "ally_positions_export": "fiftyThirdBattleAllyPositions", "out": BATTLE_IMAGE_DIR / "fifty-third-battle-map.webp", "before": BATTLE_IMAGE_DIR / "fifty-third-battle-map.svg", "seed": 241224, "tile_size": COLOSSAL_LARGE_MAP_TILE_SIZE, }, "fifty-fourth": { "slug": "fifty-fourth-battle-map", "terrain_export": "fiftyFourthBattleMap", "units_export": "fiftyFourthBattleUnits", "ally_positions_export": "fiftyFourthBattleAllyPositions", "out": BATTLE_IMAGE_DIR / "fifty-fourth-battle-map.webp", "before": BATTLE_IMAGE_DIR / "fifty-fourth-battle-map.svg", "seed": 241234, "tile_size": COLOSSAL_LARGE_MAP_TILE_SIZE, }, "fifty-fifth": { "slug": "fifty-fifth-battle-map", "terrain_export": "fiftyFifthBattleMap", "units_export": "fiftyFifthBattleUnits", "ally_positions_export": "fiftyFifthBattleAllyPositions", "out": BATTLE_IMAGE_DIR / "fifty-fifth-battle-map.webp", "before": BATTLE_IMAGE_DIR / "fifty-fifth-battle-map.svg", "seed": 241244, "tile_size": GIGANTIC_LARGE_MAP_TILE_SIZE, }, "fifty-sixth": { "slug": "fifty-sixth-battle-map", "terrain_export": "fiftySixthBattleMap", "units_export": "fiftySixthBattleUnits", "ally_positions_export": "fiftySixthBattleAllyPositions", "out": BATTLE_IMAGE_DIR / "fifty-sixth-battle-map.webp", "before": BATTLE_IMAGE_DIR / "fifty-sixth-battle-map.svg", "seed": 241254, "tile_size": GIGANTIC_LARGE_MAP_TILE_SIZE, }, "fifty-seventh": { "slug": "fifty-seventh-battle-map", "terrain_export": "fiftySeventhBattleMap", "units_export": "fiftySeventhBattleUnits", "ally_positions_export": "fiftySeventhBattleAllyPositions", "out": BATTLE_IMAGE_DIR / "fifty-seventh-battle-map.webp", "before": BATTLE_IMAGE_DIR / "fifty-seventh-battle-map.svg", "seed": 241264, "tile_size": GIGANTIC_LARGE_MAP_TILE_SIZE, }, "fifty-eighth": { "slug": "fifty-eighth-battle-map", "terrain_export": "fiftyEighthBattleMap", "units_export": "fiftyEighthBattleUnits", "ally_positions_export": "fiftyEighthBattleAllyPositions", "out": BATTLE_IMAGE_DIR / "fifty-eighth-battle-map.webp", "before": BATTLE_IMAGE_DIR / "fifty-eighth-battle-map.svg", "seed": 241274, "tile_size": GIGANTIC_LARGE_MAP_TILE_SIZE, }, "fifty-ninth": { "slug": "fifty-ninth-battle-map", "terrain_export": "fiftyNinthBattleMap", "units_export": "fiftyNinthBattleUnits", "ally_positions_export": "fiftyNinthBattleAllyPositions", "out": BATTLE_IMAGE_DIR / "fifty-ninth-battle-map.webp", "before": BATTLE_IMAGE_DIR / "fifty-ninth-battle-map.svg", "seed": 241284, "tile_size": GIGANTIC_LARGE_MAP_TILE_SIZE, }, "sixtieth": { "slug": "sixtieth-battle-map", "terrain_export": "sixtiethBattleMap", "units_export": "sixtiethBattleUnits", "ally_positions_export": "sixtiethBattleAllyPositions", "out": BATTLE_IMAGE_DIR / "sixtieth-battle-map.webp", "before": BATTLE_IMAGE_DIR / "sixtieth-battle-map.svg", "seed": 241294, "tile_size": IMMENSE_LARGE_MAP_TILE_SIZE, }, "sixty-first": { "slug": "sixty-first-battle-map", "terrain_export": "sixtyFirstBattleMap", "units_export": "sixtyFirstBattleUnits", "ally_positions_export": "sixtyFirstBattleAllyPositions", "out": BATTLE_IMAGE_DIR / "sixty-first-battle-map.webp", "before": BATTLE_IMAGE_DIR / "sixty-first-battle-map.svg", "seed": 241304, "tile_size": IMMENSE_LARGE_MAP_TILE_SIZE, }, "sixty-second": { "slug": "sixty-second-battle-map", "terrain_export": "sixtySecondBattleMap", "units_export": "sixtySecondBattleUnits", "ally_positions_export": "sixtySecondBattleAllyPositions", "out": BATTLE_IMAGE_DIR / "sixty-second-battle-map.webp", "before": BATTLE_IMAGE_DIR / "sixty-second-battle-map.svg", "seed": 241314, "tile_size": IMMENSE_LARGE_MAP_TILE_SIZE, }, "sixty-third": { "slug": "sixty-third-battle-map", "terrain_export": "sixtyThirdBattleMap", "units_export": "sixtyThirdBattleUnits", "ally_positions_export": "sixtyThirdBattleAllyPositions", "out": BATTLE_IMAGE_DIR / "sixty-third-battle-map.webp", "before": BATTLE_IMAGE_DIR / "sixty-third-battle-map.svg", "seed": 241324, "tile_size": VAST_LARGE_MAP_TILE_SIZE, }, "sixty-fourth": { "slug": "sixty-fourth-battle-map", "terrain_export": "sixtyFourthBattleMap", "units_export": "sixtyFourthBattleUnits", "ally_positions_export": "sixtyFourthBattleAllyPositions", "out": BATTLE_IMAGE_DIR / "sixty-fourth-battle-map.webp", "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", "terrain_export": "sixtyFifthBattleMap", "units_export": "sixtyFifthBattleUnits", "ally_positions_export": "sixtyFifthBattleAllyPositions", "out": BATTLE_IMAGE_DIR / "sixty-fifth-battle-map.webp", "before": BATTLE_IMAGE_DIR / "sixty-fifth-battle-map.svg", "seed": 241344, "tile_size": VAST_LARGE_MAP_TILE_SIZE, "output_tile_size": DESKTOP_RUNTIME_MAP_TILE_SIZE, "detail_profile": "weishui-northbank", }, "sixty-sixth": { "slug": "sixty-sixth-battle-map", "terrain_export": "sixtySixthBattleMap", "units_export": "sixtySixthBattleUnits", "ally_positions_export": "sixtySixthBattleAllyPositions", "out": BATTLE_IMAGE_DIR / "sixty-sixth-battle-map.webp", "before": BATTLE_IMAGE_DIR / "sixty-sixth-battle-map.svg", "seed": 241354, "tile_size": VAST_LARGE_MAP_TILE_SIZE, "output_tile_size": DESKTOP_RUNTIME_MAP_TILE_SIZE, "detail_profile": "wuzhang-final", }, } 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 extract_balanced(text: str, start: int, open_char: str, close_char: str) -> str: depth = 0 for index in range(start, len(text)): char = text[index] if char == open_char: depth += 1 elif char == close_char: depth -= 1 if depth == 0: return text[start : index + 1] raise RuntimeError(f"Could not find balanced block starting at {start}") def ts_condition_to_python(condition: str) -> str: expression = condition.replace("\n", " ") expression = expression.replace("!==", "!=") expression = expression.replace("===", "==") expression = expression.replace("&&", " and ") expression = expression.replace("||", " or ") expression = re.sub(r"!\s*(?!=)", " not ", expression) expression = expression.replace("Math.floor", "math.floor") return expression def evaluate_terrain_factory(text: str, factory_name: str) -> list[list[str]]: start = text.index(f"function {factory_name}") brace_start = text.index("{", start) block = extract_balanced(text, brace_start, "{", "}") size_match = re.search( r"Array\.from\(\{\s*length:\s*(\d+)\s*\}.*?Array\.from\(\{\s*length:\s*(\d+)\s*\}", block, re.S, ) if not size_match: raise RuntimeError(f"Could not read terrain dimensions from {factory_name}") height = int(size_match.group(1)) width = int(size_match.group(2)) rules: list[tuple[str, str]] = [] cursor = 0 while True: match = re.search(r"\bif\s*\(", block[cursor:]) if not match: break paren_start = cursor + match.end() - 1 condition_block = extract_balanced(block, paren_start, "(", ")") brace_start = block.index("{", paren_start + len(condition_block)) body = extract_balanced(block, brace_start, "{", "}") terrain_match = re.search(r"return\s+'([^']+)';", body) if terrain_match: rules.append((ts_condition_to_python(condition_block[1:-1]), terrain_match.group(1))) cursor = brace_start + len(body) returns = re.findall(r"return\s+'([^']+)';", block) if not returns: raise RuntimeError(f"Could not read terrain fallback from {factory_name}") fallback = returns[-1] terrain: list[list[str]] = [] for y in range(height): row: list[str] = [] for x in range(width): tile = fallback for expression, terrain_type in rules: if eval(expression, {"__builtins__": {}}, {"x": x, "y": y, "math": math}): tile = terrain_type break row.append(tile) terrain.append(row) return terrain def parse_battle_terrain(export_name: str) -> list[list[str]]: text = SCENARIO_FILE.read_text(encoding="utf-8") start = text.index(f"export const {export_name}") brace_start = text.index("{", text.index("=", start)) map_block = extract_balanced(text, brace_start, "{", "}") terrain_key = map_block.index("terrain:") terrain_source = map_block[terrain_key + len("terrain:") :].lstrip() if terrain_source.startswith("["): return ast.literal_eval(extract_balanced(terrain_source, 0, "[", "]")) factory_match = re.match(r"(\w+)\(\)", terrain_source) if factory_match: return evaluate_terrain_factory(text, factory_match.group(1)) raise RuntimeError(f"Unsupported terrain source for {export_name}") 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"'([^']+)':\s*\{\s*x:\s*(\d+),\s*y:\s*(\d+)\s*\}", block): unit_id = match.group(1) units.append( { "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)), "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) ground_top = top + max(28, int(TILE * 0.54)) ground_bottom = bottom - max(12, int(TILE * 0.09)) py = RNG.randint(ground_top, ground_bottom) 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 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 draw_northbank_ferry(draw: ImageDraw.ImageDraw, x: int, y: int) -> None: cx, cy = center(x, y) plank_w = int(TILE * 0.58) plank_h = int(TILE * 0.12) for row, offset_y in enumerate((-int(TILE * 0.18), 0, int(TILE * 0.18))): left = cx - plank_w + row * 5 top = cy + offset_y - plank_h right = cx + plank_w + row * 5 bottom = cy + offset_y + plank_h draw.rounded_rectangle( (left, top, right, bottom), radius=max(3, TILE // 36), fill=(116, 78, 43, 132), ) draw.line((left + 8, top + 4, right - 10, bottom - 4), fill=(218, 176, 99, 78), width=2) for offset in (-int(TILE * 0.45), int(TILE * 0.46)): draw.line( (cx + offset, cy - int(TILE * 0.38), cx + offset + int(TILE * 0.08), cy + int(TILE * 0.4)), fill=(65, 45, 32, 138), width=max(3, TILE // 34), ) draw.arc( (cx - int(TILE * 0.7), cy - int(TILE * 0.34), cx + int(TILE * 0.7), cy + int(TILE * 0.38)), 195, 345, fill=(189, 212, 205, 64), width=max(3, TILE // 34), ) def draw_northbank_barricade(draw: ImageDraw.ImageDraw, x: int, y: int) -> None: cx, cy = center(x, y) for offset in (-int(TILE * 0.36), -int(TILE * 0.12), int(TILE * 0.12), int(TILE * 0.36)): draw.line( (cx + offset - int(TILE * 0.16), cy + int(TILE * 0.22), cx + offset + int(TILE * 0.14), cy - int(TILE * 0.24)), fill=(66, 43, 27, 146), width=max(4, TILE // 30), ) draw.line( (cx + offset - int(TILE * 0.14), cy - int(TILE * 0.2), cx + offset + int(TILE * 0.16), cy + int(TILE * 0.2)), fill=(91, 61, 36, 130), width=max(3, TILE // 36), ) draw.line( (cx - int(TILE * 0.58), cy + int(TILE * 0.12), cx + int(TILE * 0.58), cy + int(TILE * 0.08)), fill=(39, 31, 23, 88), width=max(3, TILE // 32), ) def draw_northbank_command_post(draw: ImageDraw.ImageDraw, x: int, y: int) -> None: cx, cy = center(x, y) base_y = cy + int(TILE * 0.24) tent_w = int(TILE * 0.58) tent_h = int(TILE * 0.42) draw.polygon( [(cx - tent_w, base_y), (cx - int(TILE * 0.04), cy - tent_h), (cx + tent_w, base_y)], fill=(116, 79, 42, 152), ) draw.polygon( [(cx - int(tent_w * 0.62), base_y - 3), (cx - int(TILE * 0.04), cy - int(tent_h * 0.7)), (cx + int(tent_w * 0.2), base_y - 3)], fill=(204, 158, 78, 132), ) draw.line((cx - tent_w, base_y, cx + tent_w, base_y), fill=(56, 38, 25, 138), width=max(3, TILE // 34)) for crate_x in (cx - int(TILE * 0.44), cx + int(TILE * 0.42)): crate_y = cy + int(TILE * 0.38) draw.rectangle( (crate_x - int(TILE * 0.14), crate_y - int(TILE * 0.1), crate_x + int(TILE * 0.14), crate_y + int(TILE * 0.1)), fill=(132, 85, 42, 132), ) draw.line( (crate_x - int(TILE * 0.14), crate_y - int(TILE * 0.09), crate_x + int(TILE * 0.14), crate_y + int(TILE * 0.09)), fill=(217, 172, 91, 78), width=2, ) pole_x = cx + int(TILE * 0.52) pole_y = cy - int(TILE * 0.04) draw.line((pole_x, pole_y + int(TILE * 0.42), pole_x, pole_y - int(TILE * 0.4)), fill=(65, 43, 27, 154), width=max(3, TILE // 40)) draw.polygon( [(pole_x + 4, pole_y - int(TILE * 0.39)), (pole_x + int(TILE * 0.4), pole_y - int(TILE * 0.27)), (pole_x + 4, pole_y - int(TILE * 0.13))], fill=(176, 55, 42, 144), ) def add_weishui_northbank_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 - 72)) sy = RNG.randint(top + 14, bottom - 14) span = RNG.randint(max(38, TILE // 3), max(48, TILE)) draw.arc( (sx, sy - RNG.randint(8, 20), sx + span, sy + RNG.randint(10, 24)), 190, 352, fill=(*jitter(PALETTE["water_light"], 12), RNG.randint(42, 86)), width=RNG.choice([2, 2, 3]), ) if terrain_at(terrain, x, y - 1) != "river" or terrain_at(terrain, x, y + 1) != "river": bank_y = top + RNG.randint(8, TILE - 8) for _ in range(5): px = RNG.randint(left + 8, right - 8) reed = RNG.choice([(88, 111, 58, 82), (157, 134, 75, 72), (42, 82, 51, 72)]) draw.line((px, bank_y + RNG.randint(-5, 6), px + RNG.randint(-9, 8), bank_y - RNG.randint(10, 26)), fill=reed, width=2) if kind == "road" and (y >= 47 or x >= 76) and RNG.random() < 0.64: for _ in range(2): py = cy + RNG.randint(-30, 30) draw.line( (left + RNG.randint(10, 26), py, right - RNG.randint(10, 26), py + RNG.randint(-7, 7)), fill=(93, 65, 39, RNG.randint(42, 68)), width=RNG.choice([2, 3]), ) if RNG.random() < 0.3: for step in range(3): px = left + 22 + step * int(TILE * 0.24) + RNG.randint(-6, 6) py = cy + RNG.randint(-18, 22) draw.ellipse((px - 4, py - 2, px + 5, py + 3), fill=(57, 43, 31, 54)) if kind == "forest" and (y >= 60 or x >= 88): for _ in range(3): bx = cx + RNG.randint(-54, 56) by = cy + RNG.randint(-46, 46) draw.polygon( organic_ellipse_points(bx, by, RNG.randint(18, 42), RNG.randint(12, 30), 14, 0.34), fill=(13, 43, 28, RNG.randint(42, 74)), ) if RNG.random() < 0.4: draw.line( (cx + RNG.randint(-22, 22), cy + RNG.randint(8, 38), cx + RNG.randint(-20, 24), cy + RNG.randint(34, 58)), fill=(59, 39, 24, 88), width=RNG.randint(4, 7), ) if kind in {"hill", "cliff"} and RNG.random() < 0.55: for _ in range(2): px = RNG.randint(left + 12, right - 12) py = RNG.randint(top + 12, bottom - 12) draw.line( (px - RNG.randint(12, 38), py + RNG.randint(-5, 8), px + RNG.randint(22, 54), py + RNG.randint(-9, 9)), fill=(72, 62, 46, RNG.randint(30, 56)), width=RNG.choice([2, 2, 3]), ) if kind in {"camp", "fort"} and RNG.random() < 0.52: for _ in range(2): px = RNG.randint(left + 24, right - 26) py = RNG.randint(top + 26, bottom - 24) draw.rectangle((px - 10, py - 7, px + 12, py + 8), fill=(140, 91, 45, RNG.randint(86, 126))) if (x + y) % 8 == 0: pole_x = cx + RNG.randint(-38, 38) pole_y = cy + RNG.randint(-34, 24) sharp_draw.line((pole_x, pole_y + 36, pole_x, pole_y - 38), fill=(80, 52, 31, 156), width=max(3, TILE // 38)) sharp_draw.polygon( [(pole_x + 4, pole_y - 37), (pole_x + 42, pole_y - 25), (pole_x + 4, pole_y - 13)], fill=(190, 150, 49, 150), ) if kind == "village" and RNG.random() < 0.48: lx = cx + RNG.randint(-36, 36) ly = cy + RNG.randint(-24, 28) draw.ellipse((lx - 9, ly - 9, lx + 9, ly + 9), fill=(229, 168, 76, 84)) draw.arc((lx - 18, ly - 42, lx + 28, ly + 10), 205, 330, fill=(84, 74, 55, 42), width=2) for start, end, width in [ ((20, 95), (43, 92), 116), ((43, 92), (63, 84), 112), ((63, 84), (90, 72), 106), ((90, 72), (112, 66), 98), ((104, 58), (122, 47), 92), ]: paint_path_segment(draw, center(*start), center(*end), width, (198, 162, 94), 48, 14) paint_path_segment(draw, center(*start), center(*end), max(34, width // 2), (230, 194, 126), 28, 9) for ferry in [(28, 39), (58, 39), (88, 40), (105, 48)]: draw_northbank_ferry(sharp_draw, *ferry) for post in [(31, 96), (63, 83), (82, 55), (99, 69), (113, 61)]: draw_northbank_command_post(sharp_draw, *post) for barrier in [(91, 74), (103, 63), (113, 52), (121, 47), (124, 38), (126, 28)]: draw_northbank_barricade(sharp_draw, *barrier) for beacon in [(97, 72), (112, 66), (121, 55), (124, 42), (126, 31)]: draw_weishui_beacon(sharp_draw, *beacon) canvas.alpha_composite(details.filter(ImageFilter.GaussianBlur(0.16))) canvas.alpha_composite(sharp.filter(ImageFilter.GaussianBlur(0.035))) def draw_wuzhang_ledger_tent(draw: ImageDraw.ImageDraw, x: int, y: int) -> None: cx, cy = center(x, y) tent_w = int(TILE * 0.62) tent_h = int(TILE * 0.44) base_y = cy + int(TILE * 0.24) draw.polygon( [(cx - tent_w, base_y), (cx - int(TILE * 0.08), cy - tent_h), (cx + tent_w, base_y)], fill=(118, 86, 47, 154), ) draw.polygon( [(cx - int(tent_w * 0.7), base_y - 3), (cx - int(TILE * 0.08), cy - int(tent_h * 0.72)), (cx + int(tent_w * 0.18), base_y - 3)], fill=(205, 162, 86, 136), ) draw.line((cx - tent_w, base_y, cx + tent_w, base_y), fill=(58, 39, 25, 142), width=max(3, TILE // 32)) table_y = cy + int(TILE * 0.38) draw.rounded_rectangle( (cx - int(TILE * 0.36), table_y - int(TILE * 0.11), cx + int(TILE * 0.38), table_y + int(TILE * 0.12)), radius=max(4, TILE // 28), fill=(92, 56, 30, 144), ) draw.rectangle( (cx - int(TILE * 0.28), table_y - int(TILE * 0.07), cx + int(TILE * 0.22), table_y + int(TILE * 0.05)), fill=(222, 194, 129, 132), ) for offset in (-int(TILE * 0.18), 0, int(TILE * 0.18)): draw.line( (cx + offset, table_y - int(TILE * 0.05), cx + offset + int(TILE * 0.16), table_y + int(TILE * 0.04)), fill=(99, 68, 42, 98), width=2, ) for lx in (cx - int(TILE * 0.48), cx + int(TILE * 0.5)): ly = cy - int(TILE * 0.08) draw.ellipse((lx - 8, ly - 8, lx + 8, ly + 8), fill=(238, 177, 76, 132)) draw.arc((lx - 22, ly - 36, lx + 28, ly + 20), 205, 330, fill=(93, 75, 48, 52), width=2) def draw_wuzhang_supply_cart(draw: ImageDraw.ImageDraw, x: int, y: int) -> None: cx, cy = center(x, y) cart_w = int(TILE * 0.5) cart_h = int(TILE * 0.22) body_y = cy + int(TILE * 0.14) draw.rectangle((cx - cart_w, body_y - cart_h, cx + cart_w, body_y + cart_h), fill=(111, 71, 39, 138)) draw.line((cx - cart_w - 10, body_y - cart_h - 6, cx + cart_w + 8, body_y + cart_h + 6), fill=(213, 168, 86, 76), width=3) for wx in (cx - int(TILE * 0.38), cx + int(TILE * 0.38)): draw.ellipse((wx - 12, body_y + cart_h - 4, wx + 12, body_y + cart_h + 20), outline=(42, 29, 21, 138), width=3) for offset in (-int(TILE * 0.22), 0, int(TILE * 0.23)): draw.rectangle( (cx + offset - int(TILE * 0.12), body_y - cart_h - int(TILE * 0.14), cx + offset + int(TILE * 0.1), body_y - cart_h + int(TILE * 0.02)), fill=(153, 107, 55, 124), ) def draw_wuzhang_beacon(draw: ImageDraw.ImageDraw, x: int, y: int) -> None: cx, cy = center(x, y) base_y = cy + int(TILE * 0.28) top_y = cy - int(TILE * 0.28) draw.line((cx - int(TILE * 0.22), base_y, cx - 5, top_y), fill=(61, 43, 30, 150), width=max(4, TILE // 28)) draw.line((cx + int(TILE * 0.22), base_y, cx + 5, top_y), fill=(61, 43, 30, 150), width=max(4, TILE // 28)) draw.line((cx - int(TILE * 0.28), cy, cx + int(TILE * 0.28), cy - 5), fill=(128, 88, 48, 132), width=max(4, TILE // 34)) draw.rectangle((cx - 18, top_y - 8, cx + 18, top_y + 14), fill=(88, 55, 32, 166)) draw.polygon( [(cx - 18, top_y - 8), (cx - 6, top_y - 38), (cx + 4, top_y - 14), (cx + 16, top_y - 42), (cx + 18, top_y - 4)], fill=(222, 103, 33, 170), ) draw.polygon([(cx - 6, top_y - 5), (cx + 1, top_y - 26), (cx + 8, top_y - 5)], fill=(244, 181, 70, 152)) for offset in (-26, -2, 24): draw.arc((cx + offset - 18, top_y - 74, cx + offset + 48, top_y - 18), 205, 340, fill=(77, 72, 58, 54), width=2) def add_wuzhang_final_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 in {"hill", "cliff"} and RNG.random() < 0.7: for _ in range(3): px = RNG.randint(left + 10, right - 12) py = RNG.randint(top + 12, bottom - 12) draw.line( (px - RNG.randint(12, 42), py + RNG.randint(-6, 8), px + RNG.randint(22, 58), py + RNG.randint(-10, 10)), fill=(73, 63, 46, RNG.randint(34, 62)), width=RNG.choice([2, 2, 3]), ) if kind == "road" and (y >= 78 or x <= 70) and RNG.random() < 0.62: for _ in range(2): py = cy + RNG.randint(-30, 30) draw.line( (left + RNG.randint(10, 26), py, right - RNG.randint(10, 26), py + RNG.randint(-7, 7)), fill=(91, 67, 44, RNG.randint(40, 68)), width=RNG.choice([2, 3]), ) if RNG.random() < 0.28: for step in range(3): px = left + 24 + step * int(TILE * 0.24) + RNG.randint(-6, 6) py = cy + RNG.randint(-18, 22) draw.ellipse((px - 4, py - 2, px + 5, py + 3), fill=(59, 45, 33, 54)) if kind == "river": for _ in range(3): sx = RNG.randint(left + 12, max(left + 13, right - 58)) sy = RNG.randint(top + 16, bottom - 16) draw.arc( (sx, sy - RNG.randint(6, 16), sx + RNG.randint(38, 86), sy + RNG.randint(10, 22)), 192, 350, fill=(150, 178, 181, RNG.randint(32, 62)), width=RNG.choice([2, 2, 3]), ) if kind == "forest" and (x >= 76 or y >= 82 or RNG.random() < 0.2): for _ in range(2): bx = cx + RNG.randint(-52, 52) by = cy + RNG.randint(-44, 44) draw.polygon(organic_ellipse_points(bx, by, RNG.randint(22, 46), RNG.randint(14, 32), 12, 0.32), fill=(15, 44, 30, RNG.randint(40, 70))) if RNG.random() < 0.38: draw.line( (cx + RNG.randint(-22, 20), cy + RNG.randint(8, 42), cx + RNG.randint(-18, 24), cy + RNG.randint(34, 62)), fill=(58, 39, 25, 86), width=RNG.randint(4, 7), ) if kind in {"camp", "fort"} and RNG.random() < 0.45: for _ in range(2): px = RNG.randint(left + 22, right - 24) py = RNG.randint(top + 26, bottom - 22) draw.rectangle((px - 10, py - 7, px + 12, py + 8), fill=(137, 91, 47, RNG.randint(84, 122))) if (x + y) % 7 == 0: pole_x = cx + RNG.randint(-36, 36) pole_y = cy + RNG.randint(-34, 22) sharp_draw.line((pole_x, pole_y + 34, pole_x, pole_y - 36), fill=(70, 47, 29, 152), width=max(3, TILE // 40)) sharp_draw.polygon( [(pole_x + 4, pole_y - 34), (pole_x + 42, pole_y - 22), (pole_x + 4, pole_y - 10)], fill=(174, 58, 45, 148), ) if kind == "village" and RNG.random() < 0.58: lx = cx + RNG.randint(-36, 36) ly = cy + RNG.randint(-22, 28) draw.ellipse((lx - 9, ly - 9, lx + 9, ly + 9), fill=(229, 168, 76, 90)) draw.arc((lx - 18, ly - 42, lx + 28, ly + 10), 205, 330, fill=(84, 74, 55, 44), width=2) for start, end, width in [ ((14, 103), (25, 101), 120), ((25, 101), (46, 98), 118), ((36, 104), (64, 96), 108), ((64, 96), (90, 84), 96), ]: paint_path_segment(draw, center(*start), center(*end), width, (190, 154, 91), 52, 14) paint_path_segment(draw, center(*start), center(*end), max(34, width // 2), (226, 190, 123), 30, 9) for marker in [(20, 102), (38, 96), (46, 98), (64, 64), (86, 74)]: draw_wuzhang_ledger_tent(sharp_draw, *marker) for marker in [(14, 103), (25, 101), (36, 104), (57, 101), (63, 106)]: draw_wuzhang_supply_cart(sharp_draw, *marker) for beacon in [(84, 78), (90, 84), (103, 33), (121, 34), (124, 52)]: draw_wuzhang_beacon(sharp_draw, *beacon) canvas.alpha_composite(details.filter(ImageFilter.GaussianBlur(0.16))) canvas.alpha_composite(sharp.filter(ImageFilter.GaussianBlur(0.035))) 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(config: dict[str, object]) -> Image.Image: global RNG, TILE RNG = random.Random(int(config["seed"])) TILE = int(config.get("tile_size", DEFAULT_TILE_SIZE)) 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) paint_water(canvas, terrain) 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) elif config.get("detail_profile") == "weishui-northbank": add_weishui_northbank_details(canvas, terrain) elif config.get("detail_profile") == "wuzhang-final": add_wuzhang_final_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), "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) 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 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("--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("--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]) if args.tile_size is not None: if args.tile_size < VAST_LARGE_MAP_TILE_SIZE: raise ValueError(f"--tile-size must be {VAST_LARGE_MAP_TILE_SIZE} or greater") config["tile_size"] = args.tile_size 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") terrain = parse_battle_terrain(str(config["terrain_export"])) after = build_map(config) 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": runtime_after.save(out_path, "WEBP", quality=args.webp_quality, method=args.webp_method) else: 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, runtime_after, comparison_path) preview_path.parent.mkdir(parents=True, exist_ok=True) runtime_after.resize((1400, int(runtime_after.height * 1400 / runtime_after.width)), Image.Resampling.LANCZOS).save(preview_path, quality=92) 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": 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), } ) if __name__ == "__main__": main()