build: 切换包管理器至 pnpm 并新增西安参考底图工具与资源
- 移除 npm 锁文件 package-lock.json 与本地 node_modules 目录 - 配置 pnpm 工作区,允许 esbuild 的安装期构建脚本 - 添加 scripts/fetch-reference-maps.py 用于抓取拼接高德地图瓦片生成参考底图 - 生成两张西安参考底图并保存至 public/assets/maps/ 目录 - 更新 CHANGELOG.md 记录版本变更历史
This commit is contained in:
1 parent
b0467054c8
commit
415103a284
8 files changed
+974
-1248
No files matched your search
@@ -0,0 +1,175 @@
|
||||
#!/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()
|
||||
Reference in new issue
Block a user