dsh-hoi4-modding 1.1.2 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +32 -0
- package/README.md +14 -1
- package/package.json +6 -3
- package/skills/hoi4-modding/INSTALL.md +2 -1
- package/skills/hoi4-modding/SKILL.md +21 -1
- package/skills/hoi4-modding/references/cheatsheet.md +57 -0
- package/skills/hoi4-modding/references/icon-dds-pipeline.md +123 -0
- package/skills/hoi4-modding/references/tank-designer.md +215 -0
- package/skills/hoi4-modding/references/tank-models-3d.md +154 -0
- package/skills/hoi4-modding/references/tech-tree-ui.md +45 -0
- package/skills/hoi4-modding/scripts/tank_designer_layout.py +234 -0
- package/skills/hoi4-modding/scripts/tank_designer_probe.py +280 -0
- package/skills/hoi4-modding/scripts/tech_icon_import.py +269 -0
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""坦克设计器体检:窗口/槽位/模块/底盘槽数/背景贴图(HOI4 1.19.x)。
|
|
4
|
+
|
|
5
|
+
只读诊断,不改任何文件。排错第一步:先跑这个,确认“界面有几个槽位位置、每个底盘有几个槽、
|
|
6
|
+
槽位按钮多大、窗口背景多高”。
|
|
7
|
+
|
|
8
|
+
用法:
|
|
9
|
+
python tank_designer_probe.py
|
|
10
|
+
python tank_designer_probe.py --game "E:/Steam/steamapps/common/Hearts of Iron IV"
|
|
11
|
+
python tank_designer_probe.py --mod "C:/.../my_mod"
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import argparse
|
|
16
|
+
import re
|
|
17
|
+
import struct
|
|
18
|
+
import sys
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
22
|
+
try:
|
|
23
|
+
from _paths import find_game_dir
|
|
24
|
+
except Exception: # pragma: no cover
|
|
25
|
+
def find_game_dir():
|
|
26
|
+
return None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# ---------------------------------------------------------------- PDX 小工具
|
|
30
|
+
def read(p: Path) -> str:
|
|
31
|
+
return p.read_text(encoding="utf-8", errors="replace")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def depth_map(text: str) -> list[int]:
|
|
35
|
+
"""每个字符位置所处的花括号深度(跳过注释与字符串)。"""
|
|
36
|
+
depths: list[int] = []
|
|
37
|
+
d = 0
|
|
38
|
+
in_str = False
|
|
39
|
+
i = 0
|
|
40
|
+
n = len(text)
|
|
41
|
+
while i < n:
|
|
42
|
+
c = text[i]
|
|
43
|
+
if not in_str and c == "#":
|
|
44
|
+
while i < n and text[i] != "\n":
|
|
45
|
+
depths.append(d)
|
|
46
|
+
i += 1
|
|
47
|
+
continue
|
|
48
|
+
if c == '"':
|
|
49
|
+
in_str = not in_str
|
|
50
|
+
depths.append(d)
|
|
51
|
+
i += 1
|
|
52
|
+
continue
|
|
53
|
+
depths.append(d)
|
|
54
|
+
if not in_str:
|
|
55
|
+
if c == "{":
|
|
56
|
+
d += 1
|
|
57
|
+
elif c == "}":
|
|
58
|
+
d -= 1
|
|
59
|
+
i += 1
|
|
60
|
+
return depths
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
BLOCK_START = re.compile(r"(?m)^[ \t]*([A-Za-z0-9_@.\-]+)\s*=\s*\{")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def blocks_at(text: str, depths: list[int], target: int):
|
|
67
|
+
"""产出 (name, start, open_idx, close_idx, body):深度 == target 的块。"""
|
|
68
|
+
out = []
|
|
69
|
+
for m in BLOCK_START.finditer(text):
|
|
70
|
+
if depths[m.start()] != target:
|
|
71
|
+
continue
|
|
72
|
+
open_idx = m.end() - 1
|
|
73
|
+
d = depths[open_idx]
|
|
74
|
+
j = open_idx
|
|
75
|
+
close_idx = len(text) - 1
|
|
76
|
+
while j < len(text):
|
|
77
|
+
if text[j] == "}" and depths[j] == d:
|
|
78
|
+
close_idx = j
|
|
79
|
+
break
|
|
80
|
+
j += 1
|
|
81
|
+
body = text[open_idx + 1:close_idx]
|
|
82
|
+
out.append((m.group(1), m.start(), open_idx, close_idx, body))
|
|
83
|
+
return out
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def child_blocks(body: str):
|
|
87
|
+
"""body 内第一层 name = { ... } 子块(返回 (name, body))。"""
|
|
88
|
+
d = depth_map(body)
|
|
89
|
+
return [(n, b) for (n, _s, _o, _c, b) in blocks_at(body, d, 0)]
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def attr_after(text: str, name: str, attr: str, span: int = 3000):
|
|
93
|
+
i = text.find(f'name = "{name}"')
|
|
94
|
+
if i < 0:
|
|
95
|
+
i = text.find(f'name="{name}"')
|
|
96
|
+
if i < 0:
|
|
97
|
+
return None
|
|
98
|
+
m = re.search(attr + r"\s*=\s*\{([^}]*)\}", text[i:i + span])
|
|
99
|
+
return m.group(1) if m else None
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def width_height(snippet: str | None):
|
|
103
|
+
if not snippet:
|
|
104
|
+
return None
|
|
105
|
+
w = re.search(r"width\s*=\s*(\d+)", snippet)
|
|
106
|
+
h = re.search(r"height\s*=\s*(\d+)", snippet)
|
|
107
|
+
return (int(w.group(1)), int(h.group(1))) if (w and h) else None
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
# ---------------------------------------------------------------- DDS / GFX
|
|
111
|
+
def dds_size(path: Path):
|
|
112
|
+
try:
|
|
113
|
+
with open(path, "rb") as f:
|
|
114
|
+
head = f.read(128)
|
|
115
|
+
if head[:4] != b"DDS ":
|
|
116
|
+
return None
|
|
117
|
+
height = struct.unpack("<I", head[12:16])[0]
|
|
118
|
+
width = struct.unpack("<I", head[16:20])[0]
|
|
119
|
+
return width, height
|
|
120
|
+
except Exception:
|
|
121
|
+
return None
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def gfx_sprite_texture(game: Path, sprite_name: str):
|
|
125
|
+
for f in (game / "interface").glob("**/*.gfx"):
|
|
126
|
+
try:
|
|
127
|
+
t = read(f)
|
|
128
|
+
except Exception:
|
|
129
|
+
continue
|
|
130
|
+
m = re.search(r'name\s*=\s*"' + re.escape(sprite_name) + r'"(.{0,600}?)texturefile\s*=\s*"([^"]+)"',
|
|
131
|
+
t, re.S)
|
|
132
|
+
if m:
|
|
133
|
+
return game / m.group(2)
|
|
134
|
+
return None
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
# ---------------------------------------------------------------- 报告
|
|
138
|
+
def main() -> int:
|
|
139
|
+
ap = argparse.ArgumentParser(description="坦克设计器体检(只读)")
|
|
140
|
+
ap.add_argument("--game", default=None)
|
|
141
|
+
ap.add_argument("--mod", default=None)
|
|
142
|
+
args = ap.parse_args()
|
|
143
|
+
|
|
144
|
+
game = Path(args.game) if args.game else find_game_dir()
|
|
145
|
+
if not game or not (game / "interface" / "tank_designer_view.gui").exists():
|
|
146
|
+
print("[!] 找不到游戏目录,请用 --game 指定 Hearts of Iron IV 根目录")
|
|
147
|
+
return 2
|
|
148
|
+
|
|
149
|
+
tv = game / "interface" / "tank_designer_view.gui"
|
|
150
|
+
ed = game / "interface" / "equipmentdesignerview.gui"
|
|
151
|
+
text = read(tv)
|
|
152
|
+
|
|
153
|
+
print("=" * 72)
|
|
154
|
+
print("坦克设计器体检 @", game)
|
|
155
|
+
print("=" * 72)
|
|
156
|
+
|
|
157
|
+
win = width_height(attr_after(text, "tank_designer_view", "size"))
|
|
158
|
+
print(f"[窗口] tank_designer_view size = {win}")
|
|
159
|
+
|
|
160
|
+
mod_pos = attr_after(text, "equipment_modules", "position")
|
|
161
|
+
mod_size = width_height(attr_after(text, "equipment_modules", "size"))
|
|
162
|
+
print(f"[容器] equipment_modules pos={mod_pos.strip() if mod_pos else None} size={mod_size}")
|
|
163
|
+
bp_pos = attr_after(text, "equipment_preview", "position")
|
|
164
|
+
bp_size = width_height(attr_after(text, "equipment_preview", "size"))
|
|
165
|
+
print(f"[蓝图] equipment_preview pos={bp_pos.strip() if bp_pos else None} size={bp_size}")
|
|
166
|
+
|
|
167
|
+
print("[槽位位置]")
|
|
168
|
+
macros = dict(re.findall(r"@([A-Za-z0-9_]+)\s*=\s*(-?\d+)", text))
|
|
169
|
+
for m in re.finditer(r'name\s*=\s*"(pos_custom_module_slot_window_\d+)"', text):
|
|
170
|
+
name = m.group(1)
|
|
171
|
+
pm = re.search(r"position\s*=\s*\{([^}]*)\}", text[m.start():m.start() + 300])
|
|
172
|
+
if not pm:
|
|
173
|
+
continue
|
|
174
|
+
raw = pm.group(1)
|
|
175
|
+
for k, v in macros.items():
|
|
176
|
+
raw = raw.replace("@" + k, v)
|
|
177
|
+
nums = re.findall(r"-?\d+", raw)
|
|
178
|
+
if len(nums) >= 2:
|
|
179
|
+
print(f" {name:36s} x={int(nums[0]):4d} y={int(nums[1]):4d}")
|
|
180
|
+
|
|
181
|
+
if ed.exists():
|
|
182
|
+
m = re.search(r'name\s*=\s*"equipment_designer_module_slot_entry"(.{0,300}?)size\s*=\s*\{([^}]*)\}',
|
|
183
|
+
read(ed), re.S)
|
|
184
|
+
if m:
|
|
185
|
+
print(f"[模板] equipment_designer_module_slot_entry size={width_height(m.group(2))}")
|
|
186
|
+
|
|
187
|
+
bgm = re.search(r'background\s*=\s*\{[^}]*?spriteType\s*=\s*"([^"]+)"', text, re.S)
|
|
188
|
+
if bgm:
|
|
189
|
+
sprite = bgm.group(1)
|
|
190
|
+
tex = gfx_sprite_texture(game, sprite)
|
|
191
|
+
if tex and tex.exists():
|
|
192
|
+
size = dds_size(tex)
|
|
193
|
+
print(f"[背景] {sprite} -> {tex.name} {size}")
|
|
194
|
+
if win and size and size[0] < win[0]:
|
|
195
|
+
print(" [!] 背景宽度 < 窗口宽度")
|
|
196
|
+
if win and size and size[1] < win[1]:
|
|
197
|
+
print(" [!] 背景高度 < 窗口高度:内容会溢出窗口(见 references/tank-designer.md §4.3)")
|
|
198
|
+
|
|
199
|
+
hidden = []
|
|
200
|
+
for m in re.finditer(r'name\s*=\s*"([a-z_0-9]+)"\s*\n\s*position\s*=\s*\{\s*x\s*=\s*(-4000|-5000)', text):
|
|
201
|
+
hidden.append(m.group(1))
|
|
202
|
+
if hidden:
|
|
203
|
+
print(f"[隐藏] {len(hidden)} 个元素被移出屏幕:{', '.join(hidden[:12])}"
|
|
204
|
+
+ (" ..." if len(hidden) > 12 else ""))
|
|
205
|
+
|
|
206
|
+
# ---- 模块(游戏 + mod 覆盖)
|
|
207
|
+
mod_dirs = []
|
|
208
|
+
if args.mod:
|
|
209
|
+
mod_dirs.append(Path(args.mod) / "common" / "units" / "equipment" / "modules")
|
|
210
|
+
mod_dirs.append(game / "common" / "units" / "equipment" / "modules")
|
|
211
|
+
cats: dict[str, int] = {}
|
|
212
|
+
module_count = 0
|
|
213
|
+
module_ids: set[str] = set()
|
|
214
|
+
for d in mod_dirs:
|
|
215
|
+
if not d.exists():
|
|
216
|
+
continue
|
|
217
|
+
for f in d.glob("*.txt"):
|
|
218
|
+
t = read(f)
|
|
219
|
+
dm = depth_map(t)
|
|
220
|
+
for name, _s, _o, _c, body in blocks_at(t, dm, 0):
|
|
221
|
+
if name != "equipment_modules":
|
|
222
|
+
continue
|
|
223
|
+
for mod_id, mod_body in child_blocks(body):
|
|
224
|
+
if mod_id in ("limit",):
|
|
225
|
+
continue
|
|
226
|
+
module_count += 1
|
|
227
|
+
module_ids.add(mod_id)
|
|
228
|
+
cm = re.search(r"category\s*=\s*([A-Za-z0-9_]+)", mod_body)
|
|
229
|
+
if cm:
|
|
230
|
+
cats[cm.group(1)] = cats.get(cm.group(1), 0) + 1
|
|
231
|
+
print(f"[模块] {len(module_ids)} 个模块(含重复 id),{len(cats)} 个类别")
|
|
232
|
+
for c in sorted(cats):
|
|
233
|
+
print(f" {c:34s} {cats[c]:3d} 个模块")
|
|
234
|
+
|
|
235
|
+
# ---- 底盘槽数
|
|
236
|
+
chassis_files = []
|
|
237
|
+
if args.mod:
|
|
238
|
+
mp = Path(args.mod) / "common" / "units" / "equipment" / "tank_chassis.txt"
|
|
239
|
+
if mp.exists():
|
|
240
|
+
chassis_files.append(("mod", mp))
|
|
241
|
+
gp = game / "common" / "units" / "equipment" / "tank_chassis.txt"
|
|
242
|
+
if gp.exists():
|
|
243
|
+
chassis_files.append(("game", gp))
|
|
244
|
+
for label, cf in chassis_files:
|
|
245
|
+
t = read(cf)
|
|
246
|
+
dm = depth_map(t)
|
|
247
|
+
rows = []
|
|
248
|
+
for name, _s, _o, _c, body in blocks_at(t, dm, 1) + blocks_at(t, dm, 0):
|
|
249
|
+
if "is_archetype = yes" not in body:
|
|
250
|
+
continue
|
|
251
|
+
ms = re.search(r"module_slots\s*=\s*\{", body)
|
|
252
|
+
if not ms:
|
|
253
|
+
rows.append((name, 0, []))
|
|
254
|
+
continue
|
|
255
|
+
i = body.find("{", ms.start())
|
|
256
|
+
d = 0
|
|
257
|
+
j = i
|
|
258
|
+
while j < len(body):
|
|
259
|
+
if body[j] == "{":
|
|
260
|
+
d += 1
|
|
261
|
+
elif body[j] == "}":
|
|
262
|
+
d -= 1
|
|
263
|
+
if d == 0:
|
|
264
|
+
break
|
|
265
|
+
j += 1
|
|
266
|
+
slots = [n for n, _b in child_blocks(body[i + 1:j])]
|
|
267
|
+
rows.append((name, len(slots), slots))
|
|
268
|
+
if rows:
|
|
269
|
+
print(f"[底盘-{label}] archetype 槽位:")
|
|
270
|
+
for name, n, slots in rows:
|
|
271
|
+
print(f" {name:32s} {n:2d} 槽" + (f": {', '.join(slots)}" if slots else ""))
|
|
272
|
+
|
|
273
|
+
print("-" * 72)
|
|
274
|
+
npos = len(re.findall(r'name\s*=\s*"pos_custom_module_slot_window_\d+"', text))
|
|
275
|
+
print(f"[结论] 设计器槽位位置数 = {npos};槽位按钮数量由底盘 module_slots 决定,第 N 个槽 → 第 N 个位置。")
|
|
276
|
+
return 0
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
if __name__ == "__main__":
|
|
280
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""科技卡图标导入:照片/矢量图 → 173×77 DDS(去背 + 安全区 + 卡片预览)。
|
|
4
|
+
|
|
5
|
+
针对 HOI4 科技卡的几何:卡 183×84、Icon 控件中心 x=74 → 图标内容需左留 20、右留 4(默认)。
|
|
6
|
+
支持透明 PNG(最佳)、白底、8px 棋盘格(白 + 灰)三种常见输入。
|
|
7
|
+
|
|
8
|
+
用法:
|
|
9
|
+
# 单张(透明 PNG 或白底照片)
|
|
10
|
+
python tech_icon_import.py "虎式E型.png" out/GER_tiger_e.dds
|
|
11
|
+
|
|
12
|
+
# 批量目录 + 别名表
|
|
13
|
+
python tech_icon_import.py ./矢量图 out/ --alias alias.json --card-preview preview.png
|
|
14
|
+
|
|
15
|
+
# 自定义尺寸(例如装备 2D 图标 89×38 时)
|
|
16
|
+
python tech_icon_import.py in.png out.dds --width 89 --height 38 --margin-left 4 --margin-right 4
|
|
17
|
+
|
|
18
|
+
依赖:Pillow;DDS 写出调用同目录 dds_helper.py(raw32)。
|
|
19
|
+
"""
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import argparse
|
|
23
|
+
import json
|
|
24
|
+
import re
|
|
25
|
+
import subprocess
|
|
26
|
+
import sys
|
|
27
|
+
from collections import deque
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
|
|
30
|
+
try:
|
|
31
|
+
from PIL import Image, ImageDraw, ImageFilter
|
|
32
|
+
except ImportError: # pragma: no cover
|
|
33
|
+
raise SystemExit("需要 Pillow:pip install pillow")
|
|
34
|
+
|
|
35
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
36
|
+
try:
|
|
37
|
+
from _paths import find_game_dir
|
|
38
|
+
except Exception: # pragma: no cover
|
|
39
|
+
def find_game_dir():
|
|
40
|
+
return None
|
|
41
|
+
|
|
42
|
+
DDS_HELPER = Path(__file__).resolve().parent / "dds_helper.py"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# ---------------------------------------------------------------- 去背
|
|
46
|
+
def border_pixels(im: Image.Image):
|
|
47
|
+
w, h = im.size
|
|
48
|
+
px = im.load()
|
|
49
|
+
for x in range(w):
|
|
50
|
+
yield px[x, 0]
|
|
51
|
+
yield px[x, h - 1]
|
|
52
|
+
for y in range(h):
|
|
53
|
+
yield px[0, y]
|
|
54
|
+
yield px[w - 1, y]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def bg_like(r: int, g: int, b: int, mode: str) -> bool:
|
|
58
|
+
mx, mn = max(r, g, b), min(r, g, b)
|
|
59
|
+
if mx - mn > 14: # 有明显颜色 → 不是背景
|
|
60
|
+
return False
|
|
61
|
+
v = (r + g + b) // 3
|
|
62
|
+
if mode == "white":
|
|
63
|
+
return v >= 235
|
|
64
|
+
if mode == "checker": # 棋盘格:白 + 灰(130/192)
|
|
65
|
+
return v >= 235 or 110 <= v <= 210
|
|
66
|
+
return v >= 235
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def detect_mode(im: Image.Image) -> str:
|
|
70
|
+
"""auto:有透明通道用 alpha;否则看边框颜色判断白底/棋盘格。"""
|
|
71
|
+
if im.mode == "RGBA" and im.getchannel("A").getextrema()[0] < 250:
|
|
72
|
+
return "alpha"
|
|
73
|
+
counts = {"white": 0, "checker": 0, "other": 0}
|
|
74
|
+
for r, g, b, _a in border_pixels(im):
|
|
75
|
+
mx, mn = max(r, g, b), min(r, g, b)
|
|
76
|
+
v = (r + g + b) // 3
|
|
77
|
+
if mx - mn <= 14 and v >= 235:
|
|
78
|
+
counts["white"] += 1
|
|
79
|
+
elif mx - mn <= 14 and 110 <= v <= 210:
|
|
80
|
+
counts["checker"] += 1
|
|
81
|
+
else:
|
|
82
|
+
counts["other"] += 1
|
|
83
|
+
if counts["white"] >= counts["other"] * 3:
|
|
84
|
+
return "white"
|
|
85
|
+
return "checker"
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def remove_background(im: Image.Image, mode: str):
|
|
89
|
+
"""返回 (前景 alpha mask L, 背景已透明的 RGBA)。"""
|
|
90
|
+
im = im.convert("RGBA")
|
|
91
|
+
if mode == "alpha":
|
|
92
|
+
return im.getchannel("A"), im
|
|
93
|
+
|
|
94
|
+
# 1) 背景候选 mask:bg-like=255
|
|
95
|
+
w, h = im.size
|
|
96
|
+
px = im.load()
|
|
97
|
+
mask = Image.new("L", (w, h), 0)
|
|
98
|
+
mp = mask.load()
|
|
99
|
+
for y in range(h):
|
|
100
|
+
for x in range(w):
|
|
101
|
+
r, g, b, a = px[x, y]
|
|
102
|
+
if bg_like(r, g, b, mode):
|
|
103
|
+
mp[x, y] = 255
|
|
104
|
+
|
|
105
|
+
# 2) 从边界 flood fill(128 = 已确认背景)
|
|
106
|
+
draw = ImageDraw.Draw(mask)
|
|
107
|
+
seeds = [(x, 0) for x in range(w)] + [(x, h - 1) for x in range(w)] + \
|
|
108
|
+
[(0, y) for y in range(h)] + [(w - 1, y) for y in range(h)]
|
|
109
|
+
for s in seeds:
|
|
110
|
+
if mp[s] == 255:
|
|
111
|
+
draw.floodfill(mask, s, 128, thresh=0)
|
|
112
|
+
|
|
113
|
+
# 3) 生成前景 alpha:背景=0,其余=255;内部洞按“白涂装保护”规则处理
|
|
114
|
+
fg = Image.new("L", (w, h), 0)
|
|
115
|
+
fp = fg.load()
|
|
116
|
+
holes = 0
|
|
117
|
+
for y in range(h):
|
|
118
|
+
for x in range(w):
|
|
119
|
+
v = mp[x, y]
|
|
120
|
+
if v == 128:
|
|
121
|
+
continue
|
|
122
|
+
if v == 255: # 内部洞(被前景包围的背景色)
|
|
123
|
+
r, g, b, _a = px[x, y]
|
|
124
|
+
rel_y = y / max(1, h - 1)
|
|
125
|
+
if (r + g + b) / 3 >= 235 and rel_y < 0.8:
|
|
126
|
+
fp[x, y] = 255 # 白色涂装/编号:保留
|
|
127
|
+
else:
|
|
128
|
+
holes += 1 # 其余洞留透明(履带缝等)
|
|
129
|
+
continue
|
|
130
|
+
fp[x, y] = 255
|
|
131
|
+
|
|
132
|
+
# 4) 腐蚀 1px 去白边
|
|
133
|
+
fg = fg.filter(ImageFilter.MinFilter(3))
|
|
134
|
+
rgba = im.copy()
|
|
135
|
+
rgba.putalpha(fg)
|
|
136
|
+
return fg, rgba
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
# ---------------------------------------------------------------- 安全区缩放
|
|
140
|
+
def resize_rgba(im: Image.Image, size: tuple[int, int]) -> Image.Image:
|
|
141
|
+
"""RGB 与 alpha 分别 LANCZOS 缩放(Pillow 无预乘,照片缩放边缘可能有轻微灰边)。"""
|
|
142
|
+
r, g, b, a = im.split()
|
|
143
|
+
rgb = Image.merge("RGB", (r, g, b)).resize(size, Image.LANCZOS)
|
|
144
|
+
a2 = a.resize(size, Image.LANCZOS)
|
|
145
|
+
return Image.merge("RGBA", (*rgb.split(), a2))
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def place_with_margins(im: Image.Image, out_w: int, out_h: int,
|
|
149
|
+
margin_left: int, margin_right: int, margin_y: int) -> Image.Image:
|
|
150
|
+
bbox = im.getchannel("A").getbbox()
|
|
151
|
+
if not bbox:
|
|
152
|
+
return Image.new("RGBA", (out_w, out_h))
|
|
153
|
+
im = im.crop(bbox)
|
|
154
|
+
avail_w = max(1, out_w - margin_left - margin_right)
|
|
155
|
+
avail_h = max(1, out_h - 2 * margin_y)
|
|
156
|
+
scale = min(avail_w / im.width, avail_h / im.height)
|
|
157
|
+
new = max(1, int(round(im.width * scale))), max(1, int(round(im.height * scale)))
|
|
158
|
+
im = resize_rgba(im, new)
|
|
159
|
+
canvas = Image.new("RGBA", (out_w, out_h), (0, 0, 0, 0))
|
|
160
|
+
x = margin_left + (avail_w - new[0]) // 2
|
|
161
|
+
y = margin_y + (avail_h - new[1]) // 2
|
|
162
|
+
canvas.alpha_composite(im, (x, y))
|
|
163
|
+
return canvas
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
# ---------------------------------------------------------------- 卡片预览
|
|
167
|
+
def card_preview(icon: Image.Image, out: Path, game: Path | None, card_bg: str | None = None):
|
|
168
|
+
bg = None
|
|
169
|
+
if card_bg:
|
|
170
|
+
bg = Path(card_bg)
|
|
171
|
+
elif game:
|
|
172
|
+
c = game / "gfx/interface/techtree/technology_available_item_bg.dds"
|
|
173
|
+
if c.exists():
|
|
174
|
+
bg = c
|
|
175
|
+
if not bg or not bg.exists():
|
|
176
|
+
print("[!] 找不到卡片背景,跳过 --card-preview")
|
|
177
|
+
return
|
|
178
|
+
card = Image.open(bg).convert("RGBA").resize((183, 84), Image.LANCZOS)
|
|
179
|
+
card.alpha_composite(icon, (74 - icon.width // 2, 50 - icon.height // 2))
|
|
180
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
181
|
+
card.save(out)
|
|
182
|
+
print("[preview]", out)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
# ---------------------------------------------------------------- 主流程
|
|
186
|
+
def convert_one(src: Path, dst: Path, args, game) -> bool:
|
|
187
|
+
try:
|
|
188
|
+
im = Image.open(src).convert("RGBA")
|
|
189
|
+
except Exception as e:
|
|
190
|
+
print(f"[!] 读图失败 {src}: {e}")
|
|
191
|
+
return False
|
|
192
|
+
mode = args.mode
|
|
193
|
+
if mode == "auto":
|
|
194
|
+
mode = detect_mode(im)
|
|
195
|
+
if mode == "none":
|
|
196
|
+
alpha = im.getchannel("A")
|
|
197
|
+
rgba = im
|
|
198
|
+
else:
|
|
199
|
+
alpha, rgba = remove_background(im, mode)
|
|
200
|
+
icon = place_with_margins(rgba, args.width, args.height,
|
|
201
|
+
args.margin_left, args.margin_right, args.margin_y)
|
|
202
|
+
png = dst.with_suffix(".png")
|
|
203
|
+
dst.parent.mkdir(parents=True, exist_ok=True)
|
|
204
|
+
icon.save(png)
|
|
205
|
+
env = None
|
|
206
|
+
r = subprocess.run([sys.executable, str(DDS_HELPER), "make", str(png), str(dst),
|
|
207
|
+
"--width", str(args.width), "--height", str(args.height)],
|
|
208
|
+
capture_output=True, text=True, encoding="utf-8", errors="replace", env=env)
|
|
209
|
+
if r.returncode != 0:
|
|
210
|
+
print(f"[!] DDS 生成失败 {dst}: {(r.stderr or r.stdout or '').strip()[:200]}")
|
|
211
|
+
return False
|
|
212
|
+
if args.card_preview:
|
|
213
|
+
card_preview(icon, Path(args.card_preview), game, args.card_bg)
|
|
214
|
+
bbox = icon.getchannel("A").getbbox() or (0, 0, 0, 0)
|
|
215
|
+
print(f"[ok] {src.name} -> {dst.name} mode={mode} bbox={bbox}")
|
|
216
|
+
return True
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def main() -> int:
|
|
220
|
+
ap = argparse.ArgumentParser(description="科技卡图标导入(照片/矢量 → 173×77 DDS)")
|
|
221
|
+
ap.add_argument("input", help="图片文件或目录")
|
|
222
|
+
ap.add_argument("output", help="输出 .dds 路径;输入为目录时为输出目录")
|
|
223
|
+
ap.add_argument("--mode", default="auto", choices=["auto", "alpha", "white", "checker", "none"])
|
|
224
|
+
ap.add_argument("--width", type=int, default=173)
|
|
225
|
+
ap.add_argument("--height", type=int, default=77)
|
|
226
|
+
ap.add_argument("--margin-left", type=int, default=20)
|
|
227
|
+
ap.add_argument("--margin-right", type=int, default=4)
|
|
228
|
+
ap.add_argument("--margin-y", type=int, default=2)
|
|
229
|
+
ap.add_argument("--card-preview", default=None, help="输出一张 183×84 合成卡预览 PNG")
|
|
230
|
+
ap.add_argument("--card-bg", default=None, help="卡片背景 DDS(默认从游戏目录找)")
|
|
231
|
+
ap.add_argument("--alias", default=None, help="JSON:文件名 stem / 中文名 → 科技 id")
|
|
232
|
+
ap.add_argument("--game", default=None)
|
|
233
|
+
args = ap.parse_args()
|
|
234
|
+
|
|
235
|
+
game = Path(args.game) if args.game else find_game_dir()
|
|
236
|
+
src = Path(args.input)
|
|
237
|
+
dst = Path(args.output)
|
|
238
|
+
aliases = {}
|
|
239
|
+
if args.alias:
|
|
240
|
+
aliases = json.loads(Path(args.alias).read_text(encoding="utf-8"))
|
|
241
|
+
|
|
242
|
+
if src.is_dir():
|
|
243
|
+
dst.mkdir(parents=True, exist_ok=True)
|
|
244
|
+
exts = {".png", ".jpg", ".jpeg", ".webp", ".bmp"}
|
|
245
|
+
files = [p for p in sorted(src.rglob("*")) if p.suffix.lower() in exts]
|
|
246
|
+
if not files:
|
|
247
|
+
print("[!] 目录里没有图片")
|
|
248
|
+
return 1
|
|
249
|
+
ok = 0
|
|
250
|
+
for f in files:
|
|
251
|
+
name = aliases.get(f.stem, aliases.get(f.name, f.stem))
|
|
252
|
+
out = dst / f"{name}.dds"
|
|
253
|
+
if convert_one(f, out, args, game):
|
|
254
|
+
ok += 1
|
|
255
|
+
print(f"[done] {ok}/{len(files)}")
|
|
256
|
+
return 0 if ok else 1
|
|
257
|
+
|
|
258
|
+
if src.is_file():
|
|
259
|
+
name = aliases.get(src.stem, aliases.get(src.name, dst.stem))
|
|
260
|
+
if dst.suffix.lower() != ".dds":
|
|
261
|
+
dst = dst / f"{name}.dds"
|
|
262
|
+
return 0 if convert_one(src, dst, args, game) else 1
|
|
263
|
+
|
|
264
|
+
print("[!] 输入不存在:", src)
|
|
265
|
+
return 1
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
if __name__ == "__main__":
|
|
269
|
+
raise SystemExit(main())
|