1202 lines
52 KiB
Python
1202 lines
52 KiB
Python
from __future__ import annotations
|
|
|
|
import argparse
|
|
import ast
|
|
import math
|
|
import random
|
|
import re
|
|
from pathlib import Path
|
|
|
|
from PIL import Image, ImageChops, ImageDraw, ImageEnhance, ImageFilter, ImageOps
|
|
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
SCENARIO_FILE = ROOT / "src" / "game" / "data" / "scenario.ts"
|
|
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
|
|
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,
|
|
},
|
|
}
|
|
|
|
|
|
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 = 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)
|
|
py = RNG.randint(top + 120, bottom - 20)
|
|
draw.line((px, py, px + RNG.randint(18, 34), py + RNG.randint(-4, 5)), fill=(*PALETTE["wood"], 120), width=3)
|
|
elif kind == "camp":
|
|
draw.ellipse((left + 24, top + 34, right - 18, bottom - 10), fill=(*PALETTE["road_shadow"], 54))
|
|
for ox, oy in [(-32, -18), (34, 8), (-8, 36)]:
|
|
bx, by = cx + ox, cy + oy
|
|
tent = [(bx - 38, by + 34), (bx, by - 38), (bx + 42, by + 34)]
|
|
draw.polygon([(px + 5, py + 9) for px, py in tent], fill=(48, 34, 24, 70))
|
|
draw.polygon(tent, fill=(*jitter(PALETTE["canvas"], 12), 232))
|
|
draw.line((bx, by - 35, bx, by + 32), fill=(*PALETTE["wood"], 140), width=4)
|
|
draw.polygon([(bx + 5, by - 35), (bx + 42, by - 24), (bx + 5, by - 14)], fill=(*PALETTE["flag"], 210))
|
|
elif kind == "fort":
|
|
draw.ellipse((left + 22, top + 34, right - 16, bottom - 14), fill=(*PALETTE["road_shadow"], 62))
|
|
posts = [(left + 48, top + 52), (right - 52, top + 48), (left + 50, bottom - 60), (right - 54, bottom - 64)]
|
|
for px, py in posts:
|
|
draw.rectangle((px - 9, py - 36, px + 9, py + 38), fill=(*jitter(PALETTE["wood"], 8), 230))
|
|
for yline in (top + 72, bottom - 78):
|
|
draw.line((left + 40, yline, right - 44, yline + RNG.randint(-5, 5)), fill=(*jitter(PALETTE["wood_light"], 12), 224), width=12)
|
|
draw.line((left + 52, yline + 20, right - 56, yline + 17), fill=(*jitter(PALETTE["wood"], 8), 220), width=9)
|
|
draw.rectangle((cx - 42, cy - 28, cx + 38, cy + 35), fill=(*jitter((92, 65, 42), 12), 220))
|
|
draw.polygon([(cx - 56, cy - 25), (cx, cy - 68), (cx + 54, cy - 25)], fill=(*jitter(PALETTE["roof"], 9), 225))
|
|
draw.polygon([(cx + 18, cy - 69), (cx + 61, cy - 56), (cx + 18, cy - 40)], fill=(*PALETTE["flag"], 210))
|
|
|
|
|
|
def paint_tiles(canvas: Image.Image, terrain: list[list[str]]) -> None:
|
|
layer = Image.new("RGBA", canvas.size, (0, 0, 0, 0))
|
|
draw = ImageDraw.Draw(layer)
|
|
for y, row in enumerate(terrain):
|
|
for x, kind in enumerate(row):
|
|
if kind in {"hill", "cliff"}:
|
|
paint_hill(draw, x, y, kind)
|
|
elif kind == "forest":
|
|
paint_forest(draw, x, y)
|
|
canvas.alpha_composite(layer.filter(ImageFilter.GaussianBlur(0.25)))
|
|
|
|
structure = Image.new("RGBA", canvas.size, (0, 0, 0, 0))
|
|
structure_draw = ImageDraw.Draw(structure)
|
|
for y, row in enumerate(terrain):
|
|
for x, kind in enumerate(row):
|
|
if kind in {"village", "camp", "fort"}:
|
|
paint_structure(structure_draw, x, y, kind)
|
|
canvas.alpha_composite(structure.filter(ImageFilter.GaussianBlur(0.15)))
|
|
|
|
|
|
def add_route_and_battlefield_details(canvas: Image.Image, terrain: list[list[str]], units: list[dict[str, object]]) -> None:
|
|
details = Image.new("RGBA", canvas.size, (0, 0, 0, 0))
|
|
draw = ImageDraw.Draw(details)
|
|
|
|
for unit in units:
|
|
x, y = int(unit["x"]), int(unit["y"])
|
|
cx, cy = center(x, y)
|
|
if unit["faction"] == "enemy":
|
|
color = (117, 76, 38, 72)
|
|
if "cavalry" in str(unit["id"]):
|
|
color = (94, 67, 38, 58)
|
|
else:
|
|
color = (90, 80, 42, 38)
|
|
draw.ellipse((cx - 54, cy - 30, cx + 54, cy + 30), fill=color)
|
|
if unit["faction"] == "enemy":
|
|
for _ in range(3):
|
|
px = cx + RNG.randint(-60, 60)
|
|
py = cy + RNG.randint(-44, 44)
|
|
draw.line((px, py, px + RNG.randint(-22, 25), py + RNG.randint(-6, 8)), fill=(92, 58, 34, 58), width=3)
|
|
|
|
width, height = canvas.size
|
|
for _ in range(2400):
|
|
x = RNG.randrange(width)
|
|
y = RNG.randrange(height)
|
|
color = RNG.choice([(137, 100, 52, 40), (188, 139, 70, 35), (58, 73, 43, 32)])
|
|
draw.ellipse((x - 2, y - 1, x + 2, y + 1), fill=color)
|
|
|
|
canvas.alpha_composite(details.filter(ImageFilter.GaussianBlur(0.15)))
|
|
|
|
|
|
def add_subtle_grid_and_grade(canvas: Image.Image, terrain: list[list[str]]) -> Image.Image:
|
|
width, height = canvas.size
|
|
overlay = Image.new("RGBA", canvas.size, (0, 0, 0, 0))
|
|
draw = ImageDraw.Draw(overlay)
|
|
for x in range(1, len(terrain[0])):
|
|
alpha = 11 if x % 2 else 8
|
|
draw.line((x * TILE, 0, x * TILE, height), fill=(19, 26, 19, alpha), width=2)
|
|
for y in range(1, len(terrain)):
|
|
alpha = 11 if y % 2 else 8
|
|
draw.line((0, y * TILE, width, y * TILE), fill=(19, 26, 19, alpha), width=2)
|
|
|
|
vignette = Image.new("L", (width, height), 0)
|
|
vdraw = ImageDraw.Draw(vignette)
|
|
margin = int(min(width, height) * 0.04)
|
|
vdraw.rounded_rectangle((margin, margin, width - margin, height - margin), radius=260, fill=255)
|
|
vignette = vignette.filter(ImageFilter.GaussianBlur(360))
|
|
dark = Image.new("RGBA", (width, height), (18, 20, 14, 58))
|
|
overlay = Image.composite(Image.new("RGBA", (width, height), (0, 0, 0, 0)), dark, vignette)
|
|
canvas.alpha_composite(overlay)
|
|
|
|
graded = ImageEnhance.Color(canvas.convert("RGB")).enhance(0.88)
|
|
graded = ImageEnhance.Contrast(graded).enhance(0.96)
|
|
graded = ImageEnhance.Brightness(graded).enhance(0.96)
|
|
return graded
|
|
|
|
|
|
def build_map(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)
|
|
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)
|
|
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)
|
|
args = parser.parse_args()
|
|
|
|
config = dict(MAP_CONFIGS[args.map])
|
|
if args.tile_size is not None:
|
|
if args.tile_size < 144:
|
|
raise ValueError("--tile-size must be 144 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)
|
|
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(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_size = (400, int(after.height * 400 / after.width))
|
|
diff = ImageChops.difference(before.resize(diff_size), after.resize(diff_size)).convert("L")
|
|
print(
|
|
{
|
|
"map": args.map,
|
|
"tile_size": TILE,
|
|
"out": str(out_path),
|
|
"size": 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()
|