购买雨云服务器
云服务器、网站搭建、游戏云、对象存储、裸金属物理机
🦈本文档由亚特兰蒂斯后裔鲨鲨编写🔱
本文档详细说明 MaaNTE-Map 项目如何实现坐标系计算、瓦片拼合和坐标定位,适用于将本项目的地图方案迁移到其他游戏地图工具。
一、整体架构
MaaNTE-Map 基于 Vue 3 + Leaflet + Vite 构建,本质上是一个面向游戏自定义地图的单页应用。地图底图并非真实地理数据,而是预先渲染好的游戏地图切片(瓦片),通过 Leaflet 的 L.CRS.Simple(简单平面坐标参考系)进行展示。
核心数据流如下:
游戏世界坐标 (World)
│
▼ worldToMapLatLng()
Leaflet 平面坐标 (MapLatLng)
│
▼ Leaflet 内部映射
像素坐标 (Pixel) ←→ 瓦片坐标 (Tile z/x/y)
│
▼ mapPixelToMapLatLng() / mapLatLngToMapLocator()
导航像素坐标 (Locator Pixel)
二、三种坐标系
项目中存在三种相互关联的坐标系,理解它们之间的关系是移植本项目的关键。
2.1 游戏世界坐标 (World Coordinates)
用途:持久化存储位置数据,与游戏内坐标一一对应。
表示方式:{ lat, lng } 对象。虽然字段名借用了地理坐标的术语,但这里 lat 和 lng 是游戏内部的世界坐标值,与经纬度无关。
来源:游戏内角色位置、NPC 坐标、地图标记等,均由游戏引擎提供。
存储位置:src/data/map-data.json 中每个 location 条目的 lat / lng 字段。
示例:
{
"id": "example-point",
"name": "示例点",
"lat": 120.5,
"lng": -45.3,
...
}
2.2 地图像素坐标 (Map Pixel Coordinates)
用途:表示位置在完整底图图片上的绝对像素偏移。
坐标系定义:以底图左上角为原点,向右为 X 正方向,向下为 Y 正方向。底图尺寸由 map-data.json 中的 width 和 height 定义(当前为 22528 x 22528 像素)。
与瓦片的关系:底图被切割成 512 x 512 的瓦片网格,像素坐标可以直接映射到具体瓦片:
tileX = floor(pixelX / tileSize)
tileY = floor(pixelY / tileSize)
2.3 Leaflet 平面坐标 (MapLatLng)
用途:Leaflet 渲染引擎内部使用的坐标,所有 UI 层(标记、路线、导航箭头)均以此坐标定位。
坐标系定义:使用 Leaflet 的 L.CRS.Simple,这是一个无投影的平面坐标系。在本项目中,其定义域为:
lat ∈ [-22528, 0] (纵向,负值在上方)
lng ∈ [0, 22528] (横向,正值向右)
关键约定:Leaflet 中 lat 的正方向是向上(北),但像素坐标的 Y 轴正方向是向下。因此在转换时需要取反。本项目通过 [-y, x] 的方式将像素坐标映射为 Leaflet 坐标。
三、核心配置参数
所有坐标系统的参数集中定义在 src/data/map-data.json 的 map 字段中:
{
"map": {
"width": 22528,
"height": 22528,
"tileSize": 512,
"pixelsPerWorldUnit": 44,
"worldOriginPixel": {
"x": 11264,
"y": 11264
}
}
}
各参数含义:
| 参数 | 值 | 含义 |
|---|---|---|
| --- | --- | --- |
width |
22528 | 底图总宽度(像素)。决定瓦片网格的列数 |
height |
22528 | 底图总高度(像素)。决定瓦片网格的行数 |
tileSize |
512 | 每个瓦片的边长(像素) |
pixelsPerWorldUnit |
44 | 缩放比例:1 个游戏世界坐标单位 = 44 像素 |
worldOriginPixel.x |
11264 | 游戏世界原点 (0,0) 在底图上的 X 像素位置 |
worldOriginPixel.y |
11264 | 游戏世界原点 (0,0) 在底图上的 Y 像素位置 |
附加参数(可选,用于导航系统):
| 参数 | 默认值 | 含义 |
|---|---|---|
| --- | --- | --- |
mapLocatorSourceWidth |
11264 | 导航服务截图/参考图的宽度 |
mapLocatorSourceHeight |
11264 | 导航服务截图/参考图的高度 |
3.1 参数之间的数学关系
底图总宽度 = width = 22528 px
瓦片列数 = width / tileSize = 22528 / 512 = 44 列
世界原点 = (11264, 11264) = 底图正中心
缩放比例推导:
假设游戏世界范围为 [-256, 256](lat 和 lng 各 512 个单位)
底图覆盖的像素范围 = 512 × 44 = 22528 px ✓
世界原点位于正中心:
11264 = 256 × 44 ✓
四、坐标转换函数详解
所有转换函数定义在 src/data/locations.js 中,共四个,覆盖了所有坐标系之间的互转需求。
4.1 worldToMapLatLng — 游戏世界坐标 → Leaflet 坐标
用途:将游戏内坐标转换为 Leaflet 可渲染的坐标。这是最高频的转换——所有标记点、路线点位的渲染都经过此函数。
源码:
export function worldToMapLatLng({ lat, lng }) {
const x = MAP_CONFIG.worldOriginPixel.x + lng * MAP_CONFIG.pixelsPerWorldUnit
const y = MAP_CONFIG.worldOriginPixel.y - lat * MAP_CONFIG.pixelsPerWorldUnit
return [-y, x]
}
转换过程:
- lng → x 方向:
pixelX = worldOriginPixel.x + lng × pixelsPerWorldUnit
- lng 为正时,位置在世界原点右侧(底图右半部分)
- lng 为负时,位置在世界原点左侧(底图左半部分)
- lat → y 方向:
pixelY = worldOriginPixel.y - lat × pixelsPerWorldUnit
- 注意这里是减法:lat 为正时,游戏中的"北"对应底图的上方(Y 更小)
- lat 为负时,位置在原点下方
- 像素坐标 → Leaflet 坐标:
return [-y, x]
- Leaflet 的 lat 取负的 pixelY(因为 Leaflet lat 正方向向上,而像素 Y 轴向下)
- Leaflet 的 lng 直接取 pixelX
数值示例:
输入:lat = 100, lng = -50
pixelX = 11264 + (-50) × 44 = 11264 - 2200 = 9064
pixelY = 11264 - 100 × 44 = 11264 - 4400 = 6864
输出:[-6864, 9064] (Leaflet lat, Leaflet lng)
使用场景:
// 在地图上放置标记
const marker = L.marker(worldToMapLatLng(location), {
icon: createIcon(location),
title: location.name,
})
// 绘制路线段
const start = worldToMapLatLng(from)
const end = worldToMapLatLng(to)
L.polyline([start, end], { color: '#ffd27d' }).addTo(map)
4.2 mapLatLngToWorld — Leaflet 坐标 → 游戏世界坐标
用途:将 Leaflet 坐标逆转换回游戏世界坐标。用于地图点击事件——用户点击地图空白处创建新点位时,需要将点击位置转换为世界坐标存储。
源码:
export function mapLatLngToWorld({ lat, lng }) {
return {
lat: (MAP_CONFIG.worldOriginPixel.y + lat) / MAP_CONFIG.pixelsPerWorldUnit,
lng: (lng - MAP_CONFIG.worldOriginPixel.x) / MAP_CONFIG.pixelsPerWorldUnit,
}
}
转换过程(4.1 的逆运算):
- Leaflet lat → pixelY:
pixelY = -lat→lat = (worldOriginPixel.y + leafletLat) / pixelsPerWorldUnit - Leaflet lng → pixelX:
pixelX = leafletLng→lng = (leafletLng - worldOriginPixel.x) / pixelsPerWorldUnit
使用场景:
map.on('click', ({ latlng }) => {
// 将点击的 Leaflet 坐标转为游戏世界坐标,用于创建新点位
const worldCoord = mapLatLngToWorld(latlng)
openCreateLocation(worldCoord)
})
4.3 mapPixelToMapLatLng — 导航像素坐标 → Leaflet 坐标
用途:将外部导航服务返回的像素坐标转换为 Leaflet 坐标,用于在地图上显示实时玩家位置。
源码:
export function mapPixelToMapLatLng({ pixelX, pixelY, sourceWidth = MAP_WIDTH, sourceHeight = MAP_HEIGHT }) {
return [
-pixelY * MAP_HEIGHT / sourceHeight,
pixelX * MAP_WIDTH / sourceWidth,
]
}
关键设计:此函数支持 sourceWidth / sourceHeight 参数,用于处理导航服务的截图尺寸与底图尺寸不一致的情况。导航服务通常使用较小的截图进行图像匹配定位(如 11264 x 11264),而非完整底图(22528 x 22528),因此需要按比例缩放。
转换逻辑:
leafletLat = -pixelY × (MAP_HEIGHT / sourceHeight)
leafletLng = pixelX × (MAP_WIDTH / sourceWidth)
- 当
sourceWidth = MAP_WIDTH且sourceHeight = MAP_HEIGHT时,就是简单的取反 - 当 source 尺寸更小时,像素坐标会被放大到底图坐标空间
数值示例:
输入:pixelX=5788, pixelY=8902, sourceWidth=11264, sourceHeight=11264
leafletLat = -8902 × (22528 / 11264) = -8902 × 2 = -17804
leafletLng = 5788 × (22528 / 11264) = 5788 × 2 = 11576
输出:[-17804, 11576]
使用场景:
// 收到 WebSocket 导航消息
const latlng = mapPixelToMapLatLng({
pixelX: payload.position.pixelX,
pixelY: payload.position.pixelY,
sourceWidth: payload.position.sourceWidth,
sourceHeight: payload.position.sourceHeight,
})
navigationMarker.setLatLng(latlng)
4.4 mapLatLngToMapLocator — Leaflet 坐标 → 导航像素坐标
用途:将 Leaflet 坐标转换为导航服务可识别的像素坐标,用于发送路径点(waypoints)到导航服务。
源码:
export function mapLatLngToMapLocator(
{ lat, lng },
sourceWidth = MAP_LOCATOR_SOURCE_WIDTH,
sourceHeight = MAP_LOCATOR_SOURCE_HEIGHT,
) {
return {
pixelX: lng * sourceWidth / MAP_WIDTH,
pixelY: -lat * sourceHeight / MAP_HEIGHT,
}
}
转换逻辑:4.3 的逆运算,将 Leaflet 坐标按比例缩放到导航服务的坐标空间。
使用场景:
// 鼠标移动时显示当前导航坐标
map.on('mousemove', ({ latlng }) => {
coordinates.value = mapLatLngToMapLocator(latlng)
})
// 发送路线点位到导航服务
const waypoints = points.map(point => mapLatLngToMapLocator(worldToMapLatLng(point)))
sendNavigationMessage({ type: 'navi-route-set', waypoints })
五、瓦片系统详解
5.1 瓦片文件结构
瓦片以静态 JPEG 文件存储在 public/tiles/ 目录下,按 {z}/{x}/{y}.jpg 的层级目录结构组织:
public/tiles/
-1/ ← 缩放级别 -1(半分辨率)
0/0.jpg 0/1.jpg ... 0/21.jpg
1/0.jpg 1/1.jpg ... 1/21.jpg
...
21/0.jpg 21/1.jpg ... 21/21.jpg
0/ ← 缩放级别 0(全分辨率)
0/0.jpg 0/1.jpg ... 0/43.jpg
1/0.jpg 1/1.jpg ... 1/43.jpg
...
43/0.jpg 43/1.jpg ... 43/43.jpg
5.2 瓦片网格计算
每个缩放级别的瓦片数量计算如下:
缩放级别 0(全分辨率,maxNativeZoom):
列数 = width / tileSize = 22528 / 512 = 44
行数 = height / tileSize = 22528 / 512 = 44
总计 = 44 × 44 = 1936 张瓦片
缩放级别 -1(半分辨率):
列数 = 44 / 2 = 22
行数 = 44 / 2 = 22
总计 = 22 × 22 = 484 张瓦片
规律:每降低一个缩放级别,瓦片网格在每个维度上减半。缩放级别 -1 的每张瓦片覆盖缩放级别 0 中 2×2 = 4 张瓦片的区域。
5.3 瓦片坐标与像素坐标的映射
瓦片坐标 (tileX, tileY) 与像素坐标的关系:
该瓦片左上角的像素坐标:
pixelX = tileX × tileSize = tileX × 512
pixelY = tileY × tileSize = tileY × 512
该瓦片覆盖的像素范围:
X: [tileX × 512, (tileX + 1) × 512)
Y: [tileY × 512, (tileY + 1) × 512)
5.4 Leaflet 瓦片加载机制
Leaflet 通过 L.tileLayer 自动完成瓦片加载与拼合,核心配置如下:
L.tileLayer('/tiles/{z}/{x}/{y}.jpg', {
bounds, // 地图边界:[[-22528, 0], [0, 22528]]
minZoom: -3, // 最小缩放级别
maxNativeZoom: 0, // 实际有瓦片文件的最大缩放级别
maxZoom: 1, // 允许放大的最大缩放级别
noWrap: true, // 不循环显示
tileSize: 512, // 瓦片尺寸
keepBuffer: 3, // 预加载视口外 3 行/列瓦片
}).addTo(map)
Leaflet 的瓦片拼合原理:
- 根据当前视口位置和缩放级别,计算可见的瓦片坐标范围
- 请求对应 URL 模板中的瓦片图片
- 用 CSS
position: absolute将瓦片按网格排列在容器中 - 相邻瓦片的像素精确对齐,无缝拼合成完整地图
keepBuffer: 3确保视口边缘外有额外 3 行/列瓦片预加载,减少快速拖动时的白块
maxNativeZoom: 0 的含义:当用户放大到缩放级别 1(超过实际瓦片的最大分辨率)时,Leaflet 会加载缩放级别 0 的瓦片并将其放大 2 倍显示。这样即使没有更高分辨率的瓦片文件,用户也能继续放大查看。
5.5 瓦片坐标到 Leaflet 坐标的映射
Leaflet L.CRS.Simple 中,瓦片坐标与 Leaflet 坐标的关系由 bounds 决定:
const bounds = L.latLngBounds([-MAP_HEIGHT, 0], [0, MAP_WIDTH])
// = L.latLngBounds([-22528, 0], [0, 22528])
在缩放级别 0 下:
tileSize = 512
总瓦片列数 = 44
瓦片 (0, 0) 对应 Leaflet 坐标范围:
lat: [-512, 0] (顶部第一行)
lng: [0, 512] (左侧第一列)
瓦片 (x, y) 对应 Leaflet 坐标范围:
lat: [-(y+1)×512, -y×512]
lng: [x×512, (x+1)×512]
5.6 像素坐标到瓦片坐标的转换
给定一个像素坐标,可以定位到它所在的瓦片:
function pixelToTile(pixelX, pixelY, zoom = 0) {
const scale = Math.pow(2, zoom) // zoom 0 → scale 1, zoom -1 → scale 0.5
const adjustedTileSize = TILE_SIZE * scale
return {
tileX: Math.floor(pixelX / adjustedTileSize),
tileY: Math.floor(pixelY / adjustedTileSize),
}
}
在缩放级别 0 下,像素 (9064, 6864) 所在的瓦片:
tileX = floor(9064 / 512) = 17
tileY = floor(6864 / 512) = 13
→ 瓦片文件:/tiles/0/17/13.jpg
六、Leaflet 地图初始化
完整的地图初始化流程(src/composables/useMapApp.js):
// 1. 定义地图边界
const bounds = L.latLngBounds([-MAP_HEIGHT, 0], [0, MAP_WIDTH])
// = [[-22528, 0], [0, 22528]]
// 2. 创建地图实例
const map = L.map(containerElement, {
crs: L.CRS.Simple, // 使用简单平面坐标系(非地理坐标系)
minZoom: -3, // 允许缩小到原始尺寸的 1/8
maxZoom: 1, // 允许放大到原始尺寸的 2 倍
maxBounds: bounds.pad(0.18), // 限制拖拽范围,边界外留 18% 缓冲
zoomControl: false, // 禁用默认缩放控件(后面手动添加到右下角)
attributionControl: false, // 禁用归属信息
})
// 3. 添加瓦片图层
L.tileLayer('/tiles/{z}/{x}/{y}.jpg', {
bounds, // 限制瓦片加载范围
minZoom: -3,
maxNativeZoom: 0, // 实际瓦片最大级别
maxZoom: 1,
noWrap: true, // 不重复/环绕
tileSize: 512, // 瓦片尺寸
keepBuffer: 3, // 预加载缓冲
}).addTo(map)
// 4. 添加缩放控件
L.control.zoom({ position: 'bottomright' }).addTo(map)
// 5. 添加标记聚合层(大量标记时自动聚合)
const markerLayer = L.markerClusterGroup().addTo(map)
// 6. 添加路线图层
const arrowLayer = L.layerGroup().addTo(map)
// 7. 绑定鼠标移动事件(实时坐标显示)
map.on('mousemove', ({ latlng }) => {
coordinates.value = mapLatLngToMapLocator(latlng)
})
// 8. 绑定点击事件(创建点位或添加路线点)
map.on('click', ({ latlng }) => {
const worldCoord = mapLatLngToWorld(latlng)
// ... 创建点位或路线点
})
6.1 CRS.Simple 坐标系说明
L.CRS.Simple 是 Leaflet 提供的无投影坐标参考系,特点:
- 不假设地球曲面,直接使用平面直角坐标
lat和lng可以是任意实数,没有地理含义- 默认 1 个坐标单位 = 1 个像素(但可通过 bounds 和 tileSize 调整)
- 适用于游戏地图、室内平面图、CAD 图纸等非地理场景
七、扩图规则
当游戏地图更新(新增区域)时,需要追加瓦片。扩图操作必须遵循以下规则,确保已有点位坐标不漂移:
7.1 向右或向下扩图
只需更新 map.width 或 map.height,其他参数不变:
原始:width = 22528(44 列瓦片)
向右追加 2 列瓦片:width = 22528 + 2 × 512 = 23552(46 列瓦片)
→ 更新 map-data.json: "width": 23552
→ worldOriginPixel.x 不变(世界原点未移动)
7.2 向左扩图
需要增加 worldOriginPixel.x:
原始:worldOriginPixel.x = 11264
向左追加 2 列瓦片(1024 像素):
→ worldOriginPixel.x = 11264 + 1024 = 12288
→ width = 22528 + 1024 = 23552
原理:世界原点在底图上的像素位置向右移动了,因为它前面多了新的瓦片。已有点位的世界坐标不变,通过新的 worldOriginPixel.x 计算出的像素位置也会相应右移,保持在底图上的正确位置。
7.3 向上扩图
需要增加 worldOriginPixel.y:
原始:worldOriginPixel.y = 11264
向上追加 2 行瓦片(1024 像素):
→ worldOriginPixel.y = 11264 + 1024 = 12288
→ height = 22528 + 1024 = 23552
7.4 扩图后的瓦片网格更新
扩图后需要:
- 生成新的瓦片文件并放入
public/tiles/目录 - 更新
map-data.json中的map字段 - 瓦片坐标从 0 开始,与像素坐标保持固定对应关系
- 已有瓦片文件不需要重命名或移动(因为向左/向上扩图时是前置新瓦片)
八、实时导航系统
8.1 架构概述
游戏进程
│ 截图 + 图像匹配
▼
导航服务(本地 WebSocket)
│ navi-state 消息
▼
MaaNTE-Map(浏览器)
│ mapPixelToMapLatLng()
▼
Leaflet 标记渲染
导航服务独立于地图前端,通过 WebSocket 推送实时位置。
8.2 WebSocket 连接
const socket = new WebSocket('ws://127.0.0.1:14514')
socket.addEventListener('message', (event) => {
const payload = JSON.parse(event.data)
if (payload.type !== 'navi-state' || payload.version !== 1) return
const pixelX = Number(payload.position?.pixelX)
const pixelY = Number(payload.position?.pixelY)
const sourceWidth = Number(payload.position?.sourceWidth)
const sourceHeight = Number(payload.position?.sourceHeight)
const angle = Number(payload.angle)
// 坐标转换并渲染
const latlng = mapPixelToMapLatLng({
pixelX,
pixelY,
sourceWidth: sourceWidth > 0 ? sourceWidth : MAP_WIDTH,
sourceHeight: sourceHeight > 0 ? sourceHeight : MAP_HEIGHT,
})
navigationMarker.setLatLng(latlng)
})
8.3 导航消息格式
接收:navi-state(导航服务 → 地图)
{
"type": "navi-state",
"version": 1,
"position": {
"pixelX": 5788,
"pixelY": 8902,
"score": 0.82,
"mode": "local",
"sourceWidth": 11264,
"sourceHeight": 11264
},
"angle": 123.4,
"angleConfidence": 0.96,
"timestamp": 1770000000.0
}
| 字段 | 类型 | 说明 |
|---|---|---|
| --- | --- | --- |
position.pixelX |
number | 玩家在导航截图上的 X 像素坐标 |
position.pixelY |
number | 玩家在导航截图上的 Y 像素坐标 |
position.sourceWidth |
number | 导航截图宽度(用于坐标缩放) |
position.sourceHeight |
number | 导航截图高度(用于坐标缩放) |
position.score |
number | 定位置信度 |
angle |
number | 玩家朝向角度(度) |
angleConfidence |
number | 角度置信度 |
发送:navi-route-set(地图 → 导航服务)
{
"type": "navi-route-set",
"sourceWidth": 11264,
"sourceHeight": 11264,
"start": true,
"waypoints": [
{ "pixelX": 5000, "pixelY": 3000 },
{ "pixelX": 5500, "pixelY": 3200 }
]
}
其他发送消息:
navi-route-start:开始/继续寻路navi-route-stop:暂停寻路navi-route-clear:清空路径
8.4 渲染平滑处理
为避免导航位置跳动,项目使用 requestAnimationFrame 做节流:
let pendingNavigationState = null
let navigationRenderFrame = 0
function scheduleNavigationRender(state) {
pendingNavigationState = state
if (!navigationRenderFrame) {
navigationRenderFrame = requestAnimationFrame(flushNavigationRender)
}
}
function flushNavigationRender() {
navigationRenderFrame = 0
if (!pendingNavigationState) return
navigationState.value = pendingNavigationState
pendingNavigationState = null
renderNavigationArrow()
}
地图居中跟随也做了平滑处理:
- 容差:
NAVIGATION_CENTER_TOLERANCE_PX = 28像素内不移动 - 平滑系数:
NAVIGATION_CENTER_SMOOTHING = 0.18(每帧移动剩余距离的 18%) - 最大步长:
NAVIGATION_CENTER_MAX_STEP_PX = 48像素
九、路线系统
9.1 路线数据结构
{
"routes": [
{
"id": "route-1",
"name": "主线A",
"isHidden": false,
"segments": [
{
"id": "segment-1",
"name": "路段1",
"isHidden": false,
"points": [
{ "lat": 100, "lng": -50 },
{ "lat": 110, "lng": -45, "locationId": "some-location-id" }
]
}
]
}
]
}
路线由多个 segment(路段)组成,每个 segment 包含有序的 point 数组。point 使用游戏世界坐标存储,可选关联 locationId。
9.2 路线渲染
路线点通过 worldToMapLatLng() 转换后渲染为 L.circleMarker,相邻点之间用 L.polyline 连接并添加方向箭头:
function drawRoutePath(points, color) {
points.forEach((point, index) => {
L.circleMarker(worldToMapLatLng(point), {
color,
fillColor: color,
radius: point.locationId ? 4 : 5,
}).addTo(arrowLayer)
if (index > 0) drawArrow(points[index - 1], point, color)
})
}
十、迁移到其他游戏的实施指南
10.1 需要修改的核心参数
将本项目迁移到其他游戏,主要修改 map-data.json 中的 map 配置:
{
"map": {
"width": "底图总宽度(像素)= 列数 × tileSize",
"height": "底图总高度(像素)= 行数 × tileSize",
"tileSize": 512,
"pixelsPerWorldUnit": "游戏世界坐标到像素的缩放比例",
"worldOriginPixel": {
"x": "世界原点在底图上的 X 像素位置",
"y": "世界原点在底图上的 Y 像素位置"
}
}
}
10.2 确定参数的方法
第一步:获取 pixelsPerWorldUnit
在游戏中找两个已知世界坐标的点 A 和 B,计算它们在底图上的像素距离:
pixelsPerWorldUnit = 像素距离 / 世界坐标距离
例如:游戏中两点相距 100 个世界单位,在底图上相距 4400 像素,则 pixelsPerWorldUnit = 44。
第二步:确定 worldOriginPixel
找到游戏中世界坐标为 (0, 0) 的位置,测量它在底图上的像素偏移:
worldOriginPixel.x = 世界原点在底图上的 X 像素位置
worldOriginPixel.y = 世界原点在底图上的 Y 像素位置
如果游戏没有明确的 (0,0) 原点,可以任意选取一个参考点作为"虚拟原点",所有世界坐标都相对于该点计算。
第三步:确定 width / height
根据底图总像素尺寸和 tileSize 计算:
width = ceil(底图宽度 / tileSize) × tileSize
height = ceil(底图高度 / tileSize) × tileSize
确保 width 和 height 是 tileSize 的整数倍,否则边缘瓦片会有空白。
10.3 制作瓦片
将游戏地图的完整截图切割为 512 x 512 的瓦片:
from PIL import Image
import math, os
TILE_SIZE = 512
def slice_map(image_path, output_dir, zoom=0):
img = Image.open(image_path)
scale = 2 ** zoom
cols = math.ceil(img.width / (TILE_SIZE * scale))
rows = math.ceil(img.height / (TILE_SIZE * scale))
for y in range(rows):
for x in range(cols):
left = x * TILE_SIZE * scale
upper = y * TILE_SIZE * scale
right = min(left + TILE_SIZE * scale, img.width)
lower = min(upper + TILE_SIZE * scale, img.height)
tile = img.crop((left, upper, right, lower))
tile_dir = os.path.join(output_dir, str(zoom), str(x))
os.makedirs(tile_dir, exist_ok=True)
tile.save(os.path.join(tile_dir, f"{y}.jpg"), "JPEG", quality=85)
# 全分辨率(zoom 0)
slice_map("full_map.png", "public/tiles", zoom=0)
# 半分辨率(zoom -1):先缩小到一半再切割
half_map = Image.open("full_map.png")
half_map = half_map.resize((half_map.width // 2, half_map.height // 2), Image.LANCZOS)
half_map.save("half_map.png")
slice_map("half_map.png", "public/tiles", zoom=-1)
瓦片命名规则:
- 路径:
public/tiles/{zoom}/{column}/{row}.jpg - zoom:缩放级别(通常 0 和 -1)
- column(x):从左到右,从 0 开始
- row(y):从上到下,从 0 开始
10.4 导航服务对接
如果目标游戏也需要实时定位,需要:
- 实现游戏内的截图+图像匹配,输出像素坐标
- 启动 WebSocket 服务,推送
navi-state消息 - 配置
mapLocatorSourceWidth/mapLocatorSourceHeight为导航截图的尺寸
导航截图的尺寸可以与底图不同(通常是底图的子区域或不同分辨率),通过 sourceWidth / sourceHeight 参数进行自动缩放。
10.5 常见适配问题
坐标轴方向不一致
不同游戏的坐标系 Y 轴方向可能不同。如果游戏的 Y 轴向下(屏幕坐标系),需要调整 worldToMapLatLng 中的符号:
// 游戏 Y 轴向下时
const y = MAP_CONFIG.worldOriginPixel.y + lat * MAP_CONFIG.pixelsPerWorldUnit // 改为加法
世界坐标不是线性映射
如果游戏使用非线性坐标(如球面坐标或非均匀网格),需要在 worldToMapLatLng 中加入非线性变换。
底图比例与世界坐标不匹配
如果底图的宽高比与世界坐标范围的宽高比不一致,pixelsPerWorldUnit 可能需要分别为 X 和 Y 方向设置不同的值:
const pixelsPerWorldUnitX = 44
const pixelsPerWorldUnitY = 44
十一、文件结构与关键源码索引
| 文件路径 | 职责 |
|---|---|
| --- | --- |
src/data/map-data.json |
核心配置:地图参数、分类、点位、路线 |
src/data/locations.js |
全部坐标转换函数 |
src/constants/mapApp.js |
缩放级别、存储键名、导航参数常量 |
src/composables/useMapApp.js |
主逻辑组合函数:地图初始化、瓦片层、标记渲染、路线绘制、WebSocket 导航、编辑器 |
src/App.vue |
UI 模板:侧边栏、工具栏、地图容器、编辑器弹窗 |
src/utils/assets.js |
深拷贝和 public 资源 URL 构建 |
src/utils/navigationEndpoint.js |
WebSocket URL 解析与规范化 |
src/utils/storage.js |
localStorage 读取工具 |
vite.config.js |
构建配置及本地编辑中间件 |
public/tiles/ |
预渲染瓦片图片 |
十二、快速移植清单
迁移到新游戏时的最小改动清单:
- 修改
map-data.json中的map字段(width、height、pixelsPerWorldUnit、worldOriginPixel) - 替换
public/tiles/中的瓦片文件(按{z}/{x}/{y}.jpg规则切割新地图) - 验证
worldToMapLatLng转换:在游戏中取几个已知坐标点,在地图上检查标记位置是否正确 - (可选)调整坐标轴方向:如果游戏的坐标系 Y 轴方向与 MaaNTE 不同,修改
locations.js中的符号 - (可选)对接导航服务:实现 WebSocket 客户端连接,配置
mapLocatorSourceWidth/Height - 替换
public/icons/和public/images/中的标记图标 - 替换
map-data.json中的categories、locations、routes为新游戏的数据
评论(0)
暂无评论