update renderer and web viewer to correctly cut map and show caps (#1261)
This commit is contained in:
@@ -0,0 +1,399 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Build map/mode info JSON for tank missions with planes + capture zones.
|
||||
|
||||
Output shape:
|
||||
{
|
||||
"<map_name>": {
|
||||
"<mode>": [
|
||||
{
|
||||
"info": { ... }
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||
BOTS_DIR = SCRIPT_DIR.parent.parent
|
||||
|
||||
DATAMINE_LEVELS_DIR = BOTS_DIR / "War-Thunder-Datamine" / "aces.vromfs.bin_u" / "levels"
|
||||
DATAMINE_MISSIONS_DIR = BOTS_DIR / "War-Thunder-Datamine" / "mis.vromfs.bin_u"
|
||||
MISSION_ROOT = DATAMINE_MISSIONS_DIR / "gamedata" / "missions" / "cta" / "tanks"
|
||||
|
||||
SHARED_LEVELS_DIR = BOTS_DIR / "SHARED" / "MAPS" / "LEVELS"
|
||||
SHARED_MISSIONS_DIR = SHARED_LEVELS_DIR / "MISSIONS"
|
||||
|
||||
SKIP_PARTS = {"mainareas", "battleareas", "spawnareas"}
|
||||
|
||||
|
||||
def _clean_map_key(raw: str) -> str:
|
||||
key = Path(raw).name.strip().rstrip("*")
|
||||
if key.endswith(".png"):
|
||||
key = key[:-4]
|
||||
return key
|
||||
|
||||
|
||||
def _normalize_rel(path: str) -> str:
|
||||
rel = str(path or "").strip().replace("\\", "/").lstrip("/")
|
||||
if rel[:9].lower() == "gamedata/":
|
||||
rel = "gamedata/" + rel[9:]
|
||||
return rel.lower()
|
||||
|
||||
|
||||
def _load_json(path: Path, cache: dict[Path, Any]) -> Any:
|
||||
if path in cache:
|
||||
return cache[path]
|
||||
try:
|
||||
cache[path] = json.loads(path.read_text())
|
||||
except Exception:
|
||||
cache[path] = None
|
||||
return cache[path]
|
||||
|
||||
|
||||
def _repo_rel(path: Path) -> str:
|
||||
try:
|
||||
return str(path.resolve().relative_to(BOTS_DIR.resolve())).replace("\\", "/")
|
||||
except Exception:
|
||||
return str(path)
|
||||
|
||||
|
||||
def _datamine_level_path(level_path: str) -> str:
|
||||
rel = str(level_path or "").replace("\\", "/").lstrip("/")
|
||||
if rel.lower().startswith("levels/"):
|
||||
return f"War-Thunder-Datamine/aces.vromfs.bin_u/{rel}"
|
||||
return f"War-Thunder-Datamine/{rel}" if rel else rel
|
||||
|
||||
|
||||
def _resolve_mission_file(rel: str) -> Path | None:
|
||||
norm = _normalize_rel(rel)
|
||||
if not norm:
|
||||
return None
|
||||
for root in (SHARED_MISSIONS_DIR, DATAMINE_MISSIONS_DIR):
|
||||
raw = root / norm
|
||||
if raw.suffix.lower() == ".blk":
|
||||
cands = [raw.with_suffix(".blkx"), raw]
|
||||
elif raw.suffix.lower() == ".blkx":
|
||||
cands = [raw, raw.with_suffix(".blk")]
|
||||
else:
|
||||
cands = [raw, raw.with_suffix(".blkx"), raw.with_suffix(".blk")]
|
||||
for p in cands:
|
||||
if p.exists():
|
||||
return p
|
||||
return None
|
||||
|
||||
|
||||
def _import_paths(mission_def: dict) -> list[str]:
|
||||
imports = mission_def.get("imports")
|
||||
if not isinstance(imports, dict):
|
||||
return []
|
||||
import_record = imports.get("import_record")
|
||||
records = import_record if isinstance(import_record, list) else [import_record]
|
||||
out: list[str] = []
|
||||
for rec in records:
|
||||
if not isinstance(rec, dict):
|
||||
continue
|
||||
f = rec.get("file")
|
||||
if isinstance(f, str) and f.strip():
|
||||
out.append(f)
|
||||
return out
|
||||
|
||||
|
||||
def _collect_defs(root_file: Path, cache: dict[Path, Any]) -> tuple[dict[str, dict], list[str], list[str]]:
|
||||
root_def = _load_json(root_file, cache)
|
||||
if not isinstance(root_def, dict):
|
||||
return {}, [], []
|
||||
|
||||
areas: dict[str, dict] = {}
|
||||
files_used: list[str] = []
|
||||
imports_used: list[str] = []
|
||||
queue: list[Path] = [root_file]
|
||||
seen: set[Path] = set()
|
||||
|
||||
while queue:
|
||||
cur_file = queue.pop(0).resolve()
|
||||
if cur_file in seen:
|
||||
continue
|
||||
seen.add(cur_file)
|
||||
files_used.append(str(cur_file))
|
||||
|
||||
cur_def = _load_json(cur_file, cache)
|
||||
if not isinstance(cur_def, dict):
|
||||
continue
|
||||
|
||||
cur_areas = cur_def.get("areas")
|
||||
if isinstance(cur_areas, dict):
|
||||
for name, area in cur_areas.items():
|
||||
if isinstance(name, str) and isinstance(area, dict) and name not in areas:
|
||||
areas[name] = area
|
||||
|
||||
for rel in _import_paths(cur_def):
|
||||
imports_used.append(_normalize_rel(rel))
|
||||
p = _resolve_mission_file(rel)
|
||||
if p is not None:
|
||||
queue.append(p)
|
||||
|
||||
return areas, files_used, sorted(set(imports_used))
|
||||
|
||||
|
||||
def _radius_from_tm(tm: list) -> float:
|
||||
try:
|
||||
ax = float(tm[0][0])
|
||||
az = float(tm[0][2])
|
||||
bx = float(tm[2][0])
|
||||
bz = float(tm[2][2])
|
||||
ra = (ax * ax + az * az) ** 0.5
|
||||
rb = (bx * bx + bz * bz) ** 0.5
|
||||
return max(ra, rb)
|
||||
except Exception:
|
||||
return 0.0
|
||||
|
||||
|
||||
def _capture_prefixes(mission_type: str, mode_key: str) -> list[str]:
|
||||
m = (mission_type or "").lower()
|
||||
k = (mode_key or "").lower()
|
||||
if "conq" in m or "conq" in k:
|
||||
return ["conq_capture_area_"]
|
||||
if "dom" in m or "dom" in k:
|
||||
return ["dom_capture_area_"]
|
||||
if "bttl" in m or "bttl" in k:
|
||||
return ["bttl_t1_capture_area_", "bttl_t2_capture_area_"]
|
||||
return ["capture_area_"]
|
||||
|
||||
|
||||
def _capture_sort(name: str) -> tuple[int, str]:
|
||||
digits = "".join(ch for ch in name if ch.isdigit())
|
||||
return (int(digits) if digits else 999, name)
|
||||
|
||||
|
||||
def _pick_capture_areas(areas: dict[str, dict], mission_type: str, mode_key: str) -> list[dict]:
|
||||
prefixes = _capture_prefixes(mission_type, mode_key)
|
||||
names: list[str] = []
|
||||
for name, area in areas.items():
|
||||
if not isinstance(area, dict):
|
||||
continue
|
||||
lname = name.lower()
|
||||
if "capture_area" not in lname or lname.startswith("briefing_"):
|
||||
continue
|
||||
if not any(lname.startswith(p) for p in prefixes):
|
||||
continue
|
||||
names.append(name)
|
||||
|
||||
if not names:
|
||||
return []
|
||||
|
||||
suffix_prio = ["_arcade", "_realistic", "_hardcore", "_simulator", ""]
|
||||
groups: dict[str, dict[str, str]] = {}
|
||||
for name in names:
|
||||
lname = name.lower()
|
||||
suffix = ""
|
||||
base = lname
|
||||
for sfx in suffix_prio[:-1]:
|
||||
if lname.endswith(sfx):
|
||||
suffix = sfx
|
||||
base = lname[:-len(sfx)]
|
||||
break
|
||||
groups.setdefault(base, {})[suffix] = name
|
||||
|
||||
chosen: list[str] = []
|
||||
for base in sorted(groups.keys(), key=_capture_sort):
|
||||
variants = groups[base]
|
||||
for sfx in suffix_prio:
|
||||
if sfx in variants:
|
||||
chosen.append(variants[sfx])
|
||||
break
|
||||
|
||||
out: list[dict] = []
|
||||
for name in chosen:
|
||||
area = areas.get(name)
|
||||
if not isinstance(area, dict):
|
||||
continue
|
||||
tm = area.get("tm")
|
||||
if not isinstance(tm, list) or len(tm) < 4 or not isinstance(tm[3], list):
|
||||
continue
|
||||
try:
|
||||
cx = float(tm[3][0])
|
||||
cz = float(tm[3][2])
|
||||
except Exception:
|
||||
continue
|
||||
out.append({
|
||||
"name": name,
|
||||
"type": str(area.get("type", "")),
|
||||
"x": cx,
|
||||
"z": cz,
|
||||
"radius": _radius_from_tm(tm),
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def _battle_areas(areas: dict[str, dict]) -> list[dict]:
|
||||
out: list[dict] = []
|
||||
for name, area in areas.items():
|
||||
if not isinstance(area, dict):
|
||||
continue
|
||||
lname = name.lower()
|
||||
if "battle_area" not in lname or lname.startswith("briefing_"):
|
||||
continue
|
||||
tm = area.get("tm")
|
||||
if not isinstance(tm, list) or len(tm) < 4 or not isinstance(tm[3], list):
|
||||
continue
|
||||
try:
|
||||
cx = float(tm[3][0])
|
||||
cz = float(tm[3][2])
|
||||
except Exception:
|
||||
continue
|
||||
out.append({
|
||||
"name": name,
|
||||
"type": str(area.get("type", "")),
|
||||
"x": cx,
|
||||
"z": cz,
|
||||
"radius": _radius_from_tm(tm),
|
||||
})
|
||||
out.sort(key=lambda x: x["name"])
|
||||
return out
|
||||
|
||||
|
||||
def _level_info(level_stem: str, cache: dict[Path, Any]) -> dict:
|
||||
cands = [
|
||||
DATAMINE_LEVELS_DIR / f"{level_stem}.blkx",
|
||||
SHARED_LEVELS_DIR / f"{level_stem}.blkx",
|
||||
SHARED_LEVELS_DIR / "DATAMINE" / f"{level_stem}.blkx",
|
||||
]
|
||||
for p in cands:
|
||||
if not p.exists():
|
||||
continue
|
||||
data = _load_json(p, cache)
|
||||
if not isinstance(data, dict):
|
||||
continue
|
||||
return {
|
||||
"file": _repo_rel(p),
|
||||
"tankMapCoord0": data.get("tankMapCoord0"),
|
||||
"tankMapCoord1": data.get("tankMapCoord1"),
|
||||
"aiTanksMapCoord0": data.get("aiTanksMapCoord0"),
|
||||
"aiTanksMapCoord1": data.get("aiTanksMapCoord1"),
|
||||
"customLevelTankMap": data.get("customLevelTankMap"),
|
||||
"customLevelMap": data.get("customLevelMap"),
|
||||
}
|
||||
return {}
|
||||
|
||||
|
||||
def _has_planes(allowed: dict | None, imports_used: list[str]) -> bool:
|
||||
if isinstance(allowed, dict):
|
||||
val = allowed.get("isAirplanesAllowed")
|
||||
if isinstance(val, bool):
|
||||
return val
|
||||
return any("air_spawns" in p for p in imports_used)
|
||||
|
||||
|
||||
def _has_tanks(allowed: dict | None) -> bool:
|
||||
if isinstance(allowed, dict):
|
||||
val = allowed.get("isTanksAllowed")
|
||||
if isinstance(val, bool):
|
||||
return val
|
||||
return True
|
||||
|
||||
|
||||
def _mode_key(mission_cfg: dict, mission_file: Path) -> str:
|
||||
postfix = mission_cfg.get("postfix")
|
||||
if isinstance(postfix, str) and postfix.strip():
|
||||
return postfix.lstrip("_")
|
||||
mtype = mission_cfg.get("type")
|
||||
if isinstance(mtype, str) and mtype.strip():
|
||||
return mtype
|
||||
return mission_file.stem
|
||||
|
||||
|
||||
def should_skip_mission_file(path: Path) -> bool:
|
||||
if path.name.startswith("template_"):
|
||||
return True
|
||||
parts = {p.lower() for p in path.parts}
|
||||
if SKIP_PARTS & parts:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def build_index() -> dict[str, dict[str, list[dict[str, Any]]]]:
|
||||
cache: dict[Path, Any] = {}
|
||||
output: dict[str, dict[str, list[dict[str, Any]]]] = {}
|
||||
|
||||
for mission_file in sorted(MISSION_ROOT.rglob("*.blkx")):
|
||||
if should_skip_mission_file(mission_file):
|
||||
continue
|
||||
|
||||
mission_def = _load_json(mission_file, cache)
|
||||
if not isinstance(mission_def, dict):
|
||||
continue
|
||||
mission_settings = mission_def.get("mission_settings")
|
||||
if not isinstance(mission_settings, dict):
|
||||
continue
|
||||
mission_cfg = mission_settings.get("mission")
|
||||
if not isinstance(mission_cfg, dict):
|
||||
continue
|
||||
|
||||
level_path = mission_cfg.get("level", "")
|
||||
if not isinstance(level_path, str) or not level_path.strip():
|
||||
continue
|
||||
level_stem = Path(level_path).stem
|
||||
mode = _mode_key(mission_cfg, mission_file)
|
||||
mission_type = str(mission_cfg.get("type", ""))
|
||||
allowed = mission_cfg.get("allowedUnitTypes")
|
||||
|
||||
areas, files_used, imports_used = _collect_defs(mission_file, cache)
|
||||
captures = _pick_capture_areas(areas, mission_type, mode)
|
||||
if not captures:
|
||||
continue
|
||||
|
||||
has_tanks = _has_tanks(allowed if isinstance(allowed, dict) else None)
|
||||
has_planes = _has_planes(allowed if isinstance(allowed, dict) else None, imports_used)
|
||||
if not (has_tanks and has_planes):
|
||||
continue
|
||||
|
||||
battle_areas = _battle_areas(areas)
|
||||
info = {
|
||||
"mission_file": _repo_rel(mission_file),
|
||||
"level_path": _datamine_level_path(level_path),
|
||||
"mission_type": mission_type,
|
||||
"mode_postfix": mission_cfg.get("postfix", ""),
|
||||
"allowed_unit_types": allowed if isinstance(allowed, dict) else {},
|
||||
"useAlternativeMapCoord": mission_cfg.get("useAlternativeMapCoord"),
|
||||
"capture_areas": captures,
|
||||
"battle_areas": battle_areas,
|
||||
"level_def": _level_info(level_stem, cache),
|
||||
"imports_count": len(imports_used),
|
||||
"files_used_count": len(files_used),
|
||||
}
|
||||
|
||||
output.setdefault(level_stem, {}).setdefault(mode, []).append({"info": info})
|
||||
|
||||
return output
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="Build map_name -> mode -> [info] JSON")
|
||||
parser.add_argument(
|
||||
"--out",
|
||||
type=Path,
|
||||
default=SCRIPT_DIR.parent / "map_mode_info.json",
|
||||
help="Output JSON path",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
data = build_index()
|
||||
args.out.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
|
||||
|
||||
maps = len(data)
|
||||
modes = sum(len(v) for v in data.values())
|
||||
entries = sum(len(items) for v in data.values() for items in v.values())
|
||||
print(f"Wrote {args.out} (maps={maps}, modes={modes}, entries={entries})")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user