pubg map: built-in intel layers (vehicle spots, drops, loot density, Blue Chip towers, secret rooms) per map
All checks were successful
Deploy production / deploy (push) Successful in 1m12s

Read-only layers from static/pubg-intel/<map>.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.
This commit is contained in:
2026-10-26 00:05:46 +00:00
parent b1edf7761c
commit be3473caa0
15 changed files with 1057 additions and 5 deletions

View File

@@ -25,3 +25,6 @@ static/vendor/
# Generated drizzle snapshots
drizzle/*/snapshot.json
# Generated PUBG map intel (scripts/build-pubg-intel.py)
static/pubg-intel/

339
scripts/build-pubg-intel.py Normal file
View File

@@ -0,0 +1,339 @@
#!/usr/bin/env python3
"""Build the PUBG drop map's built-in intel layers (static/pubg-intel/<slug>.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-<slug>.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)

View File

@@ -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: <a href="https://pubg.gs/" target="_blank" rel="noreferrer">pubg.gs</a> 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<IntelDoc | null | undefined>(undefined);
let picker = $state(false);
let fullscreen = $state(false);
let toast = $state<string | null>(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 = [`<b>${esc(p.n)}</b>`];
if (kind) parts.push(` <span class="pm-dim">${esc(kind)}</span>`);
if (p.d) parts.push(`<br>${esc(p.d)}`);
if (src)
parts.push(
`<br><span class="pm-dim">Source: ${esc(src.name)}${p.a ? ' · approximate spot' : ''}</span>`
);
return parts.join('');
}
function intelIcon(svg: string, color: string, cls = '', opacity = 1) {
return L.divIcon({
className: 'pm-icon',
html: `<div class="pm-ii ${cls}" style="--c:${color};opacity:${opacity}">${svg}</div>`,
iconSize: [18, 18],
iconAnchor: [9, 9]
});
}
// Few and important: these keep their full icon when zoomed out.
const KEEP_ICON = new Set<IntelLayerId>(['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 @@
<svelte:window onkeydown={onKey} />
<div class={['pm', tool && 'picking']}>
<div class={['pm', tool && 'picking', far && 'far']}>
<div class="pm-map" {@attach attachMap}></div>
<!-- Top bar: room, match, map, connection; players and actions on the right. -->
@@ -779,9 +955,20 @@
onclick={() => (showPast = !showPast)}
title="Past landings on this map (O)"><Footprints size={16} /></button
>
<button
class={['btn', layersOpen && 'on']}
onclick={() => {
layersOpen = !layersOpen;
if (layersOpen) drawer = false;
}}
title="Map layers: vehicles, drops, loot, secrets (K)"><Layers size={16} /></button
>
<button
class={['btn', drawer && 'on']}
onclick={() => (drawer = !drawer)}
onclick={() => {
drawer = !drawer;
if (drawer) layersOpen = false;
}}
title="Match history (T)"><History size={16} /></button
>
<button class="btn" onclick={toggleFullscreen} title="Fullscreen (F)">
@@ -909,6 +1096,90 @@
</div>
{/if}
{#if layersOpen}
<aside class="drawer layers" aria-label="Map layers">
<div class="picker-head">
<span>{current.name} layers</span><button
class="btn"
onclick={() => (layersOpen = false)}
aria-label="Close"><X size={15} /></button
>
</div>
<p class="small top">Only on your screen; your friends pick their own.</p>
{#each INTEL_GROUPS as group (group)}
{@const defs = INTEL_LAYERS.filter((l) => l.group === group)}
<section>
<h3>{group}</h3>
{#each defs as def (def.id)}
{@const n = intel?.layers[def.id]?.length ?? 0}
<label class={['layer', !n && 'none']} title={def.hint}>
<input
type="checkbox"
checked={layerOn[def.id]}
disabled={!n}
onchange={(e) => {
setLayer(def.id, e.currentTarget.checked);
// Hand the keyboard shortcuts back to the map.
e.currentTarget.blur();
}}
/>
<span class="ic" style:--c={def.color}>
<!-- eslint-disable-next-line svelte/no-at-html-tags -- static Lucide icon nodes from intel.ts -->
{@html INTEL_SVG[def.icon as keyof typeof INTEL_SVG]}
</span>
<span class="name">{def.label}</span>
<span class="n">{intel ? n || 'none' : intel === null ? '' : '…'}</span>
</label>
{/each}
</section>
{/each}
{#if layerOn.drops && intel?.layers.drops?.length}
<p class="legend">
{#each Object.entries(DROP_COLOR) as [tier, c] (tier)}
<span style:--c={c}>{DROP_LABEL[tier]}</span>
{/each}
</p>
{/if}
{#if intel?.notes.length}
<section>
<h3>Notes</h3>
{#each intel.notes as nt, i (i)}
<p class="note-item">
<b>{nt.title}.</b>
{nt.text}
{#if intel.sources[nt.s]}<a
href={intel.sources[nt.s].url}
target="_blank"
rel="noreferrer">{intel.sources[nt.s].name}</a
>{/if}
</p>
{/each}
</section>
{/if}
<p class="small">
Dashed rings are approximate: drawn on the named place, not the exact spot. Faint vehicle
icons rarely hold a vehicle.
</p>
{#if intel}
<section>
<h3>Sources</h3>
<ul class="sources">
{#each Object.entries(intel.sources) as [k, src] (k)}
<li>
<a href={src.url} target="_blank" rel="noreferrer">{src.name}</a>{#if src.note}
<span class="pm-dim"> · {src.note}</span>{/if}
</li>
{/each}
</ul>
</section>
{:else if intel === null}
<p class="small">No built-in intel for this map.</p>
{:else}
<p class="small">Loading map intel…</p>
{/if}
</aside>
{/if}
{#if drawer}
<aside class="drawer" aria-label="Match history">
<div class="picker-head">
@@ -1328,6 +1599,74 @@
width: 300px;
overflow: auto;
}
.layers h3 {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--muted);
margin: 10px 0 4px;
}
.layers .top {
margin-top: 0;
}
.layer {
display: flex;
align-items: center;
gap: 8px;
padding: 3px 4px;
border-radius: 7px;
cursor: pointer;
}
.layer:hover {
background: rgb(255 255 255 / 0.06);
}
.layer.none {
opacity: 0.45;
cursor: default;
}
.layer input {
accent-color: #fafafa;
}
.layer .ic {
color: var(--c);
display: grid;
}
.layer .name {
flex: 1;
}
.layer .n {
color: var(--muted);
font-size: 11.5px;
font-variant-numeric: tabular-nums;
}
.legend {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-top: 6px;
}
.legend span {
font-size: 11px;
padding: 1px 7px;
border-radius: 999px;
border: 1px solid var(--c);
color: var(--c);
}
.note-item {
font-size: 12px;
line-height: 1.45;
margin-bottom: 6px;
}
.layers a {
color: var(--text);
text-decoration: underline;
text-decoration-color: rgb(255 255 255 / 0.35);
}
.layers .sources {
gap: 3px;
font-size: 11.5px;
}
.drawer ul {
display: flex;
flex-direction: column;
@@ -1518,6 +1857,31 @@
.pm :global(.pm-tip::before) {
display: none;
}
.pm :global(.pm-ii) {
width: 100%;
height: 100%;
display: grid;
place-items: center;
border-radius: 50%;
background: rgb(10 10 10 / 0.78);
color: var(--c);
border: 1px solid color-mix(in oklab, var(--c) 65%, transparent);
}
.pm :global(.pm-ii.town) {
border-radius: 5px;
}
.pm.far :global(.pm-ii:not(.keep)) {
transform: scale(0.42);
background: var(--c);
border: 2px solid rgb(0 0 0 / 0.6);
}
.pm.far :global(.pm-ii:not(.keep) svg) {
display: none;
}
.pm :global(.pm-ii.approx) {
border-style: dashed;
border-width: 1.5px;
}
.pm :global(.pm-dim) {
color: var(--muted);
}

View File

@@ -0,0 +1,40 @@
import { readFileSync, readdirSync } from 'node:fs';
import { describe, expect, it } from 'vitest';
import { INTEL_LAYERS, INTEL_MAPS, readLayerPrefs, type IntelDoc } from './intel';
import { MAPS } from './shared';
describe('pubg map intel', () => {
it('defaults to places and vehicles, and keeps saved choices', () => {
const d = readLayerPrefs(null);
expect(d.towns && d.cars && d.boats).toBe(true);
expect(d.drops || d.loot || d.secret).toBe(false);
const saved = readLayerPrefs(JSON.stringify({ cars: false, drops: true, bogus: true }));
expect(saved.cars).toBe(false);
expect(saved.drops).toBe(true);
expect(readLayerPrefs('{not json')).toEqual(d);
});
it('ships sourced, in-bounds intel for every map', () => {
const dir = 'static/pubg-intel';
const files = readdirSync(dir);
const ids = new Set<string>(INTEL_LAYERS.map((l) => l.id));
expect(files.sort()).toEqual([...INTEL_MAPS].map((s) => `${s}.v1.json`).sort());
const slugs = new Set(MAPS.map((m) => m.slug));
for (const f of files) {
const doc: IntelDoc = JSON.parse(readFileSync(`${dir}/${f}`, 'utf8'));
expect(f).toBe(`${doc.map}.v1.json`);
expect(slugs.has(doc.map)).toBe(true);
for (const [id, pts] of Object.entries(doc.layers)) {
expect(ids.has(id)).toBe(true);
for (const p of pts ?? []) {
expect(p.x).toBeGreaterThan(0);
expect(p.x).toBeLessThan(1);
expect(p.y).toBeGreaterThan(0);
expect(p.y).toBeLessThan(1);
expect(doc.sources[p.s]?.url).toMatch(/^https:\/\//);
}
}
for (const n of doc.notes) expect(doc.sources[n.s]).toBeTruthy();
}
});
});

296
src/lib/pubg-map/intel.ts Normal file
View File

@@ -0,0 +1,296 @@
// Built-in, read-only intel layers for the PUBG drop map: towns, vehicle spots, drops,
// loot and secret rooms per map. Static JSON (static/pubg-intel/<slug>.v1.json, built by
// scripts/build-pubg-intel.py); nothing here is stored per room or sent over the socket.
export const INTEL_VERSION = 1;
export const INTEL_KEY = 'pubg-map:layers';
export type IntelPoint = {
x: number;
y: number;
/** Name shown first in the tooltip. */
n: string;
/** Note. */
d?: string;
/** Sub-type (vehicle type, drop tier). */
t?: string;
/** Source id (key into IntelDoc.sources). */
s: string;
/** 1 = approximate: drawn on the named place, not the exact spot. */
a?: 1;
/** Radius in map units (0..1), for rings and loot cells. */
r?: number;
/** Weight 0..1 (loot cells) or a count (towns). */
w?: number;
/** Spawn chance 0..1 (vehicle spots). */
p?: number;
};
export type IntelSource = { name: string; url: string; note?: string };
export type IntelDoc = {
v: number;
map: string;
built: string;
sources: Record<string, IntelSource>;
notes: { title: string; text: string; s: string }[];
layers: Partial<Record<IntelLayerId, IntelPoint[]>>;
};
export const INTEL_GROUPS = ['Places', 'Vehicles', 'Drops', 'Loot', 'Secrets'] as const;
export type IntelGroup = (typeof INTEL_GROUPS)[number];
export type IntelLayerId =
| 'towns'
| 'bridges'
| 'cars'
| 'garages'
| 'boats'
| 'gliders'
| 'special'
| 'drops'
| 'loot'
| 'towers'
| 'vending'
| 'bears'
| 'secret'
| 'keys';
export type IntelLayer = {
id: IntelLayerId;
label: string;
group: IntelGroup;
color: string;
/** On for a player who never touched the layer panel. */
on: boolean;
icon: string;
hint: string;
};
// Lucide icon nodes (ISC licence, copied from lucide-svelte) plus a hand-drawn bridge,
// inlined because Leaflet divIcons are plain HTML.
const svg = (body: string, size = 12) =>
`<svg viewBox="0 0 24 24" width="${size}" height="${size}" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${body}</svg>`;
const P = {
town: '<path d="M10 12h4"/><path d="M10 8h4"/><path d="M14 21v-3a2 2 0 0 0-4 0v3"/><path d="M6 10H4a2 2 0 0 0-2 2v7a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-2"/><path d="M6 21V5a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v16"/>',
bridge:
'<path d="M2 12h20"/><path d="M4 12V8"/><path d="M20 12V8"/><path d="M4 8q8 6 16 0"/><path d="M8 12v4"/><path d="M16 12v4"/>',
car: '<path d="M19 17h2c.6 0 1-.4 1-1v-3c0-.9-.7-1.7-1.5-1.9C18.7 10.6 16 10 16 10s-1.3-1.4-2.2-2.3c-.5-.4-1.1-.7-1.8-.7H5c-.6 0-1.1.4-1.4.9l-1.4 2.9A3.7 3.7 0 0 0 2 12v4c0 .6.4 1 1 1h2"/><circle cx="7" cy="17" r="2"/><path d="M9 17h6"/><circle cx="17" cy="17" r="2"/>',
garage:
'<path d="M18 21V10a1 1 0 0 0-1-1H7a1 1 0 0 0-1 1v11"/><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V8a2 2 0 0 1 1.132-1.803l7.95-3.974a2 2 0 0 1 1.837 0l7.948 3.974A2 2 0 0 1 22 8z"/><path d="M6 13h12"/><path d="M6 17h12"/>',
boat: '<path d="M12 10.189V14"/><path d="M12 2v3"/><path d="M19 13V7a2 2 0 0 0-2-2H7a2 2 0 0 0-2 2v6"/><path d="M19.38 20A11.6 11.6 0 0 0 21 14l-8.188-3.639a2 2 0 0 0-1.624 0L3 14a11.6 11.6 0 0 0 2.81 7.76"/><path d="M2 21c.6.5 1.2 1 2.5 1 2.5 0 2.5-2 5-2 1.3 0 1.9.5 2.5 1s1.2 1 2.5 1c2.5 0 2.5-2 5-2 1.3 0 1.9.5 2.5 1"/>',
glider:
'<path d="M17.8 19.2 16 11l3.5-3.5C21 6 21.5 4 21 3c-1-.5-3 0-4.5 1.5L13 8 4.8 6.2c-.5-.1-.9.1-1.1.5l-.3.5c-.2.5-.1 1 .3 1.3L9 12l-2 3H4l-1 1 3 2 2 3 1-1v-3l3-2 3.5 5.3c.3.4.8.5 1.3.3l.5-.2c.4-.3.6-.7.5-1.2z"/>',
crown:
'<path d="M11.562 3.266a.5.5 0 0 1 .876 0L15.39 8.87a1 1 0 0 0 1.516.294L21.183 5.5a.5.5 0 0 1 .798.519l-2.834 10.246a1 1 0 0 1-.956.734H5.81a1 1 0 0 1-.957-.734L2.02 6.02a.5.5 0 0 1 .798-.519l4.276 3.664a1 1 0 0 0 1.516-.294z"/><path d="M5 21h14"/>',
flame:
'<path d="M12 3q1 4 4 6.5t3 5.5a1 1 0 0 1-14 0 5 5 0 0 1 1-3 1 1 0 0 0 5 0c0-2-1.5-3-1.5-5q0-2 2.5-4"/>',
loot: '<path d="M11 21.73a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73z"/><path d="M12 22V12"/><polyline points="3.29 7 12 12 20.71 7"/><path d="m7.5 4.27 9 5.15"/>',
tower:
'<path d="M4.9 16.1C1 12.2 1 5.8 4.9 1.9"/><path d="M7.8 4.7a6.14 6.14 0 0 0-.8 7.5"/><circle cx="12" cy="9" r="2"/><path d="M16.2 4.8c2 2 2.26 5.11.8 7.47"/><path d="M19.1 1.9a9.96 9.96 0 0 1 0 14.1"/><path d="M9.5 18h5"/><path d="m8 22 4-11 4 11"/>',
vending:
'<path d="M5 6a4 4 0 0 1 4-4h6a4 4 0 0 1 4 4v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6Z"/><path d="M5 10h14"/><path d="M15 7v6"/>',
bear: '<circle cx="11" cy="4" r="2"/><circle cx="18" cy="8" r="2"/><circle cx="20" cy="16" r="2"/><path d="M9 10a5 5 0 0 1 5 5v3.5a3.5 3.5 0 0 1-6.84 1.045Q6.52 17.48 4.46 16.84A3.5 3.5 0 0 1 5.5 10Z"/>',
lock: '<rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>',
key: '<path d="M2.586 17.414A2 2 0 0 0 2 18.828V21a1 1 0 0 0 1 1h3a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1h1a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1h.172a2 2 0 0 0 1.414-.586l.814-.814a6.5 6.5 0 1 0-4-4z"/><circle cx="16.5" cy="7.5" r=".5" fill="currentColor"/>'
};
export const INTEL_SVG = Object.fromEntries(
Object.entries(P).map(([k, v]) => [k, svg(v)])
) as Record<keyof typeof P, string>;
export const INTEL_LAYERS: IntelLayer[] = [
{
id: 'towns',
label: 'Named places',
group: 'Places',
color: '#e4e4e7',
on: true,
icon: 'town',
hint: 'towns and landmarks, with how many item spots are inside'
},
{
id: 'bridges',
label: 'Bridges',
group: 'Places',
color: '#94a3b8',
on: false,
icon: 'bridge',
hint: 'bridge points from the game files'
},
{
id: 'cars',
label: 'Car spots',
group: 'Vehicles',
color: '#c084fc',
on: true,
icon: 'car',
hint: 'road and off-road spots; the chance is per spot per match'
},
{
id: 'garages',
label: 'Garages',
group: 'Vehicles',
color: '#a855f7',
on: true,
icon: 'garage',
hint: 'garage and police-station spots (high chance)'
},
{
id: 'boats',
label: 'Boats',
group: 'Vehicles',
color: '#38bdf8',
on: true,
icon: 'boat',
hint: 'boat, Aquarail and Airboat spots'
},
{
id: 'gliders',
label: 'Motor gliders',
group: 'Vehicles',
color: '#f0f9ff',
on: true,
icon: 'glider',
hint: 'motor glider spots'
},
{
id: 'special',
label: 'Special vehicles',
group: 'Vehicles',
color: '#facc15',
on: true,
icon: 'crown',
hint: 'fixed rare spawns such as the Gold Mirado'
},
{
id: 'drops',
label: 'Hot and quiet drops',
group: 'Drops',
color: '#f97316',
on: false,
icon: 'flame',
hint: 'from drop guides: red hot, amber balanced, green quiet'
},
{
id: 'loot',
label: 'Loot density',
group: 'Loot',
color: '#4ade80',
on: false,
icon: 'loot',
hint: 'the squares with the most item spots (game files, aggregated)'
},
{
id: 'towers',
label: 'Blue Chip towers',
group: 'Loot',
color: '#60a5fa',
on: false,
icon: 'tower',
hint: 'Blue Chip transmitter spots'
},
{
id: 'vending',
label: 'Vending machines',
group: 'Loot',
color: '#f472b6',
on: false,
icon: 'vending',
hint: 'vending machine spots'
},
{
id: 'bears',
label: 'Bear caves',
group: 'Loot',
color: '#f8fafc',
on: false,
icon: 'bear',
hint: 'polar bear spawns (bear caves, airdrop-tier loot)'
},
{
id: 'secret',
label: 'Secret rooms',
group: 'Secrets',
color: '#fb923c',
on: false,
icon: 'lock',
hint: 'approximate: drawn around the named place'
},
{
id: 'keys',
label: 'Security keys',
group: 'Secrets',
color: '#fde047',
on: false,
icon: 'key',
hint: 'approximate: drawn around the named place'
}
];
export const INTEL_BY_ID = Object.fromEntries(INTEL_LAYERS.map((l) => [l.id, l])) as Record<
IntelLayerId,
IntelLayer
>;
export const VEHICLE_ICON: Record<string, keyof typeof P> = {
car: 'car',
garage: 'garage',
boat: 'boat',
glider: 'glider',
special: 'crown'
};
export const DROP_COLOR: Record<string, string> = {
hot: '#ef4444',
busy: '#f97316',
balanced: '#facc15',
quiet: '#4ade80'
};
export const DROP_LABEL: Record<string, string> = {
hot: 'Hot drop',
busy: 'Busy drop',
balanced: 'Balanced drop',
quiet: 'Quiet drop'
};
/** The player's on/off choices merged over the defaults. */
export function readLayerPrefs(raw: string | null): Record<IntelLayerId, boolean> {
const out = Object.fromEntries(INTEL_LAYERS.map((l) => [l.id, l.on])) as Record<
IntelLayerId,
boolean
>;
try {
const saved = JSON.parse(raw ?? 'null');
if (saved && typeof saved === 'object')
for (const l of INTEL_LAYERS) if (typeof saved[l.id] === 'boolean') out[l.id] = saved[l.id];
} catch {
// keep the defaults
}
return out;
}
/** Maps with a static/pubg-intel file (Camp Jackal, the training map, has none). */
export const INTEL_MAPS = new Set([
'erangel',
'miramar',
'sanhok',
'vikendi',
'taego',
'deston',
'rondo',
'karakin',
'paramo',
'haven'
]);
const docs = new Map<string, Promise<IntelDoc | null>>();
/** Fetches a map's intel once per page load; null when the map has none. */
export function loadIntel(slug: string): Promise<IntelDoc | null> {
if (!INTEL_MAPS.has(slug)) return Promise.resolve(null);
let p = docs.get(slug);
if (!p) {
p = fetch(`/pubg-intel/${slug}.v${INTEL_VERSION}.json`)
.then((r) => (r.ok ? (r.json() as Promise<IntelDoc>) : null))
.catch(() => null);
p.then((d) => d || docs.delete(slug));
docs.set(slug, p);
}
return p;
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
{"v":1,"map":"haven","built":"2026-10-25","sources":{"gs":{"name":"pubg.gs","url":"https://pubg.gs/maps/haven/","note":"positions read from the game level files"}},"notes":[],"layers":{"towns":[{"x":0.78078,"y":0.46118,"n":"Coal Yards","r":0.09804,"d":"68 item spots within 100 m","w":68,"s":"gs"},{"x":0.17461,"y":0.64304,"n":"Docks","r":0.09804,"d":"172 item spots within 100 m","w":172,"s":"gs"},{"x":0.31618,"y":0.16647,"n":"Industrial Park","r":0.19608,"d":"815 item spots within 200 m","w":815,"s":"gs"},{"x":0.32137,"y":0.42069,"n":"Rail Yard","r":0.09804,"d":"153 item spots within 100 m","w":153,"s":"gs"},{"x":0.6502,"y":0.80637,"n":"Residential","r":0.19608,"d":"3405 item spots within 200 m","w":3405,"s":"gs"},{"x":0.41382,"y":0.55804,"n":"Steel Mill","r":0.10784,"d":"265 item spots within 110 m","w":265,"s":"gs"}],"vending":[{"x":0.26047,"y":0.79107,"n":"Vending machine","s":"gs"},{"x":0.13654,"y":0.57338,"n":"Vending machine","s":"gs"},{"x":0.11726,"y":0.3686,"n":"Vending machine","s":"gs"},{"x":0.20017,"y":0.08545,"n":"Vending machine","s":"gs"},{"x":0.12396,"y":0.31752,"n":"Vending machine","s":"gs"},{"x":0.19537,"y":0.25512,"n":"Vending machine","s":"gs"},{"x":0.58327,"y":0.69538,"n":"Vending machine","s":"gs"},{"x":0.54638,"y":0.7088,"n":"Vending machine","s":"gs"},{"x":0.59464,"y":0.72995,"n":"Vending machine","s":"gs"},{"x":0.62364,"y":0.70636,"n":"Vending machine","s":"gs"},{"x":0.27932,"y":0.16221,"n":"Vending machine","s":"gs"},{"x":0.3146,"y":0.30975,"n":"Vending machine","s":"gs"},{"x":0.22901,"y":0.58969,"n":"Vending machine","s":"gs"},{"x":0.35425,"y":0.68822,"n":"Vending machine","s":"gs"},{"x":0.4989,"y":0.57154,"n":"Vending machine","s":"gs"},{"x":0.25691,"y":0.81252,"n":"Vending machine","s":"gs"},{"x":0.47615,"y":0.85119,"n":"Vending machine","s":"gs"},{"x":0.51154,"y":0.7871,"n":"Vending machine","s":"gs"},{"x":0.5421,"y":0.75803,"n":"Vending machine","s":"gs"}],"loot":[{"x":0.63725,"y":0.73529,"r":0.04902,"w":1.0,"n":"537 item spots in this 100 m square","s":"gs"},{"x":0.73529,"y":0.73529,"r":0.04902,"w":0.91,"n":"491 item spots in this 100 m square","s":"gs"},{"x":0.53922,"y":0.73529,"r":0.04902,"w":0.66,"n":"355 item spots in this 100 m square","s":"gs"},{"x":0.44118,"y":0.83333,"r":0.04902,"w":0.65,"n":"348 item spots in this 100 m square","s":"gs"},{"x":0.53922,"y":0.83333,"r":0.04902,"w":0.49,"n":"264 item spots in this 100 m square","s":"gs"},{"x":0.63725,"y":0.83333,"r":0.04902,"w":0.43,"n":"233 item spots in this 100 m square","s":"gs"},{"x":0.73529,"y":0.83333,"r":0.04902,"w":0.42,"n":"227 item spots in this 100 m square","s":"gs"},{"x":0.14706,"y":0.2451,"r":0.04902,"w":0.35,"n":"190 item spots in this 100 m square","s":"gs"},{"x":0.53922,"y":0.93137,"r":0.04902,"w":0.34,"n":"183 item spots in this 100 m square","s":"gs"},{"x":0.83333,"y":0.73529,"r":0.04902,"w":0.3,"n":"159 item spots in this 100 m square","s":"gs"}]}}

View File

@@ -0,0 +1 @@
{"v":1,"map":"karakin","built":"2026-10-25","sources":{"gs":{"name":"pubg.gs","url":"https://pubg.gs/maps/karakin/","note":"positions read from the game level files"}},"notes":[],"layers":{"towns":[{"x":0.72493,"y":0.21809,"n":"Al Habar","r":0.08333,"d":"1052 item spots within 170 m","w":1052,"s":"gs"},{"x":0.4824,"y":0.82735,"n":"Al Hayik","r":0.04902,"d":"214 item spots within 100 m","w":214,"s":"gs"},{"x":0.3226,"y":0.25147,"n":"Bahr Sahir","r":0.07353,"d":"466 item spots within 150 m","w":466,"s":"gs"},{"x":0.19172,"y":0.58152,"n":"Bashara","r":0.07353,"d":"546 item spots within 150 m","w":546,"s":"gs"},{"x":0.72669,"y":0.61346,"n":"Hadiqa Nemo","r":0.05882,"d":"488 item spots within 120 m","w":488,"s":"gs"}],"towers":[{"x":0.54683,"y":0.70376,"n":"Blue Chip tower","d":"Blue Chip Transmitter spot","s":"gs"},{"x":0.34238,"y":0.72275,"n":"Blue Chip tower","d":"Blue Chip Transmitter spot","s":"gs"},{"x":0.27892,"y":0.48781,"n":"Blue Chip tower","d":"Blue Chip Transmitter spot","s":"gs"},{"x":0.463,"y":0.36379,"n":"Blue Chip tower","d":"Blue Chip Transmitter spot","s":"gs"},{"x":0.65896,"y":0.32989,"n":"Blue Chip tower","d":"Blue Chip Transmitter spot","s":"gs"},{"x":0.64384,"y":0.53326,"n":"Blue Chip tower","d":"Blue Chip Transmitter spot","s":"gs"}],"loot":[{"x":0.71078,"y":0.61275,"r":0.02451,"w":1.0,"n":"395 item spots in this 100 m square","s":"gs"},{"x":0.17157,"y":0.56373,"r":0.02451,"w":0.89,"n":"352 item spots in this 100 m square","s":"gs"},{"x":0.7598,"y":0.22059,"r":0.02451,"w":0.66,"n":"260 item spots in this 100 m square","s":"gs"},{"x":0.7598,"y":0.17157,"r":0.02451,"w":0.57,"n":"227 item spots in this 100 m square","s":"gs"},{"x":0.71078,"y":0.22059,"r":0.02451,"w":0.55,"n":"217 item spots in this 100 m square","s":"gs"},{"x":0.71078,"y":0.26961,"r":0.02451,"w":0.38,"n":"150 item spots in this 100 m square","s":"gs"},{"x":0.71078,"y":0.17157,"r":0.02451,"w":0.36,"n":"142 item spots in this 100 m square","s":"gs"},{"x":0.26961,"y":0.26961,"r":0.02451,"w":0.33,"n":"132 item spots in this 100 m square","s":"gs"},{"x":0.31863,"y":0.22059,"r":0.02451,"w":0.33,"n":"131 item spots in this 100 m square","s":"gs"},{"x":0.56373,"y":0.46569,"r":0.02451,"w":0.28,"n":"110 item spots in this 100 m square","s":"gs"},{"x":0.56373,"y":0.71078,"r":0.02451,"w":0.26,"n":"102 item spots in this 100 m square","s":"gs"},{"x":0.31863,"y":0.26961,"r":0.02451,"w":0.26,"n":"101 item spots in this 100 m square","s":"gs"},{"x":0.22059,"y":0.61275,"r":0.02451,"w":0.24,"n":"93 item spots in this 100 m square","s":"gs"},{"x":0.46569,"y":0.80882,"r":0.02451,"w":0.23,"n":"91 item spots in this 100 m square","s":"gs"}]}}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long