Replace eighth battle map with hand-painted asset

This commit is contained in:
2026-07-09 14:59:36 +09:00
parent 56e73117f6
commit 4482a7e17a
7 changed files with 83 additions and 101 deletions

View File

@@ -2,7 +2,7 @@
## Scope ## Scope
The first seven campaign battles now use the same hand-painted tactical map pipeline. The renderer still reads the original scenario tile coordinates and battle logic; only the map texture asset changes from flat/vector map art to low-contrast raster terrain. The first eight campaign battles now use the same hand-painted tactical map pipeline. The renderer still reads the original scenario tile coordinates and battle logic; only the map texture asset changes from flat/vector map art to low-contrast raster terrain.
## Art Direction ## Art Direction
@@ -24,6 +24,7 @@ python scripts/generate-handpaint-map-sample.py --map fourth --webp-quality 90
python scripts/generate-handpaint-map-sample.py --map fifth --webp-quality 90 python scripts/generate-handpaint-map-sample.py --map fifth --webp-quality 90
python scripts/generate-handpaint-map-sample.py --map sixth --webp-quality 90 python scripts/generate-handpaint-map-sample.py --map sixth --webp-quality 90
python scripts/generate-handpaint-map-sample.py --map seventh --webp-quality 90 python scripts/generate-handpaint-map-sample.py --map seventh --webp-quality 90
python scripts/generate-handpaint-map-sample.py --map eighth --webp-quality 90
``` ```
## Current Assets ## Current Assets
@@ -35,6 +36,7 @@ python scripts/generate-handpaint-map-sample.py --map seventh --webp-quality 90
- `src/assets/images/battle/fifth-battle-map.webp` - `src/assets/images/battle/fifth-battle-map.webp`
- `src/assets/images/battle/sixth-battle-map.webp` - `src/assets/images/battle/sixth-battle-map.webp`
- `src/assets/images/battle/seventh-battle-map.webp` - `src/assets/images/battle/seventh-battle-map.webp`
- `src/assets/images/battle/eighth-battle-map.webp`
## QA Outputs ## QA Outputs
@@ -50,5 +52,7 @@ python scripts/generate-handpaint-map-sample.py --map seventh --webp-quality 90
- `docs/sixth-battle-map-handpaint-preview.png` - `docs/sixth-battle-map-handpaint-preview.png`
- `docs/seventh-battle-map-handpaint-before-after.png` - `docs/seventh-battle-map-handpaint-before-after.png`
- `docs/seventh-battle-map-handpaint-preview.png` - `docs/seventh-battle-map-handpaint-preview.png`
- `docs/eighth-battle-map-handpaint-before-after.png`
- `docs/eighth-battle-map-handpaint-preview.png`
Browser screenshots are saved per verification run after build/deploy. Browser screenshots are saved per verification run after build/deploy.

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

View File

@@ -82,6 +82,15 @@ MAP_CONFIGS = {
"before": BATTLE_IMAGE_DIR / "seventh-battle-map.svg", "before": BATTLE_IMAGE_DIR / "seventh-battle-map.svg",
"seed": 240764, "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,
},
} }
@@ -132,12 +141,77 @@ def extract_balanced(text: str, start: int, open_char: str, close_char: str) ->
raise RuntimeError(f"Could not find balanced block starting at {start}") 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]]: def parse_battle_terrain(export_name: str) -> list[list[str]]:
text = SCENARIO_FILE.read_text(encoding="utf-8") text = SCENARIO_FILE.read_text(encoding="utf-8")
start = text.index(f"export const {export_name}") start = text.index(f"export const {export_name}")
terrain_key = text.index("terrain:", start) brace_start = text.index("{", text.index("=", start))
bracket_start = text.index("[", terrain_key) map_block = extract_balanced(text, brace_start, "{", "}")
return ast.literal_eval(extract_balanced(text, bracket_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: def parse_export_array_source(export_name: str) -> str:

View File

@@ -1,96 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 2600 2200" width="2600" height="2200">
<defs>
<filter id="grain">
<feTurbulence type="fractalNoise" baseFrequency="0.02" numOctaves="4" seed="821" />
<feColorMatrix type="saturate" values="0.62" />
<feBlend mode="multiply" in2="SourceGraphic" />
</filter>
<pattern id="grass" width="96" height="96" patternUnits="userSpaceOnUse">
<rect width="96" height="96" fill="#61764b" />
<path d="M8 28h28M48 58h30M24 76l20-14M66 18l16 12" stroke="#b7c077" stroke-width="3" opacity="0.24" />
<path d="M12 62l14-24M40 34l13-18M70 80l9-25" stroke="#2f4b2d" stroke-width="2" opacity="0.36" />
<circle cx="20" cy="44" r="3" fill="#7f9258" opacity="0.42" />
<circle cx="68" cy="39" r="4" fill="#3f5f35" opacity="0.32" />
</pattern>
<pattern id="earth" width="104" height="104" patternUnits="userSpaceOnUse">
<rect width="104" height="104" fill="#9b7749" />
<path d="M0 26c38-18 70 12 104-8M0 70c42-18 72 14 104-4" stroke="#c9a069" stroke-width="7" opacity="0.25" />
<path d="M20 86l18-24M62 42l24-16M78 82l14-9" stroke="#57422c" stroke-width="3" opacity="0.34" />
</pattern>
<pattern id="forest" width="126" height="110" patternUnits="userSpaceOnUse">
<rect width="126" height="110" fill="#31482e" />
<circle cx="26" cy="42" r="30" fill="#203823" />
<circle cx="62" cy="32" r="35" fill="#587448" />
<circle cx="96" cy="50" r="31" fill="#294328" />
<circle cx="52" cy="82" r="27" fill="#69844f" />
<path d="M14 62c34 18 74 16 104-8M36 26c22 18 54 16 72-6" stroke="#142615" stroke-width="5" opacity="0.36" />
</pattern>
<pattern id="hill" width="132" height="98" patternUnits="userSpaceOnUse">
<rect width="132" height="98" fill="#77704b" />
<path d="M-10 84c48-62 92-82 134-64c36 15 56 44 74 80" fill="#55543a" opacity="0.72" />
<path d="M18 76c36-40 72-50 108-32" fill="none" stroke="#aa9d66" stroke-width="10" opacity="0.3" />
<path d="M40 36l30 20M82 22l22 22" stroke="#393828" stroke-width="4" opacity="0.34" />
</pattern>
<linearGradient id="river" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#61a4bd" />
<stop offset="0.48" stop-color="#2c6687" />
<stop offset="1" stop-color="#17384f" />
</linearGradient>
<linearGradient id="wall" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#907a5f" />
<stop offset="1" stop-color="#493728" />
</linearGradient>
<linearGradient id="roof" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#d79a41" />
<stop offset="1" stop-color="#7a4d1d" />
</linearGradient>
</defs>
<rect width="2600" height="2200" fill="url(#grass)" />
<g filter="url(#grain)">
<path d="M-80 2020C210 1740 386 1542 604 1322C836 1086 1056 1018 1260 1014C1508 1008 1740 888 2040 708C2268 570 2440 484 2680 438" fill="none" stroke="#a77e4d" stroke-width="272" stroke-linecap="round" opacity="0.96" />
<path d="M-80 2020C210 1740 386 1542 604 1322C836 1086 1056 1018 1260 1014C1508 1008 1740 888 2040 708C2268 570 2440 484 2680 438" fill="none" stroke="url(#earth)" stroke-width="178" stroke-linecap="round" opacity="0.96" />
<path d="M300 2220C350 1830 304 1600 430 1382C560 1158 860 1110 1160 1028C1446 950 1788 764 2180 458" fill="none" stroke="#a77e4d" stroke-width="190" stroke-linecap="round" opacity="0.9" />
<path d="M300 2220C350 1830 304 1600 430 1382C560 1158 860 1110 1160 1028C1446 950 1788 764 2180 458" fill="none" stroke="url(#earth)" stroke-width="114" stroke-linecap="round" opacity="0.86" />
<path d="M1460 -80c-88 250-58 420 72 552c158 160 194 350 106 568c-76 190-52 354 72 492c128 142 170 326 88 594" fill="none" stroke="#173c52" stroke-width="280" stroke-linecap="round" opacity="0.96" />
<path d="M1460 -80c-88 250-58 420 72 552c158 160 194 350 106 568c-76 190-52 354 72 492c128 142 170 326 88 594" fill="none" stroke="url(#river)" stroke-width="184" stroke-linecap="round" opacity="0.98" />
<path d="M1508 146c36 132 48 250-16 384M1638 782c88 108 102 238 34 386M1808 1560c58 108 68 216 24 332" fill="none" stroke="#c0eef5" stroke-width="14" opacity="0.34" />
<path d="M-40 40c260-76 518-42 760 106c40 146-54 282-276 408c-238-22-402-104-492-250Z" fill="url(#forest)" opacity="0.84" />
<path d="M520 320c236-98 474-70 710 78c14 152-88 268-306 346c-246-32-382-170-404-424Z" fill="url(#forest)" opacity="0.82" />
<path d="M18 762c314-118 610-64 872 156c-70 226-330 350-774 366C-16 1118-42 950 18 762Z" fill="url(#forest)" opacity="0.84" />
<path d="M1800 1710c260-96 520-66 760 88v360c-298 76-572 18-820-176c-50-112-30-202 60-272Z" fill="url(#forest)" opacity="0.82" />
<path d="M910 1540c236-84 432-58 590 72c-42 220-190 366-448 438c-148-76-206-246-142-510Z" fill="url(#hill)" opacity="0.84" />
<path d="M2240 760c150-38 286-12 410 82v464c-170 64-322 36-454-82c-58-160-42-314 44-464Z" fill="url(#hill)" opacity="0.86" />
<g transform="translate(2020 230)">
<rect x="-46" y="-26" width="520" height="318" rx="20" fill="#241d16" opacity="0.55" />
<rect x="0" y="0" width="450" height="260" rx="16" fill="url(#wall)" />
<rect x="42" y="50" width="366" height="170" fill="#765f43" stroke="#241d15" stroke-width="12" />
<path d="M42 50h366M42 220h366M42 50v170M408 50v170" stroke="#35291e" stroke-width="10" />
<rect x="82" y="104" width="74" height="84" fill="#98704a" />
<rect x="198" y="78" width="88" height="110" fill="#9e7650" />
<rect x="330" y="104" width="58" height="84" fill="#8c6846" />
<path d="M62 104l58-48l62 48M184 78l60-52l70 52M318 104l42-38l54 38" fill="url(#roof)" stroke="#302316" stroke-width="9" />
<path d="M220 188v-60h46v60" fill="#1f1712" />
</g>
<g transform="translate(760 1220)">
<rect x="0" y="0" width="230" height="130" rx="10" fill="#6d563b" stroke="#2a2119" stroke-width="10" />
<path d="M-16 12L116-74l132 86Z" fill="url(#roof)" stroke="#342313" stroke-width="10" />
<path d="M42 130v-58h46v58M140 130v-70h46v70" fill="#2a2119" opacity="0.8" />
</g>
<g transform="translate(1710 770)">
<rect x="0" y="0" width="220" height="126" rx="10" fill="#6d563b" stroke="#2a2119" stroke-width="10" />
<path d="M-14 14L110-62l128 76Z" fill="url(#roof)" stroke="#342313" stroke-width="10" />
<path d="M78 126v-56h52v56" fill="#2a2119" opacity="0.8" />
</g>
<g transform="translate(60 1630)">
<rect x="0" y="0" width="315" height="230" rx="16" fill="#4f5c53" stroke="#222820" stroke-width="14" />
<path d="M20 56h270M20 116h270M20 176h270M74 20v190M154 20v190M234 20v190" stroke="#263124" stroke-width="9" opacity="0.68" />
<rect x="72" y="-34" width="168" height="76" fill="url(#roof)" stroke="#2c2115" stroke-width="10" />
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1009 KiB

View File

@@ -1,4 +1,4 @@
import eighthBattleMapUrl from '../../assets/images/battle/eighth-battle-map.svg'; import eighthBattleMapUrl from '../../assets/images/battle/eighth-battle-map.webp';
import eighteenthBattleMapUrl from '../../assets/images/battle/eighteenth-battle-map.svg'; import eighteenthBattleMapUrl from '../../assets/images/battle/eighteenth-battle-map.svg';
import eleventhBattleMapUrl from '../../assets/images/battle/eleventh-battle-map.svg'; import eleventhBattleMapUrl from '../../assets/images/battle/eleventh-battle-map.svg';
import fiftiethBattleMapUrl from '../../assets/images/battle/fiftieth-battle-map.svg'; import fiftiethBattleMapUrl from '../../assets/images/battle/fiftieth-battle-map.svg';