- 移除 npm 锁文件 package-lock.json 与本地 node_modules 目录 - 配置 pnpm 工作区,允许 esbuild 的安装期构建脚本 - 添加 scripts/fetch-reference-maps.py 用于抓取拼接高德地图瓦片生成参考底图 - 生成两张西安参考底图并保存至 public/assets/maps/ 目录 - 更新 CHANGELOG.md 记录版本变更历史
176 lines
5.9 KiB
Python
176 lines
5.9 KiB
Python
#!/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()
|