initial commit: classic-only M+ planner with upreza-derived maps
This commit is contained in:
@@ -0,0 +1,219 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Combine AtlasLoot boss coords + stitched hi-res maps into web/assets/dungeons.json.
|
||||
|
||||
Pipeline assumption:
|
||||
data/maps_png_hires/<MapName>[_floor<n>].png ← stitch_uprez.py
|
||||
web/assets/maps/<MapName>[_floor<n>].webp ← compress_for_web.py
|
||||
|
||||
Resolution order for AtlasLoot dungeon-id → map file(s):
|
||||
1. data/aliases.json explicit override (keyed by AtlasLoot id OR MapName)
|
||||
2. exact basename match (case-sensitive)
|
||||
3. case-insensitive basename match
|
||||
4. nothing → dungeon listed without a map (still pickable)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from PIL import Image
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
DATA = ROOT / "data"
|
||||
WEB_ASSETS = ROOT / "web" / "assets"
|
||||
WEB_MAPS = WEB_ASSETS / "maps" # holds the .webp files (and is also the source of truth for dimensions)
|
||||
ALIASES_PATH = DATA / "aliases.json"
|
||||
ATLASLOOT_PATH = DATA / "atlasloot_maps.json"
|
||||
OUT_PATH = WEB_ASSETS / "dungeons.json"
|
||||
|
||||
FLOOR_RE = re.compile(r"^(.+?)(?:_floor\d+|_overview)$")
|
||||
|
||||
|
||||
def load_json(p: Path) -> dict:
|
||||
with p.open() as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def join_zone_name(zn) -> str | None:
|
||||
if isinstance(zn, list) and zn:
|
||||
return str(zn[0])
|
||||
if isinstance(zn, str):
|
||||
return zn
|
||||
return None
|
||||
|
||||
|
||||
def join_location(loc) -> str | None:
|
||||
if isinstance(loc, list) and loc:
|
||||
return str(loc[0])
|
||||
if isinstance(loc, str):
|
||||
return loc
|
||||
return None
|
||||
|
||||
|
||||
def collect_bosses(dungeon: dict) -> list[dict]:
|
||||
out: list[dict] = []
|
||||
seen = set()
|
||||
for k, v in dungeon.items():
|
||||
if not k.isdigit() or not isinstance(v, list):
|
||||
continue
|
||||
for entry in v:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
if entry.get("SubZone"):
|
||||
continue
|
||||
cords = entry.get("cords")
|
||||
name = entry.get("1")
|
||||
if not (isinstance(cords, list) and len(cords) == 2 and name):
|
||||
continue
|
||||
key = (name, cords[0], cords[1])
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
out.append({
|
||||
"name": name,
|
||||
"x": cords[0],
|
||||
"y": cords[1],
|
||||
"type": entry.get("pinType", "boss"),
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def floor_sort_key(stem: str):
|
||||
"""Sort _overview before any _floor<n>; floor numbers in numeric order."""
|
||||
if stem.endswith("_overview"):
|
||||
return (0, 0, stem)
|
||||
m = re.match(r".+_floor(\d+)$", stem)
|
||||
if m:
|
||||
return (1, int(m.group(1)), stem)
|
||||
return (2, 0, stem)
|
||||
|
||||
|
||||
def index_map_files() -> dict[str, list[str]]:
|
||||
"""Group web .webp files by base map name → { base: [stem1, stem2, ...] }."""
|
||||
if not WEB_MAPS.exists():
|
||||
return {}
|
||||
grouped: dict[str, list[str]] = {}
|
||||
for p in WEB_MAPS.glob("*.webp"):
|
||||
stem = p.stem
|
||||
m = FLOOR_RE.match(stem)
|
||||
base = m.group(1) if m else stem
|
||||
grouped.setdefault(base, []).append(stem)
|
||||
for base in grouped:
|
||||
grouped[base].sort(key=floor_sort_key)
|
||||
return grouped
|
||||
|
||||
|
||||
def expand_bases(bases: list[str], grouped: dict[str, list[str]]) -> list[str]:
|
||||
"""An alias entry like "Karazhan" expands to all floors; a specific stem
|
||||
like "Karazhan_floor1" passes through as-is."""
|
||||
out: list[str] = []
|
||||
for b in bases:
|
||||
if b in grouped:
|
||||
out.extend(grouped[b])
|
||||
else:
|
||||
out.append(b)
|
||||
return out
|
||||
|
||||
|
||||
def resolve_basenames(
|
||||
dungeon_id: str,
|
||||
map_name: str | None,
|
||||
aliases: dict,
|
||||
grouped: dict[str, list[str]],
|
||||
) -> list[str]:
|
||||
"""Return list of webp basenames to use for this dungeon entry.
|
||||
Note: AtlasLoot's WotLK MapData has a known bug where every dungeon's
|
||||
MapName is "DireMaul"; we therefore never resolve by MapName, only by
|
||||
dungeon_id (with explicit aliases for the rest)."""
|
||||
# Explicit alias override wins
|
||||
if dungeon_id in aliases:
|
||||
return expand_bases(aliases[dungeon_id], grouped)
|
||||
if map_name and map_name in aliases:
|
||||
return expand_bases(aliases[map_name], grouped)
|
||||
|
||||
# Auto-resolve by AtlasLoot id, exact then case-insensitive
|
||||
if dungeon_id in grouped:
|
||||
return list(grouped[dungeon_id])
|
||||
lc_index = {k.lower(): k for k in grouped}
|
||||
if dungeon_id and dungeon_id.lower() in lc_index:
|
||||
return list(grouped[lc_index[dungeon_id.lower()]])
|
||||
|
||||
return []
|
||||
|
||||
|
||||
def main() -> int:
|
||||
atlasloot = load_json(ATLASLOOT_PATH)
|
||||
aliases = load_json(ALIASES_PATH) if ALIASES_PATH.exists() else {}
|
||||
aliases = {k: v for k, v in aliases.items() if not k.startswith("_")}
|
||||
|
||||
# Ascension M+ pool is classic-only.
|
||||
atlasloot = {k: v for k, v in atlasloot.items() if k == "OriginalWoW"}
|
||||
|
||||
grouped = index_map_files()
|
||||
if not grouped:
|
||||
print(f"warning: no hi-res PNGs in {PNG_HIRES}", file=sys.stderr)
|
||||
|
||||
dungeons: list[dict] = []
|
||||
used_bases: set[str] = set()
|
||||
no_map: list[str] = []
|
||||
|
||||
for expansion, entries in atlasloot.items():
|
||||
for did, dungeon in entries.items():
|
||||
if not isinstance(dungeon, dict):
|
||||
continue
|
||||
map_name = dungeon.get("MapName")
|
||||
zone_name = join_zone_name(dungeon.get("ZoneName")) or did
|
||||
bosses = collect_bosses(dungeon)
|
||||
|
||||
basenames = resolve_basenames(did, map_name, aliases, grouped)
|
||||
|
||||
maps_out: list[dict] = []
|
||||
for basename in basenames:
|
||||
src = WEB_MAPS / f"{basename}.webp"
|
||||
if not src.exists():
|
||||
continue
|
||||
with Image.open(src) as im:
|
||||
w, h = im.size
|
||||
used_bases.add(basename)
|
||||
maps_out.append({
|
||||
"image": f"maps/{basename}.webp",
|
||||
"width": w, "height": h,
|
||||
"label": basename,
|
||||
"bosses": bosses if len(basenames) == 1 else [],
|
||||
})
|
||||
|
||||
entry = {
|
||||
"id": did,
|
||||
"expansion": expansion,
|
||||
"name": zone_name,
|
||||
"acronym": dungeon.get("Acronym"),
|
||||
"levelRange": dungeon.get("LevelRange"),
|
||||
"playerLimit": dungeon.get("PlayerLimit"),
|
||||
"location": join_location(dungeon.get("Location")),
|
||||
"reputation": dungeon.get("Reputation"),
|
||||
"maps": maps_out,
|
||||
}
|
||||
if not maps_out:
|
||||
no_map.append(f"{expansion}/{did} (MapName={map_name})")
|
||||
elif len(maps_out) > 1:
|
||||
entry["unassignedBosses"] = bosses
|
||||
dungeons.append(entry)
|
||||
|
||||
dungeons.sort(key=lambda d: (d["expansion"], d["name"]))
|
||||
|
||||
OUT_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
with OUT_PATH.open("w") as f:
|
||||
json.dump({"dungeons": dungeons}, f, indent=2)
|
||||
|
||||
mapped = sum(1 for d in dungeons if d["maps"])
|
||||
print(f"wrote {OUT_PATH} — {len(dungeons)} dungeons, {mapped} with maps")
|
||||
if no_map:
|
||||
print(f"\n{len(no_map)} AtlasLoot entries had no resolvable map:", file=sys.stderr)
|
||||
for s in no_map:
|
||||
print(f" {s}", file=sys.stderr)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,65 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Render every classic dungeon's webp with AtlasLoot boss coords overlaid as
|
||||
red rings, side-by-side with the bare texture. Output to /tmp/alignment/ for
|
||||
human review.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
WEB_MAPS = ROOT / "web" / "assets" / "maps"
|
||||
DUNGEONS_JSON = ROOT / "web" / "assets" / "dungeons.json"
|
||||
OUT_DIR = Path("/tmp/alignment")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if not DUNGEONS_JSON.exists():
|
||||
print("dungeons.json missing, run build_data.py first", file=sys.stderr)
|
||||
return 1
|
||||
OUT_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
data = json.loads(DUNGEONS_JSON.read_text())
|
||||
rendered = []
|
||||
for d in data["dungeons"]:
|
||||
if d.get("expansion") != "OriginalWoW":
|
||||
continue
|
||||
# Use bosses from the first map (single-floor) or unassignedBosses (multi)
|
||||
bosses = d["maps"][0]["bosses"] if d["maps"] and d["maps"][0]["bosses"] else d.get("unassignedBosses", [])
|
||||
if not d["maps"]:
|
||||
continue
|
||||
# Pick the first map for the alignment check
|
||||
m = d["maps"][0]
|
||||
src = WEB_MAPS / Path(m["image"]).name
|
||||
if not src.exists():
|
||||
continue
|
||||
|
||||
with Image.open(src) as im:
|
||||
im = im.convert("RGBA")
|
||||
W, H = im.size
|
||||
draw = ImageDraw.Draw(im)
|
||||
for b in bosses:
|
||||
px = int((b["x"] / 100) * W)
|
||||
py = int((b["y"] / 100) * H)
|
||||
r = 80
|
||||
draw.ellipse([px - r, py - r, px + r, py + r], outline="red", width=14)
|
||||
draw.ellipse([px - 4, py - 4, px + 4, py + 4], fill="red")
|
||||
# Scale down for review
|
||||
scale = 1024 / W
|
||||
im_small = im.resize((1024, int(H * scale)), Image.LANCZOS)
|
||||
out = OUT_DIR / f"{d['id']}.png"
|
||||
im_small.save(out)
|
||||
rendered.append((d["id"], d["name"], len(bosses), out))
|
||||
|
||||
print(f"Rendered {len(rendered)} dungeons → {OUT_DIR}")
|
||||
for did, name, n, p in rendered:
|
||||
print(f" {did:30s} {n:>2d} bosses → {p.name}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,145 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Stitch WoWMapUprez_Dungeons 4×3 BLP tile grids and emit BOTH a hi-res PNG
|
||||
(lossless intermediate) and a web-ready WebP per dungeon-floor in parallel.
|
||||
|
||||
Inputs: data/uprez/Interface/Worldmap/<MapName>/<MapName><floor>_<idx>.blp
|
||||
Outputs: data/maps_png_hires/<MapName>[_floor<n>].png (lossless, ~8 MB)
|
||||
web/assets/maps/<MapName>[_floor<n>].webp (q=82, ~600 KB-1.5 MB)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import argparse
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from collections import defaultdict
|
||||
from concurrent.futures import ProcessPoolExecutor, as_completed
|
||||
from pathlib import Path
|
||||
from PIL import Image
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
SRC = ROOT / "data" / "uprez" / "Interface" / "Worldmap"
|
||||
DST_PNG = ROOT / "data" / "maps_png_hires"
|
||||
DST_WEBP = ROOT / "web" / "assets" / "maps"
|
||||
|
||||
# Two tile-naming conventions:
|
||||
# <Map><floor>_<idx>.blp — multi-level dungeons (e.g. Naxxramas2_5.blp)
|
||||
# <Map><idx>.blp — single-level dungeons (e.g. ZulFarrak5.blp)
|
||||
# In both cases, idx is 1-12 (12 tiles per level in a 4×3 grid).
|
||||
TILE_IDX_RE = re.compile(r"^(?P<prefix>.+?)(?P<idx>1[0-2]|[1-9])\.blp$", re.IGNORECASE)
|
||||
FLOOR_SUFFIX_RE = re.compile(r"^(?P<map>.+?)(?P<floor>\d+)_$")
|
||||
COLS, ROWS = 4, 3
|
||||
TILES_PER_FLOOR = COLS * ROWS
|
||||
|
||||
|
||||
def parse_tile(filename: str):
|
||||
"""Return (mapname, floor, idx) or None. floor=0 indicates the
|
||||
"single-level" form with no explicit floor in the filename."""
|
||||
m = TILE_IDX_RE.match(filename)
|
||||
if not m:
|
||||
return None
|
||||
prefix = m.group("prefix")
|
||||
idx = int(m.group("idx"))
|
||||
fm = FLOOR_SUFFIX_RE.match(prefix)
|
||||
if fm:
|
||||
return fm.group("map"), int(fm.group("floor")), idx
|
||||
return prefix, 0, idx
|
||||
|
||||
|
||||
def stitch_one(args):
|
||||
out_basename, tile_paths, png_dir, webp_dir, max_width, quality, write_png = args
|
||||
# Open tile #1 to learn dimensions; assume uniform.
|
||||
with Image.open(tile_paths[1]) as t1:
|
||||
tw, th = t1.size
|
||||
canvas = Image.new("RGBA", (COLS * tw, ROWS * th))
|
||||
for idx in range(1, TILES_PER_FLOOR + 1):
|
||||
with Image.open(tile_paths[idx]) as tile:
|
||||
col = (idx - 1) % COLS
|
||||
row = (idx - 1) // COLS
|
||||
canvas.paste(tile.convert("RGBA"), (col * tw, row * th))
|
||||
|
||||
if write_png:
|
||||
canvas.save(png_dir / f"{out_basename}.png", optimize=False)
|
||||
|
||||
# WebP needs RGB and may want a downscale
|
||||
img = canvas.convert("RGB")
|
||||
if max_width and img.width > max_width:
|
||||
ratio = max_width / img.width
|
||||
img = img.resize((max_width, int(img.height * ratio)), Image.LANCZOS)
|
||||
img.save(webp_dir / f"{out_basename}.webp", "WEBP", quality=quality, method=4)
|
||||
return out_basename, canvas.size
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--max-width", type=int, default=4096,
|
||||
help="downscale WebP to this max width (default: keep 4096)")
|
||||
ap.add_argument("--quality", type=int, default=82)
|
||||
ap.add_argument("--workers", type=int, default=max(1, (os.cpu_count() or 4) - 1))
|
||||
ap.add_argument("--no-png", action="store_true",
|
||||
help="skip lossless PNG intermediate (save disk + time)")
|
||||
args = ap.parse_args()
|
||||
|
||||
if not SRC.exists():
|
||||
print(f"missing {SRC} — clone WoWMapUprez_Dungeons first", file=sys.stderr)
|
||||
return 1
|
||||
DST_PNG.mkdir(parents=True, exist_ok=True)
|
||||
DST_WEBP.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Group: { (mapname, floor): {idx: path} }; floor=0 is "no floor in filename"
|
||||
groups: dict[tuple[str, int], dict[int, Path]] = defaultdict(dict)
|
||||
for dungeon_dir in sorted(SRC.iterdir()):
|
||||
if not dungeon_dir.is_dir():
|
||||
continue
|
||||
for blp in dungeon_dir.glob("*.blp"):
|
||||
parsed = parse_tile(blp.name)
|
||||
if not parsed:
|
||||
continue
|
||||
mapname, floor, idx = parsed
|
||||
groups[(mapname, floor)][idx] = blp
|
||||
|
||||
floors_per_map: dict[str, set[int]] = defaultdict(set)
|
||||
for (mapname, floor) in groups:
|
||||
floors_per_map[mapname].add(floor)
|
||||
|
||||
jobs = []
|
||||
skipped = []
|
||||
for (mapname, floor), tiles in sorted(groups.items()):
|
||||
if len(tiles) != TILES_PER_FLOOR:
|
||||
skipped.append(f"{mapname} floor {floor}: {len(tiles)}/{TILES_PER_FLOOR}")
|
||||
continue
|
||||
floors = floors_per_map[mapname]
|
||||
if len(floors) == 1:
|
||||
suffix = "" # only one variant
|
||||
elif floor == 0:
|
||||
suffix = "_overview" # mixed: 0 + others
|
||||
else:
|
||||
suffix = f"_floor{floor}"
|
||||
out_base = f"{mapname}{suffix}"
|
||||
jobs.append((out_base, tiles, DST_PNG, DST_WEBP, args.max_width, args.quality, not args.no_png))
|
||||
|
||||
print(f"stitching {len(jobs)} dungeon-floors with {args.workers} workers...")
|
||||
done = 0
|
||||
with ProcessPoolExecutor(max_workers=args.workers) as pool:
|
||||
futures = [pool.submit(stitch_one, j) for j in jobs]
|
||||
for fut in as_completed(futures):
|
||||
try:
|
||||
base, size = fut.result()
|
||||
done += 1
|
||||
if done % 20 == 0 or done == len(jobs):
|
||||
print(f" {done}/{len(jobs)} (latest: {base} {size[0]}×{size[1]})")
|
||||
except Exception as e:
|
||||
print(f" FAIL: {e}", file=sys.stderr)
|
||||
|
||||
print(f"done: {done}/{len(jobs)} stitched")
|
||||
if skipped:
|
||||
print(f"\nskipped {len(skipped)} (incomplete tile sets):", file=sys.stderr)
|
||||
for s in skipped[:10]:
|
||||
print(f" {s}", file=sys.stderr)
|
||||
if len(skipped) > 10:
|
||||
print(f" ... and {len(skipped) - 10} more", file=sys.stderr)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user