From be3473caa01184110a326dfb73421ff5ea8f5a86 Mon Sep 17 00:00:00 2001 From: Stan Runge Date: Mon, 26 Oct 2026 00:05:46 +0000 Subject: [PATCH] pubg map: built-in intel layers (vehicle spots, drops, loot density, Blue Chip towers, secret rooms) per map Read-only layers from static/pubg-intel/.v1.json (scripts/build-pubg-intel.py): positions from pubg.gs (game level files), drops from guides, secret rooms and Deston keys as approximate rings on the named place. Layer panel (K), per-player choices in localStorage, drawn below room markers; clicks still place markers. --- .prettierignore | 3 + scripts/build-pubg-intel.py | 339 +++++++++++++++++++++++++++ src/lib/pubg-map/PubgMap.svelte | 374 +++++++++++++++++++++++++++++- src/lib/pubg-map/intel.test.ts | 40 ++++ src/lib/pubg-map/intel.ts | 296 +++++++++++++++++++++++ static/pubg-intel/deston.v1.json | 1 + static/pubg-intel/erangel.v1.json | 1 + static/pubg-intel/haven.v1.json | 1 + static/pubg-intel/karakin.v1.json | 1 + static/pubg-intel/miramar.v1.json | 1 + static/pubg-intel/paramo.v1.json | 1 + static/pubg-intel/rondo.v1.json | 1 + static/pubg-intel/sanhok.v1.json | 1 + static/pubg-intel/taego.v1.json | 1 + static/pubg-intel/vikendi.v1.json | 1 + 15 files changed, 1057 insertions(+), 5 deletions(-) create mode 100644 scripts/build-pubg-intel.py create mode 100644 src/lib/pubg-map/intel.test.ts create mode 100644 src/lib/pubg-map/intel.ts create mode 100644 static/pubg-intel/deston.v1.json create mode 100644 static/pubg-intel/erangel.v1.json create mode 100644 static/pubg-intel/haven.v1.json create mode 100644 static/pubg-intel/karakin.v1.json create mode 100644 static/pubg-intel/miramar.v1.json create mode 100644 static/pubg-intel/paramo.v1.json create mode 100644 static/pubg-intel/rondo.v1.json create mode 100644 static/pubg-intel/sanhok.v1.json create mode 100644 static/pubg-intel/taego.v1.json create mode 100644 static/pubg-intel/vikendi.v1.json diff --git a/.prettierignore b/.prettierignore index 450aaba..440b04d 100755 --- a/.prettierignore +++ b/.prettierignore @@ -25,3 +25,6 @@ static/vendor/ # Generated drizzle snapshots drizzle/*/snapshot.json + +# Generated PUBG map intel (scripts/build-pubg-intel.py) +static/pubg-intel/ diff --git a/scripts/build-pubg-intel.py b/scripts/build-pubg-intel.py new file mode 100644 index 0000000..fce345f --- /dev/null +++ b/scripts/build-pubg-intel.py @@ -0,0 +1,339 @@ +#!/usr/bin/env python3 +"""Build the PUBG drop map's built-in intel layers (static/pubg-intel/.v1.json). + +Exact positions come from pubg.gs, which reads them from the game's level files +(https://pubg.gs, fetched once on 2026-10-25). Only selected layers are published +(named places, bridges, normal-BR vehicle spots, Blue Chip towers, vending machines, +bear spawns); the 20k+ item spots are reduced to coarse loot-density cells. +Hot/quiet drops and secret rooms come from guides, anchored to the game's own +named-location points and flagged approximate. Nothing is placed by eye. + +Usage: python3 scripts/build-pubg-intel.py static/pubg-intel +Input: the pubg.gs layer files (layers-.json) in $PUBG_GS_DIR, default the +script's folder. They are not committed: pubg.gs asks not to republish its data wholesale. +""" +import json, math, sys, collections, os + +SRC = os.environ.get('PUBG_GS_DIR') or os.path.dirname(os.path.abspath(__file__)) +OUT = sys.argv[1] if len(sys.argv) > 1 else os.path.join(SRC, 'out') +BUILT = '2026-10-25' + +DEV = {'erangel': 'erangel', 'miramar': 'miramar', 'sanhok': 'sanhok', 'vikendi': 'vikendi', 'taego': 'taego', + 'deston': 'deston', 'rondo': 'rondo', 'karakin': 'karakin', 'paramo': 'paramo', 'haven': 'haven'} + +SOURCES = { + 'gs': {'name': 'pubg.gs', 'url': 'https://pubg.gs/maps/{slug}/', + 'note': 'positions read from the game level files'}, + 'pcg': {'name': 'PCGamesN, PUBG map list', 'url': 'https://www.pcgamesn.com/playerunknowns-battlegrounds/pubg-map'}, + 'psr': {'name': 'pubgsecretroom.com, best drop locations', + 'url': 'https://pubgsecretroom.com/pubg-best-drop-locations-guide.html'}, + 'epic': {'name': 'Epic Games Store, guide to Rondo', + 'url': 'https://store.epicgames.com/en-US/news/a-guide-to-pubg-battlegrounds-massive-new-map-rondo'}, + 'isp': {'name': 'InsideSport, Rondo hot spots', + 'url': 'https://www.insidesport.in/gaming/pubg-mobile-rondo-hot-spots-check-out-the-top-landing-locations/'}, + 'gamepur': {'name': 'Gamepur, Deston secret rooms', + 'url': 'https://www.gamepur.com/guides/all-deston-secret-room-locations-in-pubg'}, + 'loadout': {'name': 'The Loadout, Deston security keys', + 'url': 'https://www.theloadout.com/pubg/security-key-location-deston'}, + 'ib': {'name': 'ImmortalBoost, PUBG secret rooms', 'url': 'https://immortalboost.com/blog/pubg/secret-locations/'}, + 'pn141': {'name': 'PUBG Wiki (Fandom), Erangel: Update 14.1 church basement', 'url': 'https://pubg.fandom.com/wiki/Erangel_(Remastered)'}, + 'pn412': {'name': 'PUBG patch notes, Update 41.2 (Miramar secret rooms)', 'url': 'https://pubg.com/en/news/10080'}, + 'psrooms': {'name': 'pubgsecretrooms.com (exact secret room spots)', 'url': 'https://pubgsecretrooms.com/{slug}-secret-room-locations/'}, +} + +NAMES = { + 'erangel': {'boatyard': 'Boatyard', 'farm': 'Farm', 'ferrypier': 'Ferry Pier', 'gatka': 'Gatka', 'georgopol': 'Georgopol', + 'hospital': 'Hospital', 'kameshki': 'Kameshki', 'lipovka': 'Lipovka', 'mansion': 'Mansion', 'mylta': 'Mylta', + 'myltapower': 'Mylta Power', 'novorepnoye': 'Novorepnoye', 'pochinki': 'Pochinki', 'primorsk': 'Primorsk', + 'prison': 'Prison', 'quarry': 'Quarry', 'rozhok': 'Rozhok', 'ruins': 'Ruins', 'school': 'School', + 'severny': 'Severny', 'shelter': 'Shelter', 'shootingrange': 'Shooting Range', + 'sosnovkamilitarybase': 'Military Base', 'stalber': 'Stalber', 'yasnayapolyana': 'Yasnaya Polyana', + 'zharki': 'Zharki'}, + 'miramar': {'alcantara': 'Alcantara', 'brickyard': 'Brick Yard', 'campomilitar': 'Campo Militar', 'cantera': 'Cantera', + 'chumacera': 'Chumacera', 'craterfields': 'Crater Fields', 'cruzdelvalle': 'Cruz del Valle', + 'elazahar': 'El Azahar', 'elpozo': 'El Pozo', 'graveyard': 'Graveyard', + 'haciendadelpatron': 'Hacienda del Patron', 'impala': 'Impala', 'lacobreria': 'La Cobreria', + 'losleones': 'Los Leones', 'manisgenerales': 'Minas Generales', 'montenuevo': 'Monte Nuevo', + 'oasis': 'Oasis', 'partona': 'Partona', 'pecado': 'Pecado', + 'powergrid': 'Power Grid', 'prison': 'Prison', 'puertoparaiso': 'Puerto Paraiso', 'resort': 'Resort', + 'sanmartin': 'San Martin', 'tierrabronca': 'Tierra Bronca', 'truckstop': 'Truck Stop', + 'valledelmar': 'Valle del Mar', 'watertreatment': 'Water Treatment'}, + 'sanhok': {'bantai': 'Ban Tai', 'bootcamp': 'Bootcamp', 'campalpha': 'Camp Alpha', 'campbravo': 'Camp Bravo', + 'campcharlie': 'Camp Charlie', 'cave': 'Cave', 'docks': 'Docks', 'hatinh': 'Ha Tinh', 'kampong': 'Kampong', + 'khao': 'Khao', 'lawaki': 'Lakawi', 'mongnai': 'Mongnai', 'nakham': 'Na Kham', 'painan': 'Pai Nan', + 'paradiseresort': 'Paradise Resort', 'quarry': 'Quarry', 'ruins': 'Ruins', 'sahmee': 'Sahmee', + 'tambang': 'Tambang', 'tatmok': 'Tat Mok'}, + 'vikendi': {'castle': 'Castle', 'coalmine': 'Coal Mine', 'dekamesto': 'Deka Mesto', 'dinoland': 'Dinoland', + 'kranik': 'Kranik', 'laveni': 'Laveni', 'lumberyard': 'Lumber Yard', 'naros': 'Naros', + 'naznova': 'Naznova', 'observatory': 'Observatory', 'pavilka': 'Pavilka', + 'trainstation': 'Train Station', 'trika': 'Trika', 'winery': 'Winery'}, + 'taego': {'airport': 'Airport', 'armybase': 'Army Base', 'buksansa': 'Buk San Sa', 'fishingcamp': 'Fishing Camp', + 'godok': 'Go Dok', 'haemoosa': 'Hae Moo Sa', 'hapo': 'Ha Po', 'happy_hospital': 'Happy Hospital', + 'hosan': 'Ho San', 'hosanprison': 'Ho San Prison', 'hospital': 'Hospital', 'kangneung': 'Kang Neung', + 'ohhyang': 'Oh Hyang', 'palace': 'Palace', 'school': 'School', 'shipyard': 'Shipyard', 'songam': 'Song Am', + 'terminal': 'Terminal', 'wolsong': 'Wol Song', 'yongcheon': 'Yong Cheon'}, + 'rondo': {'BeiLi': 'Bei Li', 'danching': 'Dan Ching', 'fongtun': 'Fong Tun', 'hemaytown': 'Hemay Town', + 'hungshan': 'Hung Shan', 'jadenacity': 'Jadena City', 'jaotin': 'Jao Tin', 'kunxia': 'Kun Xia', + 'lanpo': 'Lan Po', 'lohuaxing': 'Lo Hua Xing', 'longho': 'Long Ho', 'meyran': 'Mey Ran', 'minhu': 'Min Hu', + 'muhopan': 'Mu Ho Pan', 'nanchuan': 'Nan Chuan', 'neoxfactory': 'NEOX Factory', 'raian': 'Rai An', + 'rinjiang': 'Rin Jiang', 'stadium': 'Stadium', 'testtrack': 'Test Track', 'tinlonggarden': 'Tin Long Garden', + 'tuling': 'Tu Ling', 'yulin': 'Yu Lin', 'yungu': 'Yun Gu'}, + 'karakin': {'alhabar': 'Al Habar', 'alhayik': 'Al Hayik', 'bahrsahir': 'Bahr Sahir', 'bashara': 'Bashara', + 'hadiqanemo': 'Hadiqa Nemo'}, + 'paramo': {'Lab': 'Lab', 'Level_A': 'City slot A', 'Level_B': 'City slot B', 'Level_C': 'City slot C', + 'Level_D': 'City slot D'}, + 'haven': {'coalyards': 'Coal Yards', 'docks': 'Docks', 'industrialpark': 'Industrial Park', 'railyard': 'Rail Yard', + 'residential': 'Residential', 'steelmill': 'Steel Mill'}, +} + +# Hot, balanced and quiet drops: (poi slug or display name, tier, note, source) +DROPS = { + 'erangel': [('pochinki', 'hot', 'Centre, dense housing: guaranteed early fights', 'psr'), + ('georgopol', 'hot', 'Containers and warehouses, a lot of loot, narrow lanes', 'psr'), + ('sosnovkamilitarybase', 'hot', 'Richest loot on the map; bridges are easy to hold, hard to leave', 'psr'), + ('school', 'hot', '"Drop School if you\'re feeling feisty"', 'pcg'), + ('yasnayapolyana', 'busy', 'Large city block, high loot, medium-high traffic', 'psr'), + ('rozhok', 'balanced', 'Small, loot-dense, quick to clear', 'psr'), + ('novorepnoye', 'balanced', 'Warehouses with less traffic', 'psr'), + ('gatka', 'quiet', 'Few buildings, very little competition', 'psr'), + ('lipovka', 'quiet', 'Few buildings, very little competition', 'psr')], + 'miramar': [('pecado', 'hot', 'One of Miramar\'s best loot spots', 'pcg'), + ('haciendadelpatron', 'hot', 'One of Miramar\'s best loot spots', 'pcg'), + ('losleones', 'hot', 'Big city, one of the best loot spots', 'pcg'), + ('elpozo', 'hot', 'Big city, one of the best loot spots', 'pcg')], + 'sanhok': [('bootcamp', 'hot', 'Top-tier gear, early fights almost guaranteed', 'pcg'), + ('ruins', 'hot', 'Top-tier gear', 'pcg'), + ('paradiseresort', 'hot', 'Top-tier gear', 'pcg'), + ('kampong', 'quiet', 'Landing spot to save the heated battles for later', 'pcg'), + ('docks', 'quiet', 'Landing spot to save the heated battles for later', 'pcg')], + 'vikendi': [('castle', 'hot', 'Centre; among the most useful loot', 'psr'), + ('dinoland', 'balanced', 'Good loot (PCGamesN); a quieter alternative (pubgsecretroom.com)', 'pcg')], + 'taego': [('palace', 'hot', 'One of the two big loot magnets', 'psr'), + ('terminal', 'hot', 'One of the two big loot magnets', 'psr'), + ('hosan', 'balanced', 'The balanced option', 'psr')], + 'deston': [('Ripton', 'hot', 'Downtown core: more contested than Concert and Lodge', 'psr'), + ('Concert', 'balanced', 'One of the strongest early landings, less contested than downtown', 'psr'), + ('Lodge', 'balanced', 'One of the strongest early landings, less contested than downtown', 'psr')], + 'rondo': [('jadenacity', 'hot', 'Dense skyscrapers and strong loot; avoid when early on the flight path', 'epic'), + ('stadium', 'hot', 'High-loot bloodbath', 'isp'), + ('neoxfactory', 'balanced', 'Central, lower-risk; vehicle spawns and test track to rotate', 'epic'), + ('yulin', 'quiet', 'For a stealthy start', 'isp'), + ('hungshan', 'quiet', 'Safer early loot', 'isp')], +} + +# Secret rooms and keys anchored to a named location: (poi, layer, title, where, source) +SECRETS = { + 'erangel': [('pochinki', 'secret', 'Church basement room', 'Basement of the Pochinki church; no key needed (Update 14.1)', 'pn141')], + 'miramar': [('craterfields', 'secret', 'Secret room (mine)', 'West of Crater Fields, rocky area between La Cobreria and El Pozo', 'ib'), + ('oasis', 'secret', 'Secret room (mine)', 'South of Oasis, close to the northern road', 'ib'), + ('watertreatment', 'secret', 'Secret room (mine)', 'North of Water Treatment, on the rocks above the compound', 'ib'), + ('elazahar', 'secret', 'Secret room (mine)', 'North-west of El Azahar, near the hills below Tierra Bronca', 'ib'), + ('elpozo', 'secret', 'Secret room (mine)', 'East of El Pozo, on the route toward San Martin', 'ib')], + 'deston': [('Barclift', 'secret', 'Security room', 'Barclift, east', 'gamepur'), + ('Buxley', 'secret', 'Security room', 'Buxley, north-west part', 'gamepur'), + ("Carpenter’s End", 'secret', 'Security room', "Carpenter's End, centre", 'gamepur'), + ('Cavala', 'secret', 'Security room', 'Cavala, north', 'gamepur'), + ('Concert', 'secret', 'Security room', 'Concert, centre', 'gamepur'), + ('Holston Meadows', 'secret', 'Security room', 'Holston Meadows, centre', 'gamepur'), + ('Sancarna', 'secret', 'Security room', 'Sancarna, north-east', 'gamepur'), + ('Ripton', 'secret', 'Security rooms', 'Ripton, north; Ripton has the most rooms and trailers', 'gamepur'), + ('Ten Forts', 'secret', 'Security room', 'Ten Forts, south', 'gamepur'), + ('Turrita', 'secret', 'Security room', 'Turrita, north', 'gamepur'), + ('Wind Farms', 'secret', 'Security room', 'Wind Farm, north of the bridge', 'gamepur'), + ('Ten Forts', 'keys', 'Security key (drone)', 'North of Ten Forts', 'loadout'), + ("Carpenter’s End", 'keys', 'Security key (drone)', "North-west of Carpenter's End", 'loadout'), + ('Wind Farms', 'keys', 'Security key (drone)', 'Middle of Wind Farm', 'loadout'), + ('Buxley', 'keys', 'Security key (drone)', 'South-west of Buxley', 'loadout'), + ('Turrita', 'keys', 'Security key (drone)', 'South-west of Turrita', 'loadout'), + ('Barclift', 'keys', 'Security key (drone)', 'South of Barclift', 'loadout'), + ('Lodge', 'keys', 'Security key (drone)', 'North-east of Lodge', 'loadout'), + ('Arena', 'keys', 'Security keys (drone)', 'North and south of Arena', 'loadout'), + ('Cavala', 'keys', 'Security key (drone)', 'North-west of Cavala', 'loadout'), + ('Sancarna', 'keys', 'Security key (drone)', 'South-west of Sancarna', 'loadout'), + ('El Koro', 'keys', 'Security key (drone)', 'North of El Koro', 'loadout'), + ('Holston Meadows', 'keys', 'Security key (drone)', 'North of Holston Meadows', 'loadout')], +} + +# Per-map notes for things without sourceable coordinates. +NOTES = { + 'erangel': [('Secret rooms', 'Keyed secret rooms with underground entrances (keys are random world loot). Exact spots are not republished here; see the source map.', 'psrooms')], + 'miramar': [('Secret rooms', 'Abandoned-mine secret rooms opened with world-spawned Secret Room Keys (Update 41.2, Normal and Custom matches). Five are placed here near their landmark; more exist, see the source map for all of them.', 'pn412')], + 'taego': [('Secret rooms', 'Secret rooms behind blue corrugated roofs; keys spawn randomly (glowing pots can drop one). Exact spots: see the source map.', 'psrooms')], + 'vikendi': [('Secret rooms, labs and bear caves', 'Bear spawns below come from the game files (bear caves hold airdrop-quality loot). Secret rooms and lab rooms: see the source map.', 'psrooms')], + 'rondo': [('Secret rooms', 'Security door and keycard rooms built into compound clusters; keys are random floor loot. Exact spots: see the source map.', 'psrooms')], + 'paramo': [('Dynamic map', 'Paramo\'s cities move between the four city slots each match; the slot points below are where a city can appear.', 'gs'), + ('Secret rooms', 'Stone-door secret rooms. Exact spots: see the source map.', 'psrooms')], + 'deston': [('Security rooms', 'Rooms open with Pillar security keys, which sit in drone-only spots near the listed places (not in El Koro, Lodge or Arena). Positions are the named place, not the door.', 'loadout')], +} + +VEH_TYPE = { + 'Road': 'car', 'RoadPlus': 'car', 'Offroad': 'car', 'Rich_Houses': 'car', 'Poor_Houses': 'car', 'Farm': 'car', + 'FoodTruck': 'car', 'Sports': 'car', 'Picobus': 'car', 'Start': 'car', + 'Garage': 'garage', 'D_Garage': 'garage', 'Police_Stations': 'garage', + 'Water': 'boat', 'WaterA': 'boat', 'WaterB': 'boat', 'WaterC': 'boat', 'WaterCave': 'boat', 'WaterSea': 'boat', + 'Ocean': 'boat', 'River': 'boat', 'Dam': 'boat', 'W_Garage': 'boat', 'Water_Ferry': 'boat', + 'Motorglider': 'glider', 'Hacienda_GoldMirado': 'special', 'OnlyBikeForCaveOnly': 'special', +} +LAYER_OF = {'car': 'cars', 'garage': 'garages', 'boat': 'boats', 'glider': 'gliders', 'special': 'special'} +CAT_LABEL = {'Road': 'Road spot', 'RoadPlus': 'Road spot (extra)', 'Offroad': 'Off-road spot', 'Rich_Houses': 'Rich houses', + 'Poor_Houses': 'Poor houses', 'Farm': 'Farm (Pony Coupe)', 'FoodTruck': 'Food truck', 'Sports': 'Sports', + 'Picobus': 'Pico Bus', 'Start': 'Start buggy', 'Garage': 'Garage', 'D_Garage': 'Downtown garage', + 'Police_Stations': 'Police station (Pillar car)', 'Water': 'Boat', 'WaterA': 'Boat', 'WaterB': 'Boat', + 'WaterC': 'Boat', 'WaterCave': 'Cave boat', 'WaterSea': 'Sea boat', 'Ocean': 'Ocean (Aquarail)', + 'River': 'River (Airboat)', 'Dam': 'Dam (Airboat)', 'W_Garage': 'Boat garage (Airboat)', + 'Water_Ferry': 'Ferry', 'Motorglider': 'Motor glider', 'Hacienda_GoldMirado': 'Gold Mirado', + 'OnlyBikeForCaveOnly': 'Cave bike'} + + +def r5(v): + return round(v, 5) + + +def build(slug): + d = json.load(open(os.path.join(SRC, f'layers-{DEV[slug]}.json'))) + E = d['extent'] + L = d['layers'] + inb = lambda x, y: 1000 < x < E - 1000 and 1000 < y < E - 1000 + out = collections.OrderedDict() + skipped = collections.Counter() + + # Named places (dedupe by display name; Deston has slug duplicates of the zone markers). + names = NAMES.get(slug, {}) + + def display(p): + if slug == 'deston': + return 'Swamp' if p['name'] == 'swamp' else p['name'].replace('’', "'") + return names[p['name']] + pois = {} + for p in L.get('pois', []): + nm = p['name'] + if slug == 'deston': + if p.get('level') != 'Level_Design_Zone': + continue + disp = 'Swamp' if nm == 'swamp' else nm.replace('’', "'") + else: + disp = names.get(nm) + if not disp: + skipped['poi:' + nm] += 1 + continue + if disp in pois: + continue + pois[disp] = p + pois[nm] = p + towns = [] + seen = set() + for k, p in pois.items(): + if id(p) in seen: + continue + seen.add(id(p)) + disp = display(p) + rr = max(p.get('r') or 0, 10000) + near = sum(1 for it in L.get('items', []) if (it[0] - p['x']) ** 2 + (it[1] - p['y']) ** 2 <= rr * rr) + towns.append({'x': r5(p['x'] / E), 'y': r5(p['y'] / E), 'n': disp, 'r': r5(rr / E), + 'd': f'{near} item spots within {round(rr / 100)} m', 'w': near, 's': 'gs'}) + out['towns'] = towns + + def anchor(key): + p = pois.get(key) + if p is None: + raise SystemExit(f'{slug}: no named location {key!r}') + disp = display(p) + return p, disp + + out['bridges'] = [{'x': r5(b['x'] / E), 'y': r5(b['y'] / E), 'n': 'Bridge', 's': 'gs'} + for b in L.get('bridges', []) if inb(b['x'], b['y']) and 'IBR' not in (b.get('level') or '')] + + # Vehicles: normal battle royale only (no IBR levels, no esports groups). + rules = d.get('vehicleRules', {}).get('br', {}).get('groups', {}) + spots = collections.defaultdict(list) + for v in L.get('vehicles', []): + lvl = v.get('level') or '' + if 'IBR' in lvl.upper() or 'IBR' in (v.get('tag') or '').upper(): + continue + g = v.get('group') or 'Default' + spots[g].append(v) + for g, vs in spots.items(): + if g == 'Default': + cat, rule = 'Road', rules.get('A', [{}])[0] + else: + rs = rules.get(g) + if not rs: + skipped[f'vehicles:{g} (no rule)'] += len(vs) + continue + rule = rs[-1] if slug == 'rondo' and g == 'E' else rs[0] + cat = rule['categories'][0] + if cat.lower().startswith('esports') or cat in ('Special', 'Air'): + skipped[f'vehicles:{cat}'] += len(vs) + continue + cnt = rule.get('count') or [0, 0] + if not cnt or max(cnt) == 0: + skipped[f'vehicles:{cat} (0 per match)'] += len(vs) + continue + t = VEH_TYPE.get(cat) + if not t: + skipped[f'vehicles:{cat}'] += len(vs) + continue + n_spots = len(vs) + (len(spots.get('A', [])) if g == 'Default' else 0) + if g == 'A': + n_spots += len(spots.get('Default', [])) + lo, hi = cnt[0], cnt[-1] + pool = [x if isinstance(x, str) else (x.get('name') or x.get('vehicle')) for x in (rule.get('vehicles') or [])] + chance = min(1.0, hi / max(1, n_spots)) + note = f"{lo if lo == hi else f'{lo}-{hi}'} of {n_spots} spots spawn a vehicle each match (~{round(chance * 100)}%)" + if pool: + note += '; pool: ' + ', '.join(dict.fromkeys(p for p in pool if p)) + lay = LAYER_OF[t] + for v in vs: + if not inb(v['x'], v['y']): + continue + out.setdefault(lay, []).append({'x': r5(v['x'] / E), 'y': r5(v['y'] / E), 'n': CAT_LABEL.get(cat, cat), + 't': t, 'd': note, 'p': round(chance, 2), 's': 'gs'}) + + # Drops (hot / balanced / quiet), anchored on the named place. + for key, tier, note, src in DROPS.get(slug, []): + p, disp = anchor(key) + out.setdefault('drops', []).append({'x': r5(p['x'] / E), 'y': r5(p['y'] / E), 'n': disp, 't': tier, 'd': note, 's': src}) + + # Secret rooms and keys: approximate, shown as a ring around the named place. + for key, lay, title, where, src in SECRETS.get(slug, []): + p, disp = anchor(key) + out.setdefault(lay, []).append({'x': r5(p['x'] / E), 'y': r5(p['y'] / E), 'n': title, 'd': where + ' (approximate: drawn on the named place)', + 'a': 1, 'r': r5(max(p.get('r') or 0, 20000) / E), 's': src}) + + out['towers'] = [{'x': r5(t['x'] / E), 'y': r5(t['y'] / E), 'n': 'Blue Chip tower', 'd': 'Blue Chip Transmitter spot', 's': 'gs'} + for t in L.get('towers', []) if inb(t['x'], t['y']) and 'IBR' not in (t.get('level') or '')] + out['vending'] = [{'x': r5(t['x'] / E), 'y': r5(t['y'] / E), 'n': 'Vending machine', 's': 'gs'} + for t in L.get('vending', []) if inb(t['x'], t['y'])] + out['bears'] = [{'x': r5(t['x'] / E), 'y': r5(t['y'] / E), 'n': 'Polar bear spawn', 'd': 'Bear cave spawn; the caves hold airdrop-tier loot (see ImmortalBoost)', 's': 'gs'} + for t in L.get('other', []) if t.get('cls') == 'BP_BearSpawnStart_C' and inb(t['x'], t['y'])] + + # Loot density: item spots per ~200 m cell (normal BR levels only), top cells kept. + cell = 20000 if E >= 400000 else 10000 + grid = collections.Counter() + for it in L.get('items', []): + x, y = it[0], it[1] + if inb(x, y): + grid[(int(x // cell), int(y // cell))] += 1 + if grid: + counts = sorted(grid.values(), reverse=True) + keep = max(10, len(counts) // 12) + floor = counts[min(keep, len(counts)) - 1] + top = counts[0] + out['loot'] = [{'x': r5((i + 0.5) * cell / E), 'y': r5((j + 0.5) * cell / E), 'r': r5(cell / E / 2), + 'w': round(n / top, 2), 'n': f'{n} item spots in this {cell // 100} m square', 's': 'gs'} + for (i, j), n in sorted(grid.items(), key=lambda kv: -kv[1]) if n >= floor] + out = {k: v for k, v in out.items() if v} + + notes = [{'title': t, 'text': x, 's': s} for t, x, s in NOTES.get(slug, [])] + used = {p['s'] for v in out.values() for p in v} | {n['s'] for n in notes} | ({'ib'} if out.get('bears') else set()) + srcs = {k: {kk: (vv.replace('{slug}', slug) if isinstance(vv, str) else vv) for kk, vv in SOURCES[k].items()} for k in sorted(used)} + doc = {'v': 1, 'map': slug, 'built': BUILT, 'sources': srcs, 'notes': notes, 'layers': out} + os.makedirs(OUT, exist_ok=True) + path = os.path.join(OUT, f'{slug}.v1.json') + with open(path, 'w') as f: + json.dump(doc, f, ensure_ascii=False, separators=(',', ':')) + print(f'{slug:8s} {os.path.getsize(path) // 1024:4d} KB', {k: len(v) for k, v in out.items()}, dict(skipped) if skipped else '') + + +for s in DEV: + build(s) diff --git a/src/lib/pubg-map/PubgMap.svelte b/src/lib/pubg-map/PubgMap.svelte index bf869ef..20f156a 100644 --- a/src/lib/pubg-map/PubgMap.svelte +++ b/src/lib/pubg-map/PubgMap.svelte @@ -20,7 +20,22 @@ import Footprints from 'lucide-svelte/icons/footprints'; import ArrowLeft from 'lucide-svelte/icons/arrow-left'; import X from 'lucide-svelte/icons/x'; + import Layers from 'lucide-svelte/icons/layers'; import { PIN_SVG, esc, initials } from './pins'; + import { + DROP_COLOR, + DROP_LABEL, + INTEL_GROUPS, + INTEL_KEY, + INTEL_LAYERS, + INTEL_SVG, + VEHICLE_ICON, + loadIntel, + readLayerPrefs, + type IntelDoc, + type IntelLayerId, + type IntelPoint + } from './intel'; import { KIND_INFO, KINDS, @@ -75,6 +90,8 @@ timeZone: 'Europe/Amsterdam' }); const ME_KEY = 'pubg-map:me'; + const INTEL_CREDIT = + 'Map intel: pubg.gs and drop guides (sources under Layers)'; // Connection and room state (the markers themselves live in `layers`, outside Svelte). let conn = $state<'connecting' | 'live' | 'down' | 'gone'>('connecting'); @@ -95,6 +112,13 @@ let showGrid = $state(true); let showPast = $state(false); let drawer = $state(false); + let layersOpen = $state(false); + // Zoomed out: intel icons shrink to dots so the map stays readable. + let far = $state(true); + // Built-in intel layers: per player (localStorage), never sent to the room. + let layerOn = $state(readLayerPrefs(null)); + // undefined while loading, null for a map without intel (Camp Jackal). + let intel = $state.raw(undefined); let picker = $state(false); let fullscreen = $state(false); let toast = $state(null); @@ -124,6 +148,9 @@ let gridLayer: Leaflet.LayerGroup | undefined; let pastLayer: Leaflet.LayerGroup | undefined; let tempLayer: Leaflet.LayerGroup | undefined; + let intelLayer: Leaflet.LayerGroup | undefined; + let attrib: Leaflet.Control.Attribution | undefined; + let creditShown = false; let ws: WebSocket | null = null; let disposed = false; let retry = 0; @@ -463,6 +490,113 @@ } } + function intelTip(p: IntelPoint, kind: string) { + const src = intel?.sources[p.s]; + const parts = [`${esc(p.n)}`]; + if (kind) parts.push(` ${esc(kind)}`); + if (p.d) parts.push(`
${esc(p.d)}`); + if (src) + parts.push( + `
Source: ${esc(src.name)}${p.a ? ' · approximate spot' : ''}` + ); + return parts.join(''); + } + + function intelIcon(svg: string, color: string, cls = '', opacity = 1) { + return L.divIcon({ + className: 'pm-icon', + html: `
${svg}
`, + iconSize: [18, 18], + iconAnchor: [9, 9] + }); + } + + // Few and important: these keep their full icon when zoomed out. + const KEEP_ICON = new Set(['special', 'drops', 'secret', 'keys', 'bears']); + + function drawIntel() { + if (!intelLayer || !L) return; + intelLayer.clearLayers(); + if (!intel) return; + const tip = { className: 'pm-tip', direction: 'top', offset: [0, -8] } as const; + for (const def of INTEL_LAYERS) { + const pts = intel.layers[def.id]; + if (!layerOn[def.id] || !pts?.length) continue; + for (const p of pts) { + if (def.id === 'loot') { + const r = p.r ?? 0.01; + L.rectangle([ll(p.x - r, p.y - r), ll(p.x + r, p.y + r)], { + pane: 'pm-area', + color: def.color, + weight: 0.5, + opacity: 0.6, + fillColor: def.color, + fillOpacity: 0.12 + 0.4 * (p.w ?? 0.5) + }) + .bindTooltip(intelTip(p, 'loot density'), { className: 'pm-tip', sticky: true }) + .addTo(intelLayer); + continue; + } + if (p.a && p.r) + L.circle(ll(p.x, p.y), { + pane: 'pm-area', + radius: p.r * S, + color: def.color, + weight: 1.5, + dashArray: '4 5', + fillColor: def.color, + fillOpacity: 0.06, + interactive: false + }).addTo(intelLayer); + let svg = INTEL_SVG[def.icon as keyof typeof INTEL_SVG]; + let color = def.color; + let kind = ''; + let opacity = 1; + if (def.group === 'Vehicles') { + svg = INTEL_SVG[VEHICLE_ICON[p.t ?? ''] ?? 'car']; + // Fainter = less likely to hold a vehicle this match. + if (p.p !== undefined) opacity = 0.45 + 0.55 * Math.min(1, p.p * 1.5); + if (p.p !== undefined) kind = `~${Math.round(p.p * 100)}% per match`; + } else if (def.id === 'drops') { + color = DROP_COLOR[p.t ?? ''] ?? def.color; + kind = DROP_LABEL[p.t ?? ''] ?? ''; + } + L.marker(ll(p.x, p.y), { + pane: 'pm-intel', + icon: intelIcon( + svg, + color, + [p.a && 'approx', def.id === 'towns' && 'town', KEEP_ICON.has(def.id) && 'keep'] + .filter(Boolean) + .join(' '), + opacity + ), + keyboard: false, + bubblingMouseEvents: true + }) + .bindTooltip(intelTip(p, kind), tip) + .addTo(intelLayer); + } + } + } + + // The intel credit shows while the current map has intel. + function syncCredit() { + if (!attrib || !!intel === creditShown) return; + creditShown = !!intel; + if (creditShown) attrib.addAttribution(INTEL_CREDIT); + else attrib.removeAttribution(INTEL_CREDIT); + } + + function setLayer(id: IntelLayerId, on: boolean) { + layerOn[id] = on; + try { + localStorage.setItem(INTEL_KEY, JSON.stringify(layerOn)); + } catch { + // private mode: the choice lasts this tab only + } + } + let tilesSlug = ''; function setMap(slug: string, fit: boolean) { if (!map) return; @@ -640,11 +774,17 @@ tempLayer?.clearLayers(); note = null; picker = false; + layersOpen = false; confirmClear = false; } else if (k === 'g') showGrid = !showGrid; else if (k === 'o') showPast = !showPast; - else if (k === 't') drawer = !drawer; - else if (k === 'm') picker = !picker; + else if (k === 't') { + drawer = !drawer; + if (drawer) layersOpen = false; + } else if (k === 'k') { + layersOpen = !layersOpen; + if (layersOpen) drawer = false; + } else if (k === 'm') picker = !picker; else if (k === 'f') toggleFullscreen(); else if (k === ' ' && hover) { e.preventDefault(); @@ -657,6 +797,23 @@ void current; drawGrid(); }); + // Intel for the current map: fetched once per map, then redrawn when layers toggle. + $effect(() => { + const slug = current.slug; + intel = undefined; + loadIntel(slug).then((d) => { + if (current.slug === slug) intel = d; + }); + }); + $effect(() => { + void intel; + for (const l of INTEL_LAYERS) void layerOn[l.id]; + drawIntel(); + }); + $effect(() => { + void intel; + syncCredit(); + }); $effect(() => { void showPast; void focusMatch; @@ -681,6 +838,11 @@ // no saved nickname } if (!me) openName(); + try { + layerOn = readLayerPrefs(localStorage.getItem(INTEL_KEY)); + } catch { + // storage blocked: defaults + } import('leaflet').then((mod) => { if (disposed) return; L = (mod as unknown as { default: typeof Leaflet }).default ?? mod; @@ -697,7 +859,14 @@ maxBoundsViscosity: 0.8, attributionControl: false }); - L.control.attribution({ prefix: false, position: 'bottomleft' }).addTo(map); + attrib = L.control.attribution({ prefix: false, position: 'bottomleft' }).addTo(map); + creditShown = false; + syncCredit(); + // Intel sits under everything players place: areas above the tiles, points under + // past landings (overlayPane 400) and room markers (markerPane 600). + map.createPane('pm-area').style.zIndex = '330'; + map.createPane('pm-intel').style.zIndex = '380'; + intelLayer = L.layerGroup().addTo(map); L.control.zoom({ position: 'bottomright' }).addTo(map); gridLayer = L.layerGroup().addTo(map); pastLayer = L.layerGroup().addTo(map); @@ -710,7 +879,12 @@ map.on('mousemove', (e: Leaflet.LeafletMouseEvent) => (hover = xy(e.latlng))); map.on('mouseout', () => (hover = null)); map.on('movestart', () => (note = null)); + // Dots below ~1.5 zoom steps past "whole map fits"; full icons once zoomed in. + const farBelow = map.getZoom() + 1.5; + far = true; + map.on('zoomend', () => (far = (map?.getZoom() ?? 0) < farBelow)); drawGrid(); + drawIntel(); connect(); }); const onFs = () => { @@ -726,6 +900,8 @@ map?.remove(); map = undefined; tiles = undefined; + intelLayer = undefined; + attrib = undefined; tilesSlug = ''; }; } @@ -733,7 +909,7 @@ -
+
@@ -779,9 +955,20 @@ onclick={() => (showPast = !showPast)} title="Past landings on this map (O)"> +
{/if} + {#if layersOpen} + + {/if} + {#if drawer}