switch to keystone.guru tiles + enemy data
Replaces the upreza-derived 4K dungeon textures + AtlasLoot boss-coord overlay (which had a consistent positional offset against texture skulls) with keystone.guru's z=4 tile pyramid stitched to 6144x4096 WebP per floor. kg's split_floors.js gives per-dungeon enemies, packs (polygons), patrols (polylines), and map icons calibrated to those tiles, so overlays align pixel-perfectly. 27/29 classic dungeons now have full enemy/pack data; ZG + Sunken Temple have maps only. Pipeline: tools/kg_fetch.py -> tools/kg_stitch.py -> tools/kg_build_data.py.
This commit is contained in:
+1
-2
@@ -14,8 +14,6 @@ Thumbs.db
|
||||
*.swp
|
||||
|
||||
# Large data assets — not tracked in git (no LFS yet)
|
||||
# data/uprez is an 11 GB sparse-clone of upreza interface
|
||||
data/uprez/
|
||||
# legacy hires PNG dump (gone, but keep ignored)
|
||||
data/maps_png_hires/
|
||||
# 336 MB of WebP map tiles — exclude until LFS is set up
|
||||
@@ -23,3 +21,4 @@ web/assets/maps/
|
||||
|
||||
# Build output
|
||||
output/
|
||||
data/kg/
|
||||
|
||||
@@ -1,48 +1,46 @@
|
||||
# mplus-routes
|
||||
|
||||
Mythic+ route planner targeted at `mplus.exil.es` (Ascension community).
|
||||
Static web app: pick a dungeon, see the 4K AI-upscaled map with boss
|
||||
positions, click to drop waypoints/pull markers, draw a route, share via
|
||||
URL hash.
|
||||
|
||||
## Data sources
|
||||
|
||||
- **Boss coords:** AtlasLootAscension `MapData.lua` (vanilla / TBC / WotLK
|
||||
data files), exported by `bisbeard/export_atlasloot_maps.lua` →
|
||||
`data/atlasloot_maps.json`.
|
||||
- **Dungeon maps:** [keyboardturner/WoWMapUprez_Dungeons][uprez] —
|
||||
AI-upscaled (SRCNN) ~4K dungeon maps for every WoW expansion. Tiles are
|
||||
4×3 grids of 1024×1024 BLPs that we stitch into 4096×3072 PNGs.
|
||||
|
||||
[uprez]: https://github.com/keyboardturner/WoWMapUprez_Dungeons
|
||||
Mythic+ route planner for Ascension classic-vanilla dungeons. Static web app
|
||||
targeted at `mplus.exil.es`. Uses **Keystone.guru** map tiles + enemy data,
|
||||
courtesy of RaiderIO (verbal permission, see attribution below).
|
||||
|
||||
## Stack
|
||||
|
||||
- **Maps:** keystone.guru z=4 tile pyramid stitched to 6144×4096 WebP per
|
||||
dungeon-floor (~150–600 KB each).
|
||||
- **Data:** keystone.guru `mapcontext/data/<slug>/<mapping_id>/split_floors.js`
|
||||
— enemies (with positions, classification, NPC linkage), enemy packs
|
||||
(polygon outlines), enemy patrols (polylines), map icons (skulls, doors,
|
||||
comments, gateways), NPC names from companion `en_US.js`.
|
||||
- **Frontend:** vanilla HTML/CSS/JS, single page, no build step. Pan/zoom
|
||||
via CSS transform on the canvas stage; all overlays drawn into an SVG
|
||||
matching the map's image-pixel coord space.
|
||||
- **Build pipeline:** Python + Pillow (uses Pillow's native BLP decoder).
|
||||
- **Hosting:** static. ~260 MB of WebPs total — drop behind any nginx /
|
||||
CDN. No backend.
|
||||
via CSS transform on the canvas stage; SVG overlay in image-pixel space.
|
||||
- **Hosting:** static. ~470 MB of WebPs total. Drop behind nginx; no backend.
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
mplus-routes/
|
||||
├── data/
|
||||
│ ├── uprez/ # sparse-clone of WoWMapUprez_Dungeons
|
||||
│ ├── atlasloot_maps.json # boss coords by MapName
|
||||
│ └── aliases.json # AtlasLoot id → uprez basename overrides
|
||||
│ ├── kg_dungeons.json registry: tile_key, data_slug, mapping_id per dungeon
|
||||
│ └── kg/ raw kg tiles + data (gitignored)
|
||||
│ ├── _summary.json
|
||||
│ └── <tile_key>/
|
||||
│ ├── split_floors.js
|
||||
│ ├── lang.js
|
||||
│ └── floor<n>/z4/<x>_<y>.png
|
||||
├── tools/
|
||||
│ ├── stitch_uprez.py # BLP tiles → 4096×3072 WebP per floor
|
||||
│ └── build_data.py # combine into web/assets/dungeons.json
|
||||
│ ├── kg_fetch.py fetch tiles + data files for every dungeon in the registry
|
||||
│ ├── kg_resync_summary.py rebuild data/kg/_summary.json from disk (after partial fetches)
|
||||
│ ├── kg_stitch.py assemble 16×16 tile grids → web/assets/maps/<key>[_floor<n>].webp
|
||||
│ ├── kg_build_data.py combine into web/assets/dungeons.json
|
||||
│ └── check_kg_alignment.py server-side alignment sanity check (PIL renders into /tmp/)
|
||||
└── web/
|
||||
├── index.html
|
||||
├── style.css
|
||||
├── app.js
|
||||
└── assets/
|
||||
├── dungeons.json # combined data feeding the UI
|
||||
└── maps/ # per-dungeon WebP files served to browsers
|
||||
├── dungeons.json
|
||||
└── maps/ per-dungeon WebPs (gitignored — too big without LFS)
|
||||
```
|
||||
|
||||
## Build
|
||||
@@ -51,61 +49,88 @@ mplus-routes/
|
||||
python3 -m venv .venv
|
||||
.venv/bin/pip install Pillow
|
||||
|
||||
# 1. (one-time) sparse-clone the uprez map pack into data/uprez/
|
||||
git clone --depth=1 --no-checkout https://github.com/keyboardturner/WoWMapUprez_Dungeons.git data/uprez
|
||||
cd data/uprez && git sparse-checkout init --cone && git sparse-checkout set "Interface/Worldmap" && git checkout && cd ../..
|
||||
|
||||
# 2. stitch BLP tiles → WebP (parallel, ~2 minutes on a modern laptop)
|
||||
.venv/bin/python tools/stitch_uprez.py --no-png
|
||||
|
||||
# 3. combine boss coords + map index → dungeons.json
|
||||
.venv/bin/python tools/build_data.py
|
||||
.venv/bin/python tools/kg_fetch.py --workers 32 --zoom 4 # fetch tiles + data (~1.3 GB raw)
|
||||
.venv/bin/python tools/kg_stitch.py --workers 4 # → web/assets/maps/*.webp (~470 MB)
|
||||
.venv/bin/python tools/kg_build_data.py # → web/assets/dungeons.json
|
||||
```
|
||||
|
||||
`stitch_uprez.py --max-width 2048` halves output size if 4K is overkill.
|
||||
|
||||
## Run locally
|
||||
|
||||
```bash
|
||||
cd web && python3 -m http.server 8765
|
||||
# http://localhost:8765/
|
||||
cd web && python3 -m http.server 8765 # → http://localhost:8765/
|
||||
```
|
||||
|
||||
## Hosting
|
||||
## Deployment
|
||||
|
||||
The whole `web/` directory is fully static.
|
||||
Ansible role + playbook for `mplus.exil.es` live in
|
||||
`/home/sub/repos/sub-net/ansible/roles/mplus_routes` and
|
||||
`playbooks/setup_mplus_routes.yml`. Targets `exiles01:/srv/www/mplus.exil.es/`
|
||||
behind the public HAProxy SNI router. The wildcard `*.exil.es` cert covers
|
||||
TLS automatically.
|
||||
|
||||
- **Domain:** `mplus.exil.es`
|
||||
- **TLS:** wildcard via the cluster's existing edge (Caddy / Traefik / certbot).
|
||||
- **Backend:** none — routes encode into `#<base64>` for sharing.
|
||||
First-time bring-up:
|
||||
1. Add `mplus.exil.es` A/AAAA records in NetBox (→ public HAProxy VIPs)
|
||||
2. Push the Ansible commit, trigger `Ansible_DeployPublicHaproxy`
|
||||
3. Materialize `web/assets/maps/` on the deploy host (run the build pipeline above)
|
||||
4. Run `playbooks/setup_mplus_routes.yml`
|
||||
|
||||
A short-URL service is a clean follow-up if shareable `/r/<slug>` URLs
|
||||
become wanted (KV-backed Cloudflare Worker would be enough).
|
||||
## Coverage
|
||||
|
||||
## Refresh data sources
|
||||
29 classic dungeons in the picker:
|
||||
|
||||
- **AtlasLoot coords:** re-export with `lua5.4 ../bisbeard/export_atlasloot_maps.lua > data/atlasloot_maps.json`
|
||||
after AtlasLootAscension updates.
|
||||
- **Maps:** `cd data/uprez && git pull` then re-run `stitch_uprez.py`
|
||||
+ `build_data.py` after upstream uprez updates.
|
||||
| Dungeon | Enemies | Packs | Map icons |
|
||||
|---|---:|---:|---:|
|
||||
| Ragefire Chasm | 139 | 9 | 1 |
|
||||
| The Deadmines | 218 | 26 | 13 |
|
||||
| Wailing Caverns | 261 | 18 | 8 |
|
||||
| Shadowfang Keep | 140 | 6 | 5 |
|
||||
| The Stockade | 94 | 21 | 5 |
|
||||
| Blackfathom Deeps | 240 | 20 | 21 |
|
||||
| Gnomeregan | 282 | 44 | 25 |
|
||||
| Razorfen Kraul | 199 | 39 | 12 |
|
||||
| Razorfen Downs | 219 | 53 | 12 |
|
||||
| SM (4 wings) | 69+96+87+79 | 22+25+20+25 | 7+2+4+4 |
|
||||
| Uldaman | 261 | 33 | 12 |
|
||||
| Zul'Farrak | 302 | 40 | 6 |
|
||||
| Maraudon | 548 | 107 | 4 |
|
||||
| Blackrock Depths | 819 | 169 | 33 |
|
||||
| Lower Blackrock Spire | 380 | 98 | 17 |
|
||||
| Upper Blackrock Spire | 207 | 56 | 20 |
|
||||
| Stratholme | 377 | 77 | 19 |
|
||||
| Scholomance | 344 | 64 | 14 |
|
||||
| Dire Maul (3 wings) | 229+300+241 | 58+44+48 | 7+7+10 |
|
||||
| Naxxramas (40) | 574 | 92 | 2 |
|
||||
| Molten Core | 241 | 39 | 1 |
|
||||
| Blackwing Lair | 79 | 15 | 1 |
|
||||
| Zul'Gurub | 0 | 0 | 0 |
|
||||
| Sunken Temple | 0 | 0 | 0 |
|
||||
|
||||
ZG and Sunken Temple have map tiles but no enemy data — kg's mapping_id
|
||||
search didn't find populated data for them at time of build. Picker still
|
||||
lets you draw routes on the map manually.
|
||||
|
||||
## Attribution
|
||||
|
||||
Map textures derived from [WoWMapUprez_Dungeons][uprez].
|
||||
World of Warcraft trademarks belong to Blizzard Entertainment.
|
||||
This site is not affiliated with Blizzard.
|
||||
Map tiles and enemy data derived from
|
||||
[Keystone.guru](https://keystone.guru/) (RaiderIO, Inc.). Used with
|
||||
permission from the maintainers for the Ascension community deployment at
|
||||
`mplus.exil.es`. World of Warcraft trademarks belong to Blizzard
|
||||
Entertainment; this site is not affiliated with Blizzard.
|
||||
|
||||
## Refresh data sources
|
||||
|
||||
Both kg's tiles and data update over time. To refresh:
|
||||
- Re-run the wide mapping-id probe (`tools/` doesn't ship one — easiest is
|
||||
to fetch the kg homepage and find current ids, or re-run a probe like the
|
||||
one in `data/kg/_summary.json` headers note).
|
||||
- Update `data/kg_dungeons.json` with new mapping_ids.
|
||||
- Re-run `kg_fetch.py` → `kg_stitch.py` → `kg_build_data.py`.
|
||||
|
||||
## Known gaps
|
||||
|
||||
- AtlasLootAscension's WotLK `MapData.lua` has a known upstream bug where
|
||||
every dungeon's `MapName` is `"DireMaul"` — we ignore `MapName` for
|
||||
resolution and use the AtlasLoot dungeon-id (with explicit aliases as
|
||||
needed) instead.
|
||||
- A handful of dungeons in AtlasLoot don't have a corresponding upreza
|
||||
texture (e.g. SerpentshrineCavern interior) — those land in the
|
||||
picker without a map and can still be used for blank-canvas routing.
|
||||
- No trash-pull data — coords are boss-only. Trash groups are authored
|
||||
by hand using the Pull tool.
|
||||
- Multi-floor dungeons currently put all bosses into `unassignedBosses`
|
||||
rather than mapping each pin to its specific floor. Floor-assignment
|
||||
is a follow-up tool.
|
||||
- 2/29 dungeons (Zul'Gurub, Sunken Temple) are present without enemy data.
|
||||
- Multi-floor dungeons (LBRS 7, BWL 4, Naxx 6, etc.) have all floors mapped
|
||||
individually; the floor tabs in the UI let you switch between them.
|
||||
- No trash-pull authoring beyond kg's data — packs are inherited as-is. For
|
||||
custom-routing ("skip this pack, take this one"), use the route+pull tools
|
||||
in the toolbar.
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
{
|
||||
"_comment": "Maps AtlasLoot dungeon-id → list of webp basenames (or base map names). A bare base like 'Ulduar' expands to every stitched floor: Ulduar_overview, Ulduar_floor1, …. Case-insensitive auto-match handles most names — only put entries here when the names truly differ.",
|
||||
|
||||
"AuchAuchenaiCrypts": ["AuchenaiCrypts"],
|
||||
"AuchManaTombs": ["ManaTombs"],
|
||||
"AuchSethekkHalls": ["SethekkHalls"],
|
||||
"AuchShadowLabyrinth": ["ShadowLabyrinth"],
|
||||
|
||||
"CFRSerpentshrineCavern": ["CoilfangReservoir"],
|
||||
"CFRTheSlavePens": ["TheSlavePens"],
|
||||
"CFRTheSteamvault": ["TheSteamvault"],
|
||||
"CFRTheUnderbog": ["TheUnderbog"],
|
||||
|
||||
"CoTBlackMorass": ["CoTTheBlackMorass"],
|
||||
"CoTHyjal": ["CoTMountHyjal"],
|
||||
"CoTOldHillsbrad": ["CoTHillsbradFoothills"],
|
||||
"CoTOldStratholme": ["CoTStratholme"],
|
||||
|
||||
"FHHallsOfReflection": ["HallsofReflection"],
|
||||
"FHPitOfSaron": ["PitofSaron"],
|
||||
"FHTheForgeOfSouls": ["TheForgeofSouls"],
|
||||
|
||||
"HCBloodFurnace": ["TheBloodFurnace"],
|
||||
"HCHellfireRamparts": ["HellfireRamparts"],
|
||||
"HCMagtheridonsLair": ["Magtheridonslair"],
|
||||
"HCTheShatteredHalls": ["TheShatteredHalls"],
|
||||
|
||||
"TempestKeepArcatraz": ["TheArcatraz"],
|
||||
"TempestKeepBotanica": ["TheBotanica"],
|
||||
"TempestKeepMechanar": ["TheMechanar"],
|
||||
"TempestKeepTheEye": ["TempestKeep"],
|
||||
|
||||
"Naxxramas60": ["Naxxramas"],
|
||||
"Onyxia60": ["OnyxiasLair"],
|
||||
|
||||
"ObsidianSanctum": ["TheObsidianSanctum"],
|
||||
"RubySanctum": ["TheRubySanctum"],
|
||||
"TheRuinsofAhnQiraj": ["RuinsofAhnQiraj"],
|
||||
"TheTempleofAhnQiraj": ["AhnQiraj"],
|
||||
"TheSunkenTemple": ["TheTempleofAtalhakkar"],
|
||||
"ragefire": ["Ragefire"],
|
||||
|
||||
"_icc": "AtlasLoot has IcecrownCitadelA/B/C as separate; uprez has only IcecrownCitadel",
|
||||
"IcecrownCitadelA": ["IcecrownCitadel"],
|
||||
"IcecrownCitadelB": ["IcecrownCitadel"],
|
||||
"IcecrownCitadelC": ["IcecrownCitadel"],
|
||||
|
||||
"_ulduar": "AtlasLoot has UlduarA/B/C/D/E as separate (5 wings); uprez has multi-floor Ulduar — show all floors for any wing",
|
||||
"UlduarA": ["Ulduar"],
|
||||
"UlduarB": ["Ulduar"],
|
||||
"UlduarC": ["Ulduar"],
|
||||
"UlduarD": ["Ulduar"],
|
||||
"UlduarE": ["Ulduar"]
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"_comment": "Keystone.guru classic dungeon registry. tile_key = used in /tiles/{expansion}/{tile_key}/{floor}/{z}/{x}_{y}.png. data_slug = used in /compiled/<hash>/mapcontext/data/{data_slug}/{mapping_id}/split_floors.js. mapping_ids are the LATEST published versions found 2026-04-25.",
|
||||
"_max_zoom": 4,
|
||||
"_tile_size": [384, 256],
|
||||
"_expansion": "classic",
|
||||
"dungeons": [
|
||||
{"name": "Ragefire Chasm", "tile_key": "ragefire_chasm", "data_slug": "ragefire-chasm", "mapping_id": 188},
|
||||
{"name": "The Deadmines", "tile_key": "deadmines", "data_slug": "the-deadmines", "mapping_id": 196},
|
||||
{"name": "Wailing Caverns", "tile_key": "wailing_caverns", "data_slug": "wailing-caverns", "mapping_id": 200},
|
||||
{"name": "Shadowfang Keep", "tile_key": "shadowfang_keep", "data_slug": "shadowfang-keep", "mapping_id": 190},
|
||||
{"name": "The Stockade", "tile_key": "the_stockade", "data_slug": "the-stockade", "mapping_id": 189},
|
||||
{"name": "Blackfathom Deeps", "tile_key": "blackfathom_deeps", "data_slug": "blackfathom-deeps", "mapping_id": 197},
|
||||
{"name": "Gnomeregan", "tile_key": "gnomeregan", "data_slug": "gnomeregan", "mapping_id": 201},
|
||||
{"name": "Razorfen Kraul", "tile_key": "razorfen_kraul", "data_slug": "razorfen-kraul", "mapping_id": 227},
|
||||
{"name": "Razorfen Downs", "tile_key": "razorfen_downs", "data_slug": "razorfen-downs", "mapping_id": 228},
|
||||
{"name": "SM: Armory", "tile_key": "scarlet_monastery_armory", "data_slug": "scarlet-monastery-armory", "mapping_id": 232},
|
||||
{"name": "SM: Cathedral", "tile_key": "scarlet_monastery_cathedral", "data_slug": "scarlet-monastery-cathedral", "mapping_id": 233},
|
||||
{"name": "SM: Graveyard", "tile_key": "scarlet_monastery_graveyard", "data_slug": "scarlet-monastery-graveyard", "mapping_id": 202},
|
||||
{"name": "SM: Library", "tile_key": "scarlet_monastery_library", "data_slug": "scarlet-monastery-library", "mapping_id": 234},
|
||||
{"name": "Uldaman", "tile_key": "uldaman", "data_slug": "uldaman", "mapping_id": 222},
|
||||
{"name": "Zul'Farrak", "tile_key": "zul_farrak", "data_slug": "zulfarrak", "mapping_id": 204},
|
||||
{"name": "Maraudon", "tile_key": "maraudon", "data_slug": "maraudon", "mapping_id": 203},
|
||||
{"name": "Blackrock Depths", "tile_key": "blackrock_depths", "data_slug": "blackrock-depths", "mapping_id": 206},
|
||||
{"name": "Lower Blackrock Spire","tile_key": "lower_blackrock_spire", "data_slug": "lower-blackrock-spire", "mapping_id": 247},
|
||||
{"name": "Upper Blackrock Spire","tile_key": "upper_blackrock_spire", "data_slug": "upper-blackrock-spire", "mapping_id": 246},
|
||||
{"name": "Stratholme", "tile_key": "stratholme", "data_slug": "stratholme", "mapping_id": 215},
|
||||
{"name": "Scholomance", "tile_key": "scholomance", "data_slug": "scholomance", "mapping_id": 219},
|
||||
{"name": "Dire Maul: North", "tile_key": "dire_maul_north", "data_slug": "dire-maul-north", "mapping_id": 218},
|
||||
{"name": "Dire Maul: East", "tile_key": "dire_maul_east", "data_slug": "dire-maul-east", "mapping_id": 217},
|
||||
{"name": "Dire Maul: West", "tile_key": "dire_maul_west", "data_slug": "dire-maul-west", "mapping_id": 216},
|
||||
{"name": "Naxxramas (40)", "tile_key": "naxxramas_classic", "data_slug": "naxxramas", "mapping_id": 60},
|
||||
{"name": "Molten Core", "tile_key": "moltencore", "data_slug": "molten-core", "mapping_id": 375},
|
||||
{"name": "Blackwing Lair", "tile_key": "blackwinglair", "data_slug": "blackwing-lair", "mapping_id": 422},
|
||||
{"name": "Zul'Gurub", "tile_key": "zulgurub", "data_slug": "zulgurub", "mapping_id": 413},
|
||||
{"name": "Sunken Temple", "tile_key": "the_temple_of_atal_hakkar", "data_slug": null, "mapping_id": null}
|
||||
]
|
||||
}
|
||||
@@ -1,219 +0,0 @@
|
||||
#!/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())
|
||||
@@ -1,65 +0,0 @@
|
||||
#!/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,59 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Render every classic dungeon's first-floor webp with kg's enemy positions
|
||||
overlaid as colored circles, plus pack polygon outlines, to verify alignment
|
||||
visually. Output: /tmp/kg_alignment/<key>.png
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import json
|
||||
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/kg_alignment")
|
||||
|
||||
CLASS_RADIUS = {1: 14, 2: 18, 3: 28, 4: 38}
|
||||
CLASS_FILL = {1: "#9aa1aa", 2: "#d6d6dc", 3: "#d63b3b", 4: "#ffd83a"}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
OUT_DIR.mkdir(parents=True, exist_ok=True)
|
||||
data = json.loads(DUNGEONS_JSON.read_text())
|
||||
rendered = []
|
||||
for d in data["dungeons"]:
|
||||
if not d["maps"]:
|
||||
continue
|
||||
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 p in m.get("packs", []):
|
||||
pts = [(int(x), int(y)) for x, y in p["vertices"]]
|
||||
if len(pts) >= 3:
|
||||
draw.polygon(pts, outline=p["color"], width=4)
|
||||
for e in m.get("enemies", []):
|
||||
cls = e.get("classification") or 1
|
||||
r = CLASS_RADIUS.get(cls, 14)
|
||||
fill = CLASS_FILL.get(cls, "#9aa1aa")
|
||||
x, y = int(e["pos"][0]), int(e["pos"][1])
|
||||
draw.ellipse([x - r, y - r, x + r, y + r], fill=fill, outline="black", width=3)
|
||||
scale = 1024 / W
|
||||
small = im.resize((1024, int(H * scale)), Image.LANCZOS)
|
||||
out = OUT_DIR / f"{d['id']}.png"
|
||||
small.save(out)
|
||||
rendered.append((d["id"], d["name"], len(m.get("enemies", [])), len(m.get("packs", []))))
|
||||
print(f"rendered {len(rendered)} dungeons → {OUT_DIR}")
|
||||
for did, name, n_e, n_p in rendered:
|
||||
print(f" {did:32s} enemies={n_e:4d} packs={n_p:3d}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,263 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Build web/assets/dungeons.json from keystone.guru's mapping data.
|
||||
|
||||
Reads:
|
||||
data/kg_dungeons.json dungeon registry
|
||||
data/kg/_summary.json stitch grids per floor
|
||||
data/kg/<key>/split_floors.js enemies, packs, patrols, mapIcons
|
||||
data/kg/<key>/lang.js NPC names + classifications
|
||||
|
||||
Coordinate transform:
|
||||
Each kg map is rendered by Leaflet CRS.Simple with native zoom = max_zoom.
|
||||
pixel_x = lng * 2^zoom
|
||||
pixel_y = -lat * 2^zoom
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
KG_DIR = ROOT / "data" / "kg"
|
||||
REGISTRY = ROOT / "data" / "kg_dungeons.json"
|
||||
WEB_ASSETS = ROOT / "web" / "assets"
|
||||
WEB_MAPS = WEB_ASSETS / "maps"
|
||||
OUT_PATH = WEB_ASSETS / "dungeons.json"
|
||||
|
||||
# Map kg's mapIconType names → simple labels we render in the UI.
|
||||
ICON_TYPE_OVERRIDES = {
|
||||
"comment": "comment",
|
||||
"door": "door",
|
||||
"door_down": "door",
|
||||
"door_left": "door",
|
||||
"door_locked": "door",
|
||||
"door_right": "door",
|
||||
"door_up": "door",
|
||||
"graveyard": "graveyard",
|
||||
"dungeon_start": "start",
|
||||
"skip_walk": "skip",
|
||||
"skip_flight": "skip",
|
||||
"skip_teleport": "skip",
|
||||
"raid_marker_skull": "boss",
|
||||
}
|
||||
|
||||
|
||||
def parse_js_var(path: Path) -> dict:
|
||||
content = path.read_text()
|
||||
start = content.find("{")
|
||||
return json.loads(content[start:].rstrip(" ;\n"))
|
||||
|
||||
|
||||
def vertices_from(json_str: str) -> list[dict]:
|
||||
if not json_str:
|
||||
return []
|
||||
try:
|
||||
return json.loads(json_str)
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def build_one(d: dict, summary: dict, npc_index: dict | None,
|
||||
icon_index: dict[int, str]) -> dict:
|
||||
"""Return the dungeons.json entry for one kg dungeon."""
|
||||
tile_key = d["tile_key"]
|
||||
name = d["name"]
|
||||
floor_summary = next((x for x in summary["dungeons"] if x["tile_key"] == tile_key), None)
|
||||
if not floor_summary or not floor_summary["floors"]:
|
||||
return None
|
||||
|
||||
floors_per_dungeon = len(floor_summary["floors"])
|
||||
zoom = floor_summary.get("max_zoom", 4)
|
||||
scale = 2 ** zoom
|
||||
|
||||
data_path = KG_DIR / tile_key / "split_floors.js"
|
||||
has_data = data_path.exists()
|
||||
raw = parse_js_var(data_path) if has_data else None
|
||||
|
||||
# Group enemies / packs / patrols / mapIcons by floor_id, since one
|
||||
# dungeon may have multiple floors and each entry says which floor.
|
||||
by_floor: dict[int, dict] = {}
|
||||
if has_data:
|
||||
for e in raw["dungeon"].get("enemies", []):
|
||||
fid = e.get("floor_id")
|
||||
by_floor.setdefault(fid, {"enemies": [], "packs": [], "patrols": [], "icons": []})
|
||||
by_floor[fid]["enemies"].append(e)
|
||||
for p in raw["dungeon"].get("enemyPacks", []):
|
||||
fid = p.get("floor_id")
|
||||
by_floor.setdefault(fid, {"enemies": [], "packs": [], "patrols": [], "icons": []})
|
||||
by_floor[fid]["packs"].append(p)
|
||||
for pa in raw["dungeon"].get("enemyPatrols", []):
|
||||
fid = pa.get("floor_id")
|
||||
by_floor.setdefault(fid, {"enemies": [], "packs": [], "patrols": [], "icons": []})
|
||||
by_floor[fid]["patrols"].append(pa)
|
||||
for ic in raw["dungeon"].get("mapIcons", []):
|
||||
fid = ic.get("floor_id")
|
||||
by_floor.setdefault(fid, {"enemies": [], "packs": [], "patrols": [], "icons": []})
|
||||
by_floor[fid]["icons"].append(ic)
|
||||
|
||||
# We need to map kg's floor_id → the index we stitched. The split_floors
|
||||
# data doesn't include floor metadata directly, so use ordered alignment:
|
||||
# kg's floors come back in `index` order; our stitched floors_summary uses
|
||||
# the same ordering (we discovered them sequentially).
|
||||
# Build a mapping kg_floor_id → stitched index. Without a definitive
|
||||
# floor list in split_floors.js we lean on the ordering of floor_summary.
|
||||
kg_floor_ids = sorted({fid for fid in by_floor})
|
||||
floor_id_to_index = {fid: i + 1 for i, fid in enumerate(kg_floor_ids)} if kg_floor_ids else {}
|
||||
|
||||
maps_out = []
|
||||
for floor_info in floor_summary["floors"]:
|
||||
idx = floor_info["index"]
|
||||
cols, rows = floor_info["cols"], floor_info["rows"]
|
||||
suffix = f"_floor{idx}" if floors_per_dungeon > 1 else ""
|
||||
image_basename = f"{tile_key}{suffix}"
|
||||
|
||||
W = cols * 384 # tileWidth = 384
|
||||
H = rows * 256 # tileHeight = 256
|
||||
|
||||
# Find the kg floor_id for this index (best-effort)
|
||||
kg_floor_id = None
|
||||
for fid, ix in floor_id_to_index.items():
|
||||
if ix == idx:
|
||||
kg_floor_id = fid
|
||||
break
|
||||
f_data = by_floor.get(kg_floor_id, {"enemies": [], "packs": [], "patrols": [], "icons": []})
|
||||
|
||||
def to_pixel(lat, lng):
|
||||
return [lng * scale, -lat * scale]
|
||||
|
||||
# Enemies
|
||||
enemies_out = []
|
||||
for e in f_data["enemies"]:
|
||||
npc_id = e.get("npc_id")
|
||||
npc = npc_index.get(npc_id) if npc_index else None
|
||||
classification = npc.get("classification_id") if npc else None
|
||||
enemies_out.append({
|
||||
"id": e["id"],
|
||||
"npc_id": npc_id,
|
||||
"name": (npc or {}).get("name") or "Unknown",
|
||||
"pos": to_pixel(e["lat"], e["lng"]),
|
||||
"classification": classification, # 1=trash, 2=elite, 3=boss-ish, 4=boss?
|
||||
"skippable": bool(e.get("skippable")),
|
||||
"required": bool(e.get("required")),
|
||||
"kill_priority": e.get("kill_priority"),
|
||||
"pack_id": e.get("enemy_pack_id"),
|
||||
"patrol_id": e.get("enemy_patrol_id"),
|
||||
})
|
||||
|
||||
# Packs (polygon vertices)
|
||||
packs_out = []
|
||||
for p in f_data["packs"]:
|
||||
verts = vertices_from(p.get("vertices_json"))
|
||||
if not verts:
|
||||
continue
|
||||
packs_out.append({
|
||||
"id": p["id"],
|
||||
"color": p.get("color") or "#5993D2",
|
||||
"label": p.get("label"),
|
||||
"vertices": [to_pixel(v["lat"], v["lng"]) for v in verts],
|
||||
"enemy_ids": [en["id"] for en in p.get("enemies", []) if "id" in en],
|
||||
})
|
||||
|
||||
# Patrols (polylines)
|
||||
patrols_out = []
|
||||
for pa in f_data["patrols"]:
|
||||
line = pa.get("polyline", {})
|
||||
verts = vertices_from(line.get("vertices_json"))
|
||||
if not verts:
|
||||
continue
|
||||
patrols_out.append({
|
||||
"id": pa["id"],
|
||||
"color": line.get("color") or "#003280",
|
||||
"weight": line.get("weight") or 3,
|
||||
"vertices": [to_pixel(v["lat"], v["lng"]) for v in verts],
|
||||
})
|
||||
|
||||
# Map icons — bosses, doors, comments, etc.
|
||||
icons_out = []
|
||||
for ic in f_data["icons"]:
|
||||
icon_type = icon_index.get(ic.get("map_icon_type_id"), "unknown")
|
||||
icons_out.append({
|
||||
"id": ic["id"],
|
||||
"type": icon_type,
|
||||
"pos": to_pixel(ic["lat"], ic["lng"]),
|
||||
"comment": ic.get("comment"),
|
||||
})
|
||||
|
||||
maps_out.append({
|
||||
"image": f"maps/{image_basename}.webp",
|
||||
"width": W, "height": H,
|
||||
"label": image_basename,
|
||||
"kg_floor_id": kg_floor_id,
|
||||
"enemies": enemies_out,
|
||||
"packs": packs_out,
|
||||
"patrols": patrols_out,
|
||||
"icons": icons_out,
|
||||
})
|
||||
|
||||
return {
|
||||
"id": tile_key,
|
||||
"expansion": "OriginalWoW",
|
||||
"name": name,
|
||||
"acronym": d.get("acronym"),
|
||||
"tile_key": tile_key,
|
||||
"data_slug": d.get("data_slug"),
|
||||
"mapping_id": d.get("mapping_id"),
|
||||
"maps": maps_out,
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if not REGISTRY.exists() or not (KG_DIR / "_summary.json").exists():
|
||||
print("missing kg_dungeons.json or _summary.json", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
registry = json.loads(REGISTRY.read_text())
|
||||
summary = json.loads((KG_DIR / "_summary.json").read_text())
|
||||
|
||||
# Build a global icon-type index from the static.js if present, else
|
||||
# fall back to known IDs.
|
||||
icon_index: dict[int, str] = {
|
||||
1: "unknown", 2: "comment", 3: "door", 4: "door", 5: "door", 6: "door_locked",
|
||||
7: "door", 8: "door", 9: "dot_yellow", 10: "start", 11: "gateway",
|
||||
12: "graveyard", 26: "boss",
|
||||
}
|
||||
static_path = KG_DIR / "_static.js"
|
||||
if static_path.exists():
|
||||
try:
|
||||
static_data = parse_js_var(static_path)["static"]
|
||||
for it in static_data.get("mapIconTypes", []):
|
||||
key = ICON_TYPE_OVERRIDES.get(it.get("key"), it.get("key"))
|
||||
icon_index[it["id"]] = key or "unknown"
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
dungeons = []
|
||||
for d in registry["dungeons"]:
|
||||
npc_index = None
|
||||
lang_path = KG_DIR / d["tile_key"] / "lang.js"
|
||||
if lang_path.exists():
|
||||
try:
|
||||
lang = parse_js_var(lang_path)
|
||||
npc_index = {n["id"]: n for n in lang.get("dungeonNpcs", [])}
|
||||
except Exception:
|
||||
npc_index = {}
|
||||
|
||||
entry = build_one(d, summary, npc_index, icon_index)
|
||||
if entry:
|
||||
dungeons.append(entry)
|
||||
|
||||
dungeons.sort(key=lambda d: d["name"])
|
||||
OUT_PATH.write_text(json.dumps({"dungeons": dungeons}, indent=2))
|
||||
print(f"wrote {OUT_PATH} — {len(dungeons)} dungeons")
|
||||
for d in dungeons:
|
||||
n_enemies = sum(len(m["enemies"]) for m in d["maps"])
|
||||
n_packs = sum(len(m["packs"]) for m in d["maps"])
|
||||
n_icons = sum(len(m["icons"]) for m in d["maps"])
|
||||
print(f" {d['name']:30s} maps={len(d['maps'])} enemies={n_enemies} packs={n_packs} icons={n_icons}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,184 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Fetch keystone.guru tiles + enemy data for every classic dungeon listed in
|
||||
data/kg_dungeons.json.
|
||||
|
||||
Outputs:
|
||||
data/kg/<tile_key>/floor<n>/z4/<x>_<y>.png raw tiles
|
||||
data/kg/<tile_key>/<data_slug>_split_floors.js
|
||||
data/kg/<tile_key>/<data_slug>_lang.js
|
||||
|
||||
The compiled-data path includes a build hash; we discover it once from a
|
||||
known route page and use it for every fetch in this run.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import argparse
|
||||
import concurrent.futures
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
KG_OUT = ROOT / "data" / "kg"
|
||||
REGISTRY = ROOT / "data" / "kg_dungeons.json"
|
||||
|
||||
UA = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36"
|
||||
TILE_BASE = "https://assets.keystone.guru/tiles"
|
||||
DATA_BASE = "https://assets.keystone.guru/compiled"
|
||||
ROUTE_PROBE_URL = "https://aws.keystone.guru/route/razorfen-downs/2bhiRi8/ascension-m-rfd/1"
|
||||
|
||||
HASH_RE = re.compile(r"compiled/([0-9a-f]{40})/")
|
||||
|
||||
|
||||
def http_get(url: str, timeout: int = 15) -> bytes:
|
||||
req = urllib.request.Request(url, headers={"User-Agent": UA})
|
||||
with urllib.request.urlopen(req, timeout=timeout) as r:
|
||||
return r.read()
|
||||
|
||||
|
||||
def http_head_ok(url: str, timeout: int = 5) -> bool:
|
||||
req = urllib.request.Request(url, headers={"User-Agent": UA}, method="HEAD")
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=timeout) as r:
|
||||
return r.status == 200
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def discover_compiled_hash() -> str:
|
||||
html = http_get(ROUTE_PROBE_URL).decode("utf-8", errors="replace")
|
||||
m = HASH_RE.search(html)
|
||||
if not m:
|
||||
raise RuntimeError("could not find compiled-asset hash on route page")
|
||||
return m.group(1)
|
||||
|
||||
|
||||
def discover_floors(tile_key: str, expansion: str, max_zoom: int = 4) -> list[int]:
|
||||
"""Probe floor numbers 1..N until the first miss. Uses zoom=1 (the
|
||||
lowest-zoom layer kg ships) for the existence check."""
|
||||
floors = []
|
||||
for f in range(1, 20):
|
||||
if http_head_ok(f"{TILE_BASE}/{expansion}/{tile_key}/{f}/1/0_0.png"):
|
||||
floors.append(f)
|
||||
else:
|
||||
break
|
||||
return floors
|
||||
|
||||
|
||||
def discover_grid(tile_key: str, expansion: str, floor: int, z: int) -> tuple[int, int]:
|
||||
"""Find max x and max y at given zoom."""
|
||||
max_x = 0
|
||||
while http_head_ok(f"{TILE_BASE}/{expansion}/{tile_key}/{floor}/{z}/{max_x + 1}_0.png"):
|
||||
max_x += 1
|
||||
max_y = 0
|
||||
while http_head_ok(f"{TILE_BASE}/{expansion}/{tile_key}/{floor}/{z}/0_{max_y + 1}.png"):
|
||||
max_y += 1
|
||||
return max_x + 1, max_y + 1 # counts (cols, rows)
|
||||
|
||||
|
||||
def fetch_tile(args) -> tuple[Path, bool]:
|
||||
url, dest = args
|
||||
if dest.exists() and dest.stat().st_size > 0:
|
||||
return dest, True
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
data = http_get(url, timeout=30)
|
||||
if not data or data[:4] != b"\x89PNG":
|
||||
return dest, False
|
||||
dest.write_bytes(data)
|
||||
return dest, True
|
||||
except Exception:
|
||||
return dest, False
|
||||
|
||||
|
||||
def fetch_dungeon_tiles(d: dict, expansion: str, max_zoom: int, workers: int) -> dict:
|
||||
"""For one dungeon, discover floors + grid, parallel-download all tiles."""
|
||||
tile_key = d["tile_key"]
|
||||
name = d["name"]
|
||||
out_root = KG_OUT / tile_key
|
||||
out_root.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
floors = discover_floors(tile_key, expansion)
|
||||
info = {"tile_key": tile_key, "name": name, "expansion": expansion,
|
||||
"max_zoom": max_zoom, "floors": []}
|
||||
if not floors:
|
||||
print(f" WARN no floors for {tile_key}", file=sys.stderr)
|
||||
return info
|
||||
|
||||
jobs = []
|
||||
for f in floors:
|
||||
cols, rows = discover_grid(tile_key, expansion, f, max_zoom)
|
||||
info["floors"].append({"index": f, "cols": cols, "rows": rows})
|
||||
floor_dir = out_root / f"floor{f}" / f"z{max_zoom}"
|
||||
for x in range(cols):
|
||||
for y in range(rows):
|
||||
url = f"{TILE_BASE}/{expansion}/{tile_key}/{f}/{max_zoom}/{x}_{y}.png"
|
||||
dest = floor_dir / f"{x}_{y}.png"
|
||||
jobs.append((url, dest))
|
||||
|
||||
ok = 0
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as pool:
|
||||
for path, success in pool.map(fetch_tile, jobs):
|
||||
if success:
|
||||
ok += 1
|
||||
print(f" {tile_key}: {ok}/{len(jobs)} tiles, floors={[f['index'] for f in info['floors']]}, "
|
||||
f"grid=" + ", ".join(f"f{f['index']}:{f['cols']}x{f['rows']}" for f in info["floors"]))
|
||||
return info
|
||||
|
||||
|
||||
def fetch_dungeon_data(d: dict, compiled_hash: str) -> bool:
|
||||
"""Download split_floors.js + en_US.js for one dungeon."""
|
||||
if not d.get("data_slug") or not d.get("mapping_id"):
|
||||
return False
|
||||
slug = d["data_slug"]; mid = d["mapping_id"]; tile_key = d["tile_key"]
|
||||
out_root = KG_OUT / tile_key
|
||||
out_root.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
splits_url = f"{DATA_BASE}/{compiled_hash}/mapcontext/data/{slug}/{mid}/split_floors.js"
|
||||
lang_url = f"{DATA_BASE}/{compiled_hash}/mapcontext/data/{slug}/en_US.js"
|
||||
try:
|
||||
(out_root / "split_floors.js").write_bytes(http_get(splits_url))
|
||||
(out_root / "lang.js").write_bytes(http_get(lang_url))
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f" data fetch failed for {tile_key}: {e}", file=sys.stderr)
|
||||
return False
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--workers", type=int, default=24)
|
||||
ap.add_argument("--zoom", type=int, default=4)
|
||||
ap.add_argument("--dungeon", help="only fetch this tile_key")
|
||||
args = ap.parse_args()
|
||||
|
||||
registry = json.loads(REGISTRY.read_text())
|
||||
expansion = registry.get("_expansion", "classic")
|
||||
|
||||
compiled_hash = discover_compiled_hash()
|
||||
print(f"compiled hash: {compiled_hash}")
|
||||
|
||||
targets = registry["dungeons"]
|
||||
if args.dungeon:
|
||||
targets = [d for d in targets if d["tile_key"] == args.dungeon]
|
||||
if not targets:
|
||||
print(f"no dungeon with tile_key={args.dungeon}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
summary = {"compiled_hash": compiled_hash, "dungeons": []}
|
||||
for d in targets:
|
||||
print(f"==> {d['name']} ({d['tile_key']})")
|
||||
info = fetch_dungeon_tiles(d, expansion, args.zoom, args.workers)
|
||||
info["data_fetched"] = fetch_dungeon_data(d, compiled_hash)
|
||||
summary["dungeons"].append(info)
|
||||
|
||||
(KG_OUT / "_summary.json").write_text(json.dumps(summary, indent=2))
|
||||
print(f"\nwrote {KG_OUT}/_summary.json — {len(summary['dungeons'])} dungeons")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,70 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Rebuild data/kg/_summary.json by inspecting which tiles are on disk.
|
||||
|
||||
Each dungeon's floor is assumed to be 16x16 tiles at z=4 (that's the
|
||||
convention kg uses for classic dungeons we sampled). If any tile is missing,
|
||||
report it but keep the floor in the summary so stitching can paste a
|
||||
black-fill placeholder.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
KG_DIR = ROOT / "data" / "kg"
|
||||
REGISTRY = ROOT / "data" / "kg_dungeons.json"
|
||||
ZOOM = 4
|
||||
COLS = ROWS = 16
|
||||
|
||||
|
||||
def main() -> int:
|
||||
registry = json.loads(REGISTRY.read_text())
|
||||
dungeons = []
|
||||
total_missing = 0
|
||||
for d in registry["dungeons"]:
|
||||
tile_key = d["tile_key"]
|
||||
root = KG_DIR / tile_key
|
||||
if not root.exists():
|
||||
continue
|
||||
floor_dirs = sorted([p for p in root.iterdir() if p.is_dir() and p.name.startswith("floor")],
|
||||
key=lambda p: int(re.search(r"\d+", p.name).group()))
|
||||
floors = []
|
||||
for fd in floor_dirs:
|
||||
zd = fd / f"z{ZOOM}"
|
||||
if not zd.exists():
|
||||
continue
|
||||
present = {p.name for p in zd.glob("*.png")}
|
||||
missing = []
|
||||
for x in range(COLS):
|
||||
for y in range(ROWS):
|
||||
if f"{x}_{y}.png" not in present:
|
||||
missing.append((x, y))
|
||||
if not present:
|
||||
continue
|
||||
idx = int(re.search(r"\d+", fd.name).group())
|
||||
floors.append({
|
||||
"index": idx, "cols": COLS, "rows": ROWS,
|
||||
"missing_tiles": len(missing),
|
||||
})
|
||||
total_missing += len(missing)
|
||||
dungeons.append({
|
||||
"tile_key": tile_key,
|
||||
"name": d["name"],
|
||||
"expansion": "classic",
|
||||
"max_zoom": ZOOM,
|
||||
"floors": floors,
|
||||
"data_fetched": (root / "split_floors.js").exists(),
|
||||
})
|
||||
summary = {"compiled_hash": "(resynced from disk)", "dungeons": dungeons}
|
||||
(KG_DIR / "_summary.json").write_text(json.dumps(summary, indent=2))
|
||||
print(f"wrote summary: {len(dungeons)} dungeons, {total_missing} missing tiles total")
|
||||
for d in dungeons:
|
||||
f_str = ", ".join(f"f{f['index']}({f['missing_tiles']}miss)" if f['missing_tiles'] else f"f{f['index']}" for f in d['floors'])
|
||||
flag = "" if d["data_fetched"] else " [no data]"
|
||||
print(f" {d['name']:30s} {f_str}{flag}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,79 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Stitch keystone.guru tile pyramid (z=4 by default) into one WebP per
|
||||
dungeon-floor. Reads `data/kg/_summary.json` (produced by kg_fetch.py) for
|
||||
the grid bounds.
|
||||
|
||||
Output: web/assets/maps/<tile_key>[_floor<n>].webp
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import argparse
|
||||
import concurrent.futures
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from PIL import Image
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
KG_DIR = ROOT / "data" / "kg"
|
||||
WEB_MAPS = ROOT / "web" / "assets" / "maps"
|
||||
|
||||
TILE_W, TILE_H = 384, 256
|
||||
|
||||
|
||||
def stitch_one(args):
|
||||
tile_key, floor_info, zoom, all_floors_count, quality = args
|
||||
floor = floor_info["index"]
|
||||
cols, rows = floor_info["cols"], floor_info["rows"]
|
||||
src_dir = KG_DIR / tile_key / f"floor{floor}" / f"z{zoom}"
|
||||
|
||||
canvas = Image.new("RGBA", (cols * TILE_W, rows * TILE_H), (0, 0, 0, 255))
|
||||
missing = 0
|
||||
for x in range(cols):
|
||||
for y in range(rows):
|
||||
tile_path = src_dir / f"{x}_{y}.png"
|
||||
if not tile_path.exists():
|
||||
missing += 1
|
||||
continue
|
||||
with Image.open(tile_path) as t:
|
||||
canvas.paste(t.convert("RGBA"), (x * TILE_W, y * TILE_H))
|
||||
|
||||
suffix = f"_floor{floor}" if all_floors_count > 1 else ""
|
||||
out = WEB_MAPS / f"{tile_key}{suffix}.webp"
|
||||
out.parent.mkdir(parents=True, exist_ok=True)
|
||||
canvas.convert("RGB").save(out, "WEBP", quality=quality, method=4)
|
||||
return tile_key, floor, canvas.size, missing
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--quality", type=int, default=85)
|
||||
ap.add_argument("--workers", type=int, default=4)
|
||||
args = ap.parse_args()
|
||||
|
||||
summary_path = KG_DIR / "_summary.json"
|
||||
if not summary_path.exists():
|
||||
print(f"missing {summary_path} — run kg_fetch.py first", file=sys.stderr)
|
||||
return 1
|
||||
summary = json.loads(summary_path.read_text())
|
||||
|
||||
jobs = []
|
||||
for d in summary["dungeons"]:
|
||||
if not d.get("floors"):
|
||||
continue
|
||||
for floor_info in d["floors"]:
|
||||
jobs.append((d["tile_key"], floor_info, d["max_zoom"],
|
||||
len(d["floors"]), args.quality))
|
||||
|
||||
print(f"stitching {len(jobs)} dungeon-floors with {args.workers} workers...")
|
||||
with concurrent.futures.ProcessPoolExecutor(max_workers=args.workers) as pool:
|
||||
for tile_key, floor, size, missing in pool.map(stitch_one, jobs):
|
||||
note = f" ({missing} missing tiles)" if missing else ""
|
||||
print(f" {tile_key} f{floor}: {size[0]}×{size[1]}{note}")
|
||||
|
||||
print(f"done → {WEB_MAPS}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -1,145 +0,0 @@
|
||||
#!/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())
|
||||
+279
-266
@@ -1,27 +1,29 @@
|
||||
// Ascension M+ Route Planner — vanilla JS, single-file SPA.
|
||||
// Renders keystone.guru-derived data (enemies, packs, patrols, icons) on
|
||||
// stitched z=4 dungeon tiles. All overlays live in image-pixel space; the
|
||||
// canvas-stage CSS transform handles pan/zoom uniformly.
|
||||
|
||||
const SVG_NS = "http://www.w3.org/2000/svg";
|
||||
|
||||
const state = {
|
||||
dungeons: [],
|
||||
current: null, // selected dungeon
|
||||
floorIndex: 0, // index into dungeon.maps
|
||||
current: null,
|
||||
floorIndex: 0,
|
||||
tool: "route", // "route" | "pull"
|
||||
// routes/pulls keyed by `${dungeonId}::${floorIndex}`
|
||||
routes: {}, // { key: [{x,y}] }
|
||||
pulls: {}, // { key: [{x,y,label?}] }
|
||||
routes: {}, // { key: [{x,y}] } image-pixel coords
|
||||
pulls: {}, // { key: [{x,y}] }
|
||||
history: [],
|
||||
drag: null, // { kind, key, idx }
|
||||
view: { scale: 1, tx: 0, ty: 0 }, // canvas pan/zoom
|
||||
pan: null, // active pan drag {x0,y0,tx0,ty0}
|
||||
drag: null,
|
||||
view: { scale: 1, tx: 0, ty: 0 },
|
||||
pan: null,
|
||||
show: { enemies: true, packs: true, patrols: true, icons: true },
|
||||
};
|
||||
|
||||
const $ = (id) => document.getElementById(id);
|
||||
|
||||
async function init() {
|
||||
const r = await fetch("assets/dungeons.json", { cache: "no-cache" });
|
||||
const data = await r.json();
|
||||
state.dungeons = data.dungeons;
|
||||
state.dungeons = (await r.json()).dungeons;
|
||||
|
||||
populateExpansionFilter();
|
||||
renderDungeonList();
|
||||
@@ -35,70 +37,34 @@ async function init() {
|
||||
|
||||
function populateExpansionFilter() {
|
||||
const sel = $("expansion-filter");
|
||||
const exps = [...new Set(state.dungeons.map((d) => d.expansion))].sort();
|
||||
for (const e of exps) {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = e;
|
||||
opt.textContent = labelExpansion(e);
|
||||
sel.appendChild(opt);
|
||||
}
|
||||
}
|
||||
|
||||
function labelExpansion(e) {
|
||||
return ({
|
||||
OriginalWoW: "Classic",
|
||||
BurningCrusade: "Burning Crusade",
|
||||
WrathoftheLichKing: "Wrath of the Lich King",
|
||||
Other: "Other / Unmapped",
|
||||
})[e] || e;
|
||||
sel.innerHTML = '<option value="">All</option>';
|
||||
// Classic-only deployment — keep the dropdown as a no-op for now.
|
||||
}
|
||||
|
||||
function renderDungeonList() {
|
||||
const ul = $("dungeon-list");
|
||||
const q = $("search").value.trim().toLowerCase();
|
||||
const expFilter = $("expansion-filter").value;
|
||||
ul.innerHTML = "";
|
||||
|
||||
const filtered = state.dungeons.filter((d) => {
|
||||
if (expFilter && d.expansion !== expFilter) return false;
|
||||
if (!q) return true;
|
||||
return (
|
||||
d.name.toLowerCase().includes(q) ||
|
||||
(d.acronym || "").toLowerCase().includes(q) ||
|
||||
d.id.toLowerCase().includes(q)
|
||||
);
|
||||
});
|
||||
|
||||
let lastExp = null;
|
||||
for (const d of filtered) {
|
||||
if (d.expansion !== lastExp) {
|
||||
const h = document.createElement("li");
|
||||
h.className = "group-header";
|
||||
h.textContent = labelExpansion(d.expansion);
|
||||
ul.appendChild(h);
|
||||
lastExp = d.expansion;
|
||||
}
|
||||
for (const d of state.dungeons.filter((d) =>
|
||||
!q || d.name.toLowerCase().includes(q) || (d.acronym || "").toLowerCase().includes(q)
|
||||
)) {
|
||||
const li = document.createElement("li");
|
||||
li.dataset.id = d.id;
|
||||
li.dataset.expansion = d.expansion;
|
||||
if (state.current && d.id === state.current.id && d.expansion === state.current.expansion) {
|
||||
li.classList.add("active");
|
||||
}
|
||||
if (state.current && d.id === state.current.id) li.classList.add("active");
|
||||
const name = document.createElement("span");
|
||||
name.textContent = d.name;
|
||||
const acr = document.createElement("span");
|
||||
acr.className = "acronym";
|
||||
acr.textContent = d.acronym || "";
|
||||
li.append(name, acr);
|
||||
li.onclick = () => selectDungeon(d.id, d.expansion);
|
||||
li.onclick = () => selectDungeon(d.id);
|
||||
ul.appendChild(li);
|
||||
}
|
||||
}
|
||||
|
||||
function selectDungeon(id, expansion) {
|
||||
const d = state.dungeons.find(
|
||||
(x) => x.id === id && (!expansion || x.expansion === expansion)
|
||||
);
|
||||
function selectDungeon(id) {
|
||||
const d = state.dungeons.find((x) => x.id === id);
|
||||
if (!d) return;
|
||||
state.current = d;
|
||||
state.floorIndex = 0;
|
||||
@@ -108,9 +74,8 @@ function selectDungeon(id, expansion) {
|
||||
}
|
||||
|
||||
function currentKey() {
|
||||
return state.current ? `${state.current.expansion}::${state.current.id}::${state.floorIndex}` : null;
|
||||
return state.current ? `${state.current.id}::${state.floorIndex}` : null;
|
||||
}
|
||||
|
||||
function currentMap() {
|
||||
return state.current?.maps[state.floorIndex] || null;
|
||||
}
|
||||
@@ -119,26 +84,26 @@ function renderViewer() {
|
||||
const d = state.current;
|
||||
if (!d) return;
|
||||
$("dungeon-name").textContent = d.name;
|
||||
const meta = [d.acronym, d.levelRange && `Lv ${d.levelRange}`, d.location, d.playerLimit && `${d.playerLimit} players`]
|
||||
.filter(Boolean)
|
||||
.join(" · ");
|
||||
const m = currentMap();
|
||||
const meta = [
|
||||
d.acronym, d.levelRange && `Lv ${d.levelRange}`,
|
||||
m && `${m.enemies?.length ?? 0} enemies`,
|
||||
m && `${m.packs?.length ?? 0} packs`,
|
||||
].filter(Boolean).join(" · ");
|
||||
$("dungeon-meta").textContent = meta;
|
||||
|
||||
// Floor tabs
|
||||
const tabs = $("floor-tabs");
|
||||
tabs.innerHTML = "";
|
||||
if (d.maps.length > 1) {
|
||||
d.maps.forEach((m, i) => {
|
||||
d.maps.forEach((mp, i) => {
|
||||
const b = document.createElement("button");
|
||||
b.textContent = humanizeLabel(m.label);
|
||||
b.textContent = humanizeLabel(mp.label);
|
||||
if (i === state.floorIndex) b.classList.add("active");
|
||||
b.onclick = () => { state.floorIndex = i; renderViewer(); updateHash(); };
|
||||
tabs.appendChild(b);
|
||||
});
|
||||
}
|
||||
|
||||
// Map image
|
||||
const m = currentMap();
|
||||
const img = $("map-img");
|
||||
if (m) {
|
||||
img.src = "assets/" + m.image;
|
||||
@@ -148,7 +113,7 @@ function renderViewer() {
|
||||
svg.setAttribute("width", m.width);
|
||||
svg.setAttribute("height", m.height);
|
||||
img.onload = () => { fitToScreen(); renderOverlay(); };
|
||||
if (img.complete) { fitToScreen(); }
|
||||
if (img.complete) fitToScreen();
|
||||
} else {
|
||||
img.removeAttribute("src");
|
||||
}
|
||||
@@ -159,87 +124,167 @@ function renderViewer() {
|
||||
|
||||
function humanizeLabel(s) {
|
||||
return s
|
||||
.replace(/^CFR/, "")
|
||||
.replace(/^HC/, "")
|
||||
.replace(/^CoT/, "CoT ")
|
||||
.replace(/^TempestKeep/, "TK ")
|
||||
.replace(/^SM/, "SM ")
|
||||
.replace(/^Auch/, "Auch ")
|
||||
.replace(/^FH/, "")
|
||||
.replace(/^Ulduar([A-Z])/, "Ulduar $1")
|
||||
.replace(/^IcecrownCitadel([A-Z])/, "ICC $1")
|
||||
.replace(/([a-z])([A-Z])/g, "$1 $2")
|
||||
.trim();
|
||||
.replace(/_floor(\d+)$/, " · floor $1")
|
||||
.replace(/_/g, " ")
|
||||
.replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
}
|
||||
|
||||
/* --- overlay rendering --------------------------------------------------- */
|
||||
|
||||
function renderOverlay() {
|
||||
const svg = $("overlay");
|
||||
svg.innerHTML = "";
|
||||
const m = currentMap();
|
||||
if (!m) return;
|
||||
// SVG works in image-pixel space, so circles stay circular regardless of
|
||||
// the map's aspect ratio. Coords on disk are 0-100 percent (AtlasLoot
|
||||
// convention) and converted at render/click time.
|
||||
svg.setAttribute("viewBox", `0 0 ${m.width} ${m.height}`);
|
||||
svg.setAttribute("preserveAspectRatio", "none");
|
||||
|
||||
const key = currentKey();
|
||||
const wps = state.routes[key] || [];
|
||||
const pulls = state.pulls[key] || [];
|
||||
const px = (p) => ({ x: (p.x / 100) * m.width, y: (p.y / 100) * m.height });
|
||||
|
||||
// route line first (under pins)
|
||||
// Pack polygons (under enemies)
|
||||
if (state.show.packs && m.packs) {
|
||||
for (const p of m.packs) {
|
||||
if (p.vertices.length < 3) continue;
|
||||
const poly = document.createElementNS(SVG_NS, "polygon");
|
||||
poly.setAttribute("class", "pack");
|
||||
poly.setAttribute("points", p.vertices.map(([x, y]) => `${x},${y}`).join(" "));
|
||||
poly.setAttribute("fill", p.color);
|
||||
poly.setAttribute("fill-opacity", "0.18");
|
||||
poly.setAttribute("stroke", p.color);
|
||||
poly.setAttribute("stroke-width", "4");
|
||||
poly.setAttribute("stroke-opacity", "0.65");
|
||||
svg.appendChild(poly);
|
||||
}
|
||||
}
|
||||
|
||||
// Patrol polylines
|
||||
if (state.show.patrols && m.patrols) {
|
||||
for (const pa of m.patrols) {
|
||||
if (pa.vertices.length < 2) continue;
|
||||
const line = document.createElementNS(SVG_NS, "polyline");
|
||||
line.setAttribute("class", "patrol");
|
||||
line.setAttribute("points", pa.vertices.map(([x, y]) => `${x},${y}`).join(" "));
|
||||
line.setAttribute("fill", "none");
|
||||
line.setAttribute("stroke", pa.color);
|
||||
line.setAttribute("stroke-width", String((pa.weight || 3) * 4));
|
||||
line.setAttribute("stroke-dasharray", "12 8");
|
||||
line.setAttribute("opacity", "0.7");
|
||||
svg.appendChild(line);
|
||||
}
|
||||
}
|
||||
|
||||
// Enemies (smallest pins first; bosses on top)
|
||||
if (state.show.enemies && m.enemies) {
|
||||
const ranked = [...m.enemies].sort(
|
||||
(a, b) => (a.classification || 0) - (b.classification || 0)
|
||||
);
|
||||
for (const e of ranked) {
|
||||
svg.appendChild(makeEnemyPin(e));
|
||||
}
|
||||
}
|
||||
|
||||
// Map icons (skull, comment, door, etc.)
|
||||
if (state.show.icons && m.icons) {
|
||||
for (const ic of m.icons) {
|
||||
svg.appendChild(makeIconMarker(ic));
|
||||
}
|
||||
}
|
||||
|
||||
// Route polyline
|
||||
if (wps.length > 1) {
|
||||
const path = document.createElementNS(SVG_NS, "polyline");
|
||||
path.setAttribute("class", "route-line");
|
||||
path.setAttribute(
|
||||
"points",
|
||||
wps.map((p) => { const q = px(p); return `${q.x},${q.y}`; }).join(" ")
|
||||
);
|
||||
path.setAttribute("points", wps.map((p) => `${p.x},${p.y}`).join(" "));
|
||||
svg.appendChild(path);
|
||||
}
|
||||
|
||||
// boss pins
|
||||
for (const b of m.bosses || []) {
|
||||
const q = px(b);
|
||||
const g = document.createElementNS(SVG_NS, "g");
|
||||
g.setAttribute("class", "boss-pin");
|
||||
g.setAttribute("transform", `translate(${q.x},${q.y})`);
|
||||
const c = document.createElementNS(SVG_NS, "circle");
|
||||
c.setAttribute("r", 32);
|
||||
g.appendChild(c);
|
||||
const t = document.createElementNS(SVG_NS, "title");
|
||||
t.textContent = b.name;
|
||||
g.appendChild(t);
|
||||
svg.appendChild(g);
|
||||
}
|
||||
|
||||
// pull markers
|
||||
pulls.forEach((p, i) => {
|
||||
const q = px(p);
|
||||
svg.appendChild(makePin(q.x, q.y, i + 1, "pull", "pull", i));
|
||||
});
|
||||
|
||||
// user waypoints (on top)
|
||||
wps.forEach((p, i) => {
|
||||
const q = px(p);
|
||||
svg.appendChild(makePin(q.x, q.y, i + 1, "waypoint", "route", i));
|
||||
});
|
||||
// Pull markers + waypoints (above everything)
|
||||
pulls.forEach((p, i) => svg.appendChild(makeUserPin(p.x, p.y, i + 1, "pull", "pull", i)));
|
||||
wps.forEach((p, i) => svg.appendChild(makeUserPin(p.x, p.y, i + 1, "waypoint", "route", i)));
|
||||
|
||||
svg.onclick = onCanvasClick;
|
||||
}
|
||||
|
||||
function makePin(x, y, label, cssClass, kind, idx) {
|
||||
// x/y here are SVG pixel coords (already converted from percent).
|
||||
const CLASS_RADIUS = { 1: 14, 2: 18, 3: 28, 4: 38 };
|
||||
const CLASS_FILL = { 1: "#9aa1aa", 2: "#d6d6dc", 3: "#d63b3b", 4: "#ffd83a" };
|
||||
|
||||
function makeEnemyPin(e) {
|
||||
const r = CLASS_RADIUS[e.classification || 1] ?? 14;
|
||||
const fill = CLASS_FILL[e.classification || 1] ?? "#9aa1aa";
|
||||
const g = document.createElementNS(SVG_NS, "g");
|
||||
g.setAttribute("class", `enemy enemy-c${e.classification || 1}`);
|
||||
g.setAttribute("transform", `translate(${e.pos[0]},${e.pos[1]})`);
|
||||
const c = document.createElementNS(SVG_NS, "circle");
|
||||
c.setAttribute("r", r);
|
||||
c.setAttribute("fill", fill);
|
||||
c.setAttribute("stroke", "#000");
|
||||
c.setAttribute("stroke-width", "3");
|
||||
g.appendChild(c);
|
||||
if ((e.classification || 1) >= 3) {
|
||||
// Boss — overlay a skull glyph
|
||||
const t = document.createElementNS(SVG_NS, "text");
|
||||
t.setAttribute("y", r * 0.35);
|
||||
t.setAttribute("font-size", String(Math.round(r * 1.6)));
|
||||
t.setAttribute("text-anchor", "middle");
|
||||
t.setAttribute("fill", "#1a0000");
|
||||
t.setAttribute("font-weight", "900");
|
||||
t.textContent = "☠";
|
||||
g.appendChild(t);
|
||||
}
|
||||
const title = document.createElementNS(SVG_NS, "title");
|
||||
title.textContent = e.name + (e.skippable ? " (skippable)" : "");
|
||||
g.appendChild(title);
|
||||
return g;
|
||||
}
|
||||
|
||||
function makeIconMarker(ic) {
|
||||
const g = document.createElementNS(SVG_NS, "g");
|
||||
g.setAttribute("class", `icon icon-${ic.type}`);
|
||||
g.setAttribute("transform", `translate(${ic.pos[0]},${ic.pos[1]})`);
|
||||
if (ic.type === "comment") {
|
||||
const c = document.createElementNS(SVG_NS, "circle");
|
||||
c.setAttribute("r", 14);
|
||||
c.setAttribute("fill", "#5993D2");
|
||||
c.setAttribute("stroke", "#000");
|
||||
c.setAttribute("stroke-width", "2");
|
||||
g.appendChild(c);
|
||||
const t = document.createElementNS(SVG_NS, "text");
|
||||
t.setAttribute("y", 6);
|
||||
t.setAttribute("font-size", "20");
|
||||
t.setAttribute("text-anchor", "middle");
|
||||
t.setAttribute("fill", "#fff");
|
||||
t.setAttribute("font-weight", "700");
|
||||
t.textContent = "i";
|
||||
g.appendChild(t);
|
||||
} else if (ic.type === "door") {
|
||||
const r = document.createElementNS(SVG_NS, "rect");
|
||||
r.setAttribute("x", -10); r.setAttribute("y", -14);
|
||||
r.setAttribute("width", 20); r.setAttribute("height", 28);
|
||||
r.setAttribute("fill", "#B58A3F");
|
||||
r.setAttribute("stroke", "#000");
|
||||
r.setAttribute("stroke-width", "2");
|
||||
g.appendChild(r);
|
||||
}
|
||||
if (ic.comment) {
|
||||
const title = document.createElementNS(SVG_NS, "title");
|
||||
title.textContent = ic.comment;
|
||||
g.appendChild(title);
|
||||
}
|
||||
return g;
|
||||
}
|
||||
|
||||
function makeUserPin(x, y, label, cssClass, kind, idx) {
|
||||
const g = document.createElementNS(SVG_NS, "g");
|
||||
g.setAttribute("class", cssClass);
|
||||
g.setAttribute("transform", `translate(${x},${y})`);
|
||||
const c = document.createElementNS(SVG_NS, "circle");
|
||||
c.setAttribute("r", 36);
|
||||
c.setAttribute("r", 26);
|
||||
g.appendChild(c);
|
||||
const t = document.createElementNS(SVG_NS, "text");
|
||||
t.setAttribute("y", 14);
|
||||
t.setAttribute("font-size", 36);
|
||||
t.setAttribute("y", 9);
|
||||
t.setAttribute("font-size", "26");
|
||||
t.textContent = label;
|
||||
g.appendChild(t);
|
||||
|
||||
@@ -251,21 +296,16 @@ function makePin(x, y, label, cssClass, kind, idx) {
|
||||
});
|
||||
g.addEventListener("pointermove", (e) => {
|
||||
if (!state.drag || state.drag.idx !== idx || state.drag.kind !== kind) return;
|
||||
const m = currentMap();
|
||||
const pt = svgPointFromEvent(e); // percent (0-100)
|
||||
const pt = svgPointFromEvent(e);
|
||||
const arr = kind === "route" ? state.routes[currentKey()] : state.pulls[currentKey()];
|
||||
arr[idx] = { ...arr[idx], x: pt.x, y: pt.y };
|
||||
const px = (pt.x / 100) * m.width;
|
||||
const py = (pt.y / 100) * m.height;
|
||||
g.setAttribute("transform", `translate(${px},${py})`);
|
||||
arr[idx] = { x: pt.x, y: pt.y };
|
||||
g.setAttribute("transform", `translate(${pt.x},${pt.y})`);
|
||||
if (kind === "route") {
|
||||
const polyline = document.querySelector("#overlay .route-line");
|
||||
if (polyline) {
|
||||
polyline.setAttribute(
|
||||
"points",
|
||||
state.routes[currentKey()]
|
||||
.map((p) => `${(p.x / 100) * m.width},${(p.y / 100) * m.height}`)
|
||||
.join(" ")
|
||||
state.routes[currentKey()].map((p) => `${p.x},${p.y}`).join(" ")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -291,10 +331,10 @@ function onCanvasClick(e) {
|
||||
const key = currentKey();
|
||||
if (state.tool === "route") {
|
||||
state.routes[key] = state.routes[key] || [];
|
||||
state.routes[key].push({ x: pt.x, y: pt.y });
|
||||
} else if (state.tool === "pull") {
|
||||
state.routes[key].push(pt);
|
||||
} else {
|
||||
state.pulls[key] = state.pulls[key] || [];
|
||||
state.pulls[key].push({ x: pt.x, y: pt.y });
|
||||
state.pulls[key].push(pt);
|
||||
}
|
||||
pushHistory();
|
||||
renderOverlay();
|
||||
@@ -303,110 +343,14 @@ function onCanvasClick(e) {
|
||||
}
|
||||
|
||||
function svgPointFromEvent(e) {
|
||||
// Convert client coords → 0-100% of map (same coord space as AtlasLoot)
|
||||
// Convert client coords → image-pixel coords (the SVG's own coord system,
|
||||
// which matches the underlying webp's natural dimensions).
|
||||
const m = currentMap();
|
||||
const svg = $("overlay");
|
||||
const rect = svg.getBoundingClientRect();
|
||||
const x = ((e.clientX - rect.left) / rect.width) * 100;
|
||||
const y = ((e.clientY - rect.top) / rect.height) * 100;
|
||||
return { x: clamp(x), y: clamp(y) };
|
||||
}
|
||||
function clamp(v) { return Math.max(0, Math.min(100, +v.toFixed(2))); }
|
||||
|
||||
/* --- pan / zoom ---------------------------------------------------------- */
|
||||
|
||||
function applyView() {
|
||||
const stage = $("canvas-stage");
|
||||
stage.style.transform =
|
||||
`translate(${state.view.tx}px, ${state.view.ty}px) scale(${state.view.scale})`;
|
||||
}
|
||||
|
||||
function fitToScreen() {
|
||||
const m = currentMap();
|
||||
const host = $("canvas-host");
|
||||
if (!m || !host) return;
|
||||
const pad = 24;
|
||||
const sx = (host.clientWidth - pad * 2) / m.width;
|
||||
const sy = (host.clientHeight - pad * 2) / m.height;
|
||||
const s = Math.min(sx, sy);
|
||||
// Center on host: stage origin is at host center (50/50 in CSS), so
|
||||
// shift by -half-image-size * scale to center the image visually.
|
||||
state.view.scale = s;
|
||||
state.view.tx = -(m.width * s) / 2;
|
||||
state.view.ty = -(m.height * s) / 2;
|
||||
applyView();
|
||||
}
|
||||
|
||||
function zoomBy(factor, anchorX, anchorY) {
|
||||
const host = $("canvas-host");
|
||||
const rect = host.getBoundingClientRect();
|
||||
// Anchor defaults to host center
|
||||
const ax = anchorX ?? rect.width / 2;
|
||||
const ay = anchorY ?? rect.height / 2;
|
||||
const cx = rect.width / 2 + state.view.tx;
|
||||
const cy = rect.height / 2 + state.view.ty;
|
||||
const dx = ax - cx;
|
||||
const dy = ay - cy;
|
||||
const before = state.view.scale;
|
||||
const after = Math.max(0.05, Math.min(20, before * factor));
|
||||
const ratio = after / before;
|
||||
state.view.scale = after;
|
||||
state.view.tx -= dx * (ratio - 1);
|
||||
state.view.ty -= dy * (ratio - 1);
|
||||
applyView();
|
||||
}
|
||||
|
||||
function hookCanvasPanZoom() {
|
||||
const host = $("canvas-host");
|
||||
|
||||
host.addEventListener("wheel", (e) => {
|
||||
e.preventDefault();
|
||||
const rect = host.getBoundingClientRect();
|
||||
const ax = e.clientX - rect.left;
|
||||
const ay = e.clientY - rect.top;
|
||||
const factor = e.deltaY < 0 ? 1.15 : 1 / 1.15;
|
||||
zoomBy(factor, ax, ay);
|
||||
}, { passive: false });
|
||||
|
||||
host.addEventListener("pointerdown", (e) => {
|
||||
// Skip if click is on a pin (those handle their own pointerdown).
|
||||
if (e.target.closest("g.waypoint, g.pull, g.boss-pin")) return;
|
||||
// Skip on SVG clicks (those are handled by onCanvasClick to drop pins).
|
||||
// Use middle mouse, shift-drag, or right-drag for pan; plain left-click on
|
||||
// empty area also pans if it falls outside the SVG.
|
||||
const onSvg = e.target.closest("#overlay");
|
||||
const isPanIntent = e.button === 1 || e.shiftKey || e.button === 2 || !onSvg;
|
||||
if (!isPanIntent) return;
|
||||
e.preventDefault();
|
||||
state.pan = {
|
||||
x0: e.clientX, y0: e.clientY,
|
||||
tx0: state.view.tx, ty0: state.view.ty,
|
||||
};
|
||||
host.classList.add("panning");
|
||||
host.setPointerCapture(e.pointerId);
|
||||
});
|
||||
host.addEventListener("pointermove", (e) => {
|
||||
if (!state.pan) return;
|
||||
state.view.tx = state.pan.tx0 + (e.clientX - state.pan.x0);
|
||||
state.view.ty = state.pan.ty0 + (e.clientY - state.pan.y0);
|
||||
applyView();
|
||||
});
|
||||
host.addEventListener("pointerup", () => {
|
||||
state.pan = null;
|
||||
host.classList.remove("panning");
|
||||
});
|
||||
host.addEventListener("contextmenu", (e) => {
|
||||
// suppress the menu when shift-right-clicking to allow pan
|
||||
if (state.pan) e.preventDefault();
|
||||
});
|
||||
|
||||
// Buttons
|
||||
$("zoom-in").addEventListener("click", () => zoomBy(1.25));
|
||||
$("zoom-out").addEventListener("click", () => zoomBy(1 / 1.25));
|
||||
$("zoom-reset").addEventListener("click", fitToScreen);
|
||||
window.addEventListener("resize", () => {
|
||||
// Refit on first resize, but keep user zoom afterwards.
|
||||
if (!state._userZoomed) fitToScreen();
|
||||
});
|
||||
const x = ((e.clientX - rect.left) / rect.width) * m.width;
|
||||
const y = ((e.clientY - rect.top) / rect.height) * m.height;
|
||||
return { x: +x.toFixed(1), y: +y.toFixed(1) };
|
||||
}
|
||||
|
||||
function removePoint(kind, idx) {
|
||||
@@ -420,17 +364,21 @@ function removePoint(kind, idx) {
|
||||
updateHash();
|
||||
}
|
||||
|
||||
/* --- info pane ----------------------------------------------------------- */
|
||||
|
||||
function renderInfoPane() {
|
||||
const m = currentMap();
|
||||
const bossUl = $("boss-list");
|
||||
bossUl.innerHTML = "";
|
||||
if (m) {
|
||||
for (const b of m.bosses || []) {
|
||||
const bosses = (m.enemies || []).filter((e) => (e.classification || 0) >= 3);
|
||||
bosses.sort((a, b) => (b.classification || 0) - (a.classification || 0));
|
||||
for (const e of bosses) {
|
||||
const li = document.createElement("li");
|
||||
li.innerHTML = `<span class="swatch"></span><span>${escapeHtml(b.name)}</span>`;
|
||||
li.innerHTML = `<span class="swatch boss"></span><span>${escapeHtml(e.name)}</span>`;
|
||||
bossUl.appendChild(li);
|
||||
}
|
||||
if (!m.bosses?.length) {
|
||||
if (!bosses.length) {
|
||||
const li = document.createElement("li");
|
||||
li.innerHTML = `<span style="color:var(--text-dim)">no boss data</span>`;
|
||||
bossUl.appendChild(li);
|
||||
@@ -444,7 +392,7 @@ function renderInfoPane() {
|
||||
const li = document.createElement("li");
|
||||
li.innerHTML = `
|
||||
<span class="swatch"></span>
|
||||
<span>#${i + 1} (${p.x.toFixed(1)}, ${p.y.toFixed(1)})</span>
|
||||
<span>#${i + 1} (${Math.round(p.x)}, ${Math.round(p.y)})</span>
|
||||
<button title="Remove">×</button>
|
||||
`;
|
||||
li.querySelector("button").onclick = () => removePoint("route", i);
|
||||
@@ -463,6 +411,85 @@ function escapeHtml(s) {
|
||||
);
|
||||
}
|
||||
|
||||
/* --- pan / zoom ---------------------------------------------------------- */
|
||||
|
||||
function applyView() {
|
||||
$("canvas-stage").style.transform =
|
||||
`translate(${state.view.tx}px, ${state.view.ty}px) scale(${state.view.scale})`;
|
||||
}
|
||||
|
||||
function fitToScreen() {
|
||||
const m = currentMap();
|
||||
const host = $("canvas-host");
|
||||
if (!m || !host) return;
|
||||
const pad = 24;
|
||||
const sx = (host.clientWidth - pad * 2) / m.width;
|
||||
const sy = (host.clientHeight - pad * 2) / m.height;
|
||||
const s = Math.min(sx, sy);
|
||||
state.view.scale = s;
|
||||
state.view.tx = -(m.width * s) / 2;
|
||||
state.view.ty = -(m.height * s) / 2;
|
||||
applyView();
|
||||
}
|
||||
|
||||
function zoomBy(factor, anchorX, anchorY) {
|
||||
const host = $("canvas-host");
|
||||
const rect = host.getBoundingClientRect();
|
||||
const ax = anchorX ?? rect.width / 2;
|
||||
const ay = anchorY ?? rect.height / 2;
|
||||
const cx = rect.width / 2 + state.view.tx;
|
||||
const cy = rect.height / 2 + state.view.ty;
|
||||
const dx = ax - cx; const dy = ay - cy;
|
||||
const before = state.view.scale;
|
||||
const after = Math.max(0.05, Math.min(20, before * factor));
|
||||
const ratio = after / before;
|
||||
state.view.scale = after;
|
||||
state.view.tx -= dx * (ratio - 1);
|
||||
state.view.ty -= dy * (ratio - 1);
|
||||
applyView();
|
||||
}
|
||||
|
||||
function hookCanvasPanZoom() {
|
||||
const host = $("canvas-host");
|
||||
|
||||
host.addEventListener("wheel", (e) => {
|
||||
e.preventDefault();
|
||||
const rect = host.getBoundingClientRect();
|
||||
zoomBy(e.deltaY < 0 ? 1.15 : 1 / 1.15, e.clientX - rect.left, e.clientY - rect.top);
|
||||
}, { passive: false });
|
||||
|
||||
host.addEventListener("pointerdown", (e) => {
|
||||
if (e.target.closest("g.waypoint, g.pull")) return;
|
||||
const onSvg = e.target.closest("#overlay");
|
||||
const isPanIntent = e.button === 1 || e.shiftKey || e.button === 2 || !onSvg;
|
||||
if (!isPanIntent) return;
|
||||
e.preventDefault();
|
||||
state.pan = { x0: e.clientX, y0: e.clientY, tx0: state.view.tx, ty0: state.view.ty };
|
||||
host.classList.add("panning");
|
||||
host.setPointerCapture(e.pointerId);
|
||||
});
|
||||
host.addEventListener("pointermove", (e) => {
|
||||
if (!state.pan) return;
|
||||
state.view.tx = state.pan.tx0 + (e.clientX - state.pan.x0);
|
||||
state.view.ty = state.pan.ty0 + (e.clientY - state.pan.y0);
|
||||
applyView();
|
||||
});
|
||||
host.addEventListener("pointerup", () => {
|
||||
state.pan = null;
|
||||
host.classList.remove("panning");
|
||||
});
|
||||
host.addEventListener("contextmenu", (e) => {
|
||||
if (state.pan) e.preventDefault();
|
||||
});
|
||||
|
||||
$("zoom-in").addEventListener("click", () => zoomBy(1.25));
|
||||
$("zoom-out").addEventListener("click", () => zoomBy(1 / 1.25));
|
||||
$("zoom-reset").addEventListener("click", fitToScreen);
|
||||
window.addEventListener("resize", () => fitToScreen());
|
||||
}
|
||||
|
||||
/* --- history / share / export ------------------------------------------- */
|
||||
|
||||
function pushHistory() {
|
||||
state.history.push(JSON.stringify({ routes: state.routes, pulls: state.pulls }));
|
||||
if (state.history.length > 200) state.history.shift();
|
||||
@@ -494,21 +521,12 @@ function clearCurrent() {
|
||||
updateHash();
|
||||
}
|
||||
|
||||
/* --- URL hash sharing ---------------------------------------------------- */
|
||||
|
||||
function updateHash() {
|
||||
if (!state.current) return;
|
||||
const payload = {
|
||||
d: `${state.current.expansion}/${state.current.id}`,
|
||||
f: state.floorIndex,
|
||||
r: state.routes,
|
||||
p: state.pulls,
|
||||
};
|
||||
const json = JSON.stringify(payload);
|
||||
const minified = compressForUrl(json);
|
||||
const newHash = `#${minified}`;
|
||||
if (location.hash !== newHash) {
|
||||
history.replaceState(null, "", location.pathname + location.search + newHash);
|
||||
const payload = { d: state.current.id, f: state.floorIndex, r: state.routes, p: state.pulls };
|
||||
const hash = "#" + compress(JSON.stringify(payload));
|
||||
if (location.hash !== hash) {
|
||||
history.replaceState(null, "", location.pathname + location.search + hash);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -516,11 +534,9 @@ function loadFromHash() {
|
||||
const h = location.hash.slice(1);
|
||||
if (!h) return;
|
||||
try {
|
||||
const json = decompressFromUrl(h);
|
||||
const p = JSON.parse(json);
|
||||
const p = JSON.parse(decompress(h));
|
||||
if (p.d) {
|
||||
const [expansion, id] = p.d.split("/");
|
||||
const d = state.dungeons.find((x) => x.id === id && x.expansion === expansion);
|
||||
const d = state.dungeons.find((x) => x.id === p.d);
|
||||
if (d) {
|
||||
state.current = d;
|
||||
state.floorIndex = p.f || 0;
|
||||
@@ -534,11 +550,10 @@ function loadFromHash() {
|
||||
}
|
||||
}
|
||||
|
||||
// Tiny base64-of-utf8 — small payloads make for OK-length URLs.
|
||||
function compressForUrl(s) {
|
||||
function compress(s) {
|
||||
return btoa(unescape(encodeURIComponent(s))).replace(/=+$/, "").replace(/\+/g, "-").replace(/\//g, "_");
|
||||
}
|
||||
function decompressFromUrl(s) {
|
||||
function decompress(s) {
|
||||
s = s.replace(/-/g, "+").replace(/_/g, "/");
|
||||
while (s.length % 4) s += "=";
|
||||
return decodeURIComponent(escape(atob(s)));
|
||||
@@ -554,11 +569,11 @@ function exportJson() {
|
||||
const d = state.current;
|
||||
if (!d) return;
|
||||
const payload = {
|
||||
dungeon: { id: d.id, expansion: d.expansion, name: d.name },
|
||||
dungeon: { id: d.id, name: d.name },
|
||||
floors: d.maps.map((m, i) => ({
|
||||
label: m.label,
|
||||
route: state.routes[`${d.expansion}::${d.id}::${i}`] || [],
|
||||
pulls: state.pulls[`${d.expansion}::${d.id}::${i}`] || [],
|
||||
route: state.routes[`${d.id}::${i}`] || [],
|
||||
pulls: state.pulls[`${d.id}::${i}`] || [],
|
||||
})),
|
||||
};
|
||||
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: "application/json" });
|
||||
@@ -583,11 +598,15 @@ function toast(msg) {
|
||||
toast._h = setTimeout(() => t.classList.remove("show"), 1500);
|
||||
}
|
||||
|
||||
/* --- events -------------------------------------------------------------- */
|
||||
function setTool(tool) {
|
||||
state.tool = tool;
|
||||
$("tool-route").classList.toggle("active", tool === "route");
|
||||
$("tool-pull").classList.toggle("active", tool === "pull");
|
||||
}
|
||||
|
||||
function hookEvents() {
|
||||
$("search").addEventListener("input", renderDungeonList);
|
||||
$("expansion-filter").addEventListener("change", renderDungeonList);
|
||||
const ef = $("expansion-filter"); if (ef) ef.addEventListener("change", renderDungeonList);
|
||||
$("undo").addEventListener("click", undo);
|
||||
$("clear").addEventListener("click", clearCurrent);
|
||||
$("share").addEventListener("click", shareUrl);
|
||||
@@ -604,12 +623,6 @@ function hookEvents() {
|
||||
hookCanvasPanZoom();
|
||||
}
|
||||
|
||||
function setTool(t) {
|
||||
state.tool = t;
|
||||
$("tool-route").classList.toggle("active", t === "route");
|
||||
$("tool-pull").classList.toggle("active", t === "pull");
|
||||
}
|
||||
|
||||
init().catch((e) => {
|
||||
console.error(e);
|
||||
document.body.innerHTML = `<pre style="color:#d63b3b;padding:20px">init failed: ${e.message}</pre>`;
|
||||
|
||||
+177203
-1907
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user