#!/usr/bin/env python3 """ 抓取高德地图瓦片并拼接为西安参考底图。 文件用途: - 生成两张高清参考地图(仅作绘制参照与人工核对,不参与游戏运行时加载): 1) 西安全域概览(z12,覆盖游戏 viewBox 对应的真实地理范围); 2) 主城区高清图(z14,覆盖城墙 / 大雁塔 / 曲江 / 大明宫一带)。 核心职责: 1. 由经纬度 bbox 计算指定 zoom 下的瓦片行列范围(Web 墨卡托); 2. 下载高德矢量路网瓦片(style=8,中文标注),失败重试并以灰块占位; 3. 用 PIL 拼接瓦片并在底部加版权署名条("底图数据 © 高德地图")。 主要流程: python3 scripts/fetch-reference-maps.py → 依次抓取两组配置 → 拼接 → 输出到 public/assets/maps/ """ import io import math import time import urllib.request from pathlib import Path from PIL import Image, ImageDraw, ImageFont # 项目根目录(脚本位于 scripts/ 下)。 PROJECT_ROOT = Path(__file__).resolve().parent.parent # 瓦片输出目录。 OUT_DIR = PROJECT_ROOT / "public" / "assets" / "maps" # 高德瓦片服务(style=8 标准矢量路网图,中文标注)。 TILE_URL = ( "https://webrd0{sub}.is.autonavi.com/appmaptile" "?lang=zh_cn&size=1&scale=1&style=8&x={x}&y={y}&z={z}" ) # 请求 UA(遵守瓦片服务礼仪)。 UA = "XianTravelGame/0.1 (local reference fetching; contact: dev)" # 瓦片尺寸(像素)。 TILE_SIZE = 256 # 单瓦片下载失败重试次数。 MAX_RETRY = 3 def deg2tile(lon: float, lat: float, zoom: int) -> tuple[int, int]: """经纬度转 Web 墨卡托瓦片坐标。 Args: lon: 经度(度)。 lat: 纬度(度)。 zoom: 缩放级别。 Returns: (x, y) 瓦片行列号(左上角所属瓦片)。 """ n = 2 ** zoom x = int((lon + 180.0) / 360.0 * n) lat_rad = math.radians(lat) y = int((1.0 - math.asinh(math.tan(lat_rad)) / math.pi) / 2.0 * n) return x, y def fetch_tile(zoom: int, x: int, y: int) -> Image.Image | None: """下载单张瓦片(子域名轮转与重试)。 高德路网瓦片子域名为 webrd01~webrd04;重试时轮换子域名以规避单点故障。 Args: zoom: 缩放级别。 x: 瓦片列号。 y: 瓦片行号。 Returns: 成功返回 PIL 图像,最终失败返回 None。 """ for attempt in range(MAX_RETRY): sub = (x + y + attempt) % 4 + 1 # 轮转 1-4 子域名,重试时换域。 url = TILE_URL.format(sub=sub, x=x, y=y, z=zoom) try: req = urllib.request.Request(url, headers={"User-Agent": UA}) with urllib.request.urlopen(req, timeout=10) as resp: data = resp.read() return Image.open(io.BytesIO(data)).convert("RGB") except Exception: time.sleep(0.5) return None def stitch(bbox: dict, zoom: int, out_name: str) -> Path: """按 bbox 抓取并拼接整图。 Args: bbox: {"west": float, "east": float, "south": float, "north": float}。 zoom: 缩放级别。 out_name: 输出文件名。 Returns: 输出文件路径。 """ x0, y0 = deg2tile(bbox["west"], bbox["north"], zoom) x1, y1 = deg2tile(bbox["east"], bbox["south"], zoom) cols, rows = x1 - x0 + 1, y1 - y0 + 1 total = cols * rows print(f"[{out_name}] z{zoom} 瓦片 {cols}×{rows}({total} 张),范围 x{x0}-{x1} y{y0}-{y1}") canvas = Image.new("RGB", (cols * TILE_SIZE, rows * TILE_SIZE), "#e8e0d0") failed = 0 for row in range(rows): for col in range(cols): img = fetch_tile(zoom, x0 + col, y0 + row) if img is None: failed += 1 draw = ImageDraw.Draw(canvas) left = col * TILE_SIZE top = row * TILE_SIZE draw.rectangle( [left, top, left + TILE_SIZE - 1, top + TILE_SIZE - 1], fill="#d0d0d0", ) else: canvas.paste(img, (col * TILE_SIZE, row * TILE_SIZE)) time.sleep(0.08) # 温和限速。 if failed: print(f"[{out_name}] 警告:{failed}/{total} 张瓦片下载失败(已用灰块占位)") add_attribution(canvas) out_path = OUT_DIR / out_name canvas.save(out_path, optimize=True) print(f"[{out_name}] 已保存 {canvas.width}×{canvas.height} → {out_path}") return out_path def add_attribution(canvas: Image.Image) -> None: """在拼接图底部叠加版权署名条。 Args: canvas: 待标注的拼接图。 """ bar_h = 34 draw = ImageDraw.Draw(canvas) draw.rectangle([0, canvas.height - bar_h, canvas.width, canvas.height], fill=(255, 253, 247)) text = "底图数据 © 高德地图 | 仅作项目内部绘制参考,禁止商用分发" font = None for candidate in ("PingFang.ttc", "Hiragino Sans GB.ttc", "Arial Unicode.ttf"): try: font = ImageFont.truetype(candidate, 16) break except OSError: continue if font is None: font = ImageFont.load_default() draw.text((12, canvas.height - bar_h + 8), text, fill=(107, 78, 46), font=font) def main() -> None: """入口:依次生成全域概览与主城区高清图。""" OUT_DIR.mkdir(parents=True, exist_ok=True) # 全域概览(z12):对应游戏 viewBox 的真实地理范围(西至沣河,东至兵马俑)。 stitch( bbox={"west": 108.82, "east": 109.31, "south": 34.14, "north": 34.42}, zoom=12, out_name="xian-reference-full-z12.png", ) # 主城区高清(z14):城墙 / 回民街 / 大明宫 / 小雁塔 / 陕历博 / 大雁塔 / 曲江。 stitch( bbox={"west": 108.87, "east": 109.04, "south": 34.19, "north": 34.33}, zoom=14, out_name="xian-reference-city-z14.png", ) if __name__ == "__main__": main()