#!/usr/bin/env python3 """Build image-space map markers and assemble the three SV in-game maps. Requires Pillow and beautifulsoup4. Cached source pages/tiles: /tmp/game-map-art. The map artwork is from the games; source image-map coordinates are adapted from Serebii with attribution. Hand-placed landmarks are explicitly approximate. """ from pathlib import Path import argparse import json import re import urllib.request from PIL import Image from bs4 import BeautifulSoup ROOT=Path(__file__).resolve().parents[1] parser=argparse.ArgumentParser(description=__doc__) parser.add_argument('--cache',type=Path,default=Path('/tmp/game-map-art')) args=parser.parse_args();args.cache.mkdir(parents=True,exist_ok=True) def fetch(url,name): p=args.cache/name if not p.exists():p.write_bytes(urllib.request.urlopen(url,timeout=45).read()) return p def slug(s):return re.sub(r'[^a-z0-9]+','-',s.lower().replace('é','e')).strip('-') original=json.loads((ROOT/'data/pokemon-atlas.json').read_text())['regions'] locations=json.loads((ROOT/'data/pokemon-locations.json').read_text())['locations'] def node(name,x,y,region): key=slug(name);aliases={'mount-coronet':'mt-coronet','pokemon-league':'sinnoh-pokemon-league','lake-of-rage':'lake-of-rage','mt-battle':'mt-battle','pokemon-hq-lab':'pokemon-hq-lab'} key=aliases.get(key,key) found=next((p for p in locations if slug(p['name'])==slug(name) and (p['region'] or '').lower()==region),None) if not found:found=next((p for p in locations if p['slug'] in [key,region+'-'+key] and (p['region'] or '').lower()==region),None) return dict(slug=found['slug'] if found else key,name=found['name'] if found else name,x=round(x,3),y=round(y,3),kind='town' if re.search(r'Town|City|Village|Area|League',name) else 'route' if 'Route' in name else 'landmark',cells=[]) maps={} for region in original: p=ROOT/f'static/game-art/maps/{region}.webp' if region in ['paldea','kitakami','blueberry']: remote='terarium' if region=='blueberry' else region canvas=Image.new('RGB',(2048,2048)) for x in range(8): for y in range(8): tile=fetch(f'https://www.serebii.net/pokearth/{remote}/map/tile_2-{x}-{y}.png',f'{remote}-tile-{x}-{y}.png') canvas.paste(Image.open(tile),(x*256,y*256)) canvas.save(p,format='WEBP',quality=90,method=6) im=Image.open(p);w,h=im.size source='https://www.serebii.net/pokearth/'+region+'/' if region=='blueberry':source='https://www.serebii.net/pokearth/terarium/' if region=='orre':source='https://pokemonrevolution.net/forum/topic/147410-orre-region/' nodes=[] if region in ['sinnoh','hoenn','kanto','johto','unova','kalos']: if region in ['kalos','unova']:page=fetch('https://www.serebii.net/pokearth/','pokearth.html') else:page=fetch(source,region+'.html') soup=BeautifulSoup(page.read_bytes(),'html.parser') area_map=soup.find('map',attrs={'name':re.compile('^'+region+'$',re.I)}) if area_map: for a in area_map.find_all('area'): values=list(map(float,re.findall(r'[\d.]+',a.get('coords','')))) if len(values)<4:continue xs=values[::2];ys=values[1::2] # Bounding-box centre remains inside rectangular routes and town markers. x=(min(xs)+max(xs))/2;y=(min(ys)+max(ys))/2 name=a.get('title','').strip() if name and 0<=x<=w and 0<=y<=h:nodes.append(node(name,x,y,region)) nodes=list({n['slug']:n for n in nodes}.values()) maps[region]=dict(name=original[region]['name'],image=f'/game-art/maps/{region}.webp',width=w,height=h,nodes=nodes,source=source,credit='Nintendo / The Pokémon Company; map imagery and location coordinates via Serebii' if region!='orre' else 'Nintendo / Genius Sonority; Pokémon XD screenshot via Pokémon Revolution forum',precision='Regional map · location markers aligned to the source image' if nodes else 'Game map · approximate landmark markers',pixelated=w<500) # Reviewed landmark centres as percentages of the image, not the old schematic. manual={ 'paldea':'Cabo Poco,52,93;Los Platos,51,85;Mesagoza,49,78;Cortondo,31,77;Artazon,69,78;Levincia,83,58;Cascarrafa,30,58;Porto Marinada,14,48;Medali,35,40;Montenevera,51,27;Alfornada,15,89;Glaseado Mountain,55,31;Casseroya Lake,25,28;Area Zero,48,61;Pokemon League,45,72', 'kitakami':'Mossui Town,56,77;Loyalty Plaza,43,80;Apple Hills,38,75;Kitakami Hall,67,58;Oni Mountain,49,49;Crystal Pool,48,50;Timeless Woods,75,28;Fellhorn Gorge,81,46;Paradise Barrens,27,29;Revelers Road,64,70', 'blueberry':'Central Plaza,50,50;Savanna Biome,72,70;Coastal Biome,72,30;Canyon Biome,30,70;Polar Biome,30,30', 'hisui':'Jubilife Village,31,55;Obsidian Fieldlands,27,70;Crimson Mirelands,70,68;Cobalt Coastlands,86,35;Coronet Highlands,52,43;Alabaster Icelands,37.5,18;Temple of Sinnoh,36,10;Prelude Beach,26,61', 'galar':'Postwick,27.5,68.5;Wedgehurst,27.5,63.5;Wild Area,26.1,54.2;Motostoke,25.7,46.3;Turffield,12.4,40.9;Hulbury,38.5,40.8;Hammerlocke,26.2,34.5;Stow on Side,12.6,31.3;Ballonlea,10.4,24.3;Circhester,43.3,26.5;Spikemuth,47.4,34.7;Wyndon,26.2,9.4;Isle of Armor,76.9,35.2;Crown Tundra,26.1,84.5', 'alola':'Iki Town,14.5,40.4;Hauoli City,12,48.3;Melemele Sea,18.8,55;Heahea City,30.8,32;Paniola Town,34.3,25.8;Royal Avenue,37.8,32.5;Konikoni City,26.3,28.8;Malie City,91.3,62.9;Tapu Village,78.5,73.3;Po Town,79,55;Mount Lanakila,82.8,64.6;Seafolk Village,56.8,37.9;Poni Wilds,57.8,33.3;Vast Poni Canyon,67,19.6;Aether Paradise,38.3,61.7', 'orre':'Phenac City,73,61.2;Pyrite Town,56,44;Agate Village,39,30;Mt Battle,54,19.8;Realgam Tower,59,55.6;Outskirt Stand,87.8,61.7;Snagem Hideout,84,36;Cipher Lab,73.4,27.7;Gateon Port,12.8,31.9;Pokemon HQ Lab,19.5,19.6;Rock Poke Spot,86.7,48.5;Oasis Poke Spot,57.5,31.9;Cave Poke Spot,38.3,47;Citadark Isle,31,57.7;Orre Colosseum,22.6,46.5' } for region,places in manual.items(): m=maps[region] m['nodes']=[node(name,float(x)*m['width']/100,float(y)*m['height']/100,region) for name,x,y in (p.split(',') for p in places.split(';'))] (ROOT/'data/pokemon-map-art.json').write_text(json.dumps(maps,ensure_ascii=False,indent=2)+'\n') print({r:len(m['nodes']) for r,m in maps.items()})