0%
boxmoe_header_banner_img

加载中

🗺️MaaNTE-Map 坐标系统与瓦片地图开发文档


avatar
白木 2026年6月16日 2026年6月16日 103

 

购买雨云服务器

购买服务器

购买雨云服务器

云服务器、网站搭建、游戏云、对象存储、裸金属物理机

 

🦈本文档由亚特兰蒂斯后裔鲨鲨编写🔱

本文档详细说明 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 } 对象。虽然字段名借用了地理坐标的术语,但这里 latlng 是游戏内部的世界坐标值,与经纬度无关。

来源:游戏内角色位置、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 中的 widthheight 定义(当前为 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.jsonmap 字段中:

{
  "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]
}

转换过程

  1. lng → x 方向pixelX = worldOriginPixel.x + lng × pixelsPerWorldUnit

- lng 为正时,位置在世界原点右侧(底图右半部分)
- lng 为负时,位置在世界原点左侧(底图左半部分)

  1. lat → y 方向pixelY = worldOriginPixel.y - lat × pixelsPerWorldUnit

- 注意这里是减法:lat 为正时,游戏中的"北"对应底图的上方(Y 更小)
- lat 为负时,位置在原点下方

  1. 像素坐标 → 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 的逆运算):

  1. Leaflet lat → pixelY:pixelY = -latlat = (worldOriginPixel.y + leafletLat) / pixelsPerWorldUnit
  2. Leaflet lng → pixelX:pixelX = leafletLnglng = (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_WIDTHsourceHeight = 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 的瓦片拼合原理

  1. 根据当前视口位置和缩放级别,计算可见的瓦片坐标范围
  2. 请求对应 URL 模板中的瓦片图片
  3. 用 CSS position: absolute 将瓦片按网格排列在容器中
  4. 相邻瓦片的像素精确对齐,无缝拼合成完整地图
  5. 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 提供的无投影坐标参考系,特点:

  • 不假设地球曲面,直接使用平面直角坐标
  • latlng 可以是任意实数,没有地理含义
  • 默认 1 个坐标单位 = 1 个像素(但可通过 bounds 和 tileSize 调整)
  • 适用于游戏地图、室内平面图、CAD 图纸等非地理场景

七、扩图规则

当游戏地图更新(新增区域)时,需要追加瓦片。扩图操作必须遵循以下规则,确保已有点位坐标不漂移:

7.1 向右或向下扩图

只需更新 map.widthmap.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 扩图后的瓦片网格更新

扩图后需要:

  1. 生成新的瓦片文件并放入 public/tiles/ 目录
  2. 更新 map-data.json 中的 map 字段
  3. 瓦片坐标从 0 开始,与像素坐标保持固定对应关系
  4. 已有瓦片文件不需要重命名或移动(因为向左/向上扩图时是前置新瓦片)

八、实时导航系统

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 导航服务对接

如果目标游戏也需要实时定位,需要:

  1. 实现游戏内的截图+图像匹配,输出像素坐标
  2. 启动 WebSocket 服务,推送 navi-state 消息
  3. 配置 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/ 预渲染瓦片图片

十二、快速移植清单

迁移到新游戏时的最小改动清单:

  1. 修改 map-data.json 中的 map 字段(width、height、pixelsPerWorldUnit、worldOriginPixel)
  2. 替换 public/tiles/ 中的瓦片文件(按 {z}/{x}/{y}.jpg 规则切割新地图)
  3. 验证 worldToMapLatLng 转换:在游戏中取几个已知坐标点,在地图上检查标记位置是否正确
  4. (可选)调整坐标轴方向:如果游戏的坐标系 Y 轴方向与 MaaNTE 不同,修改 locations.js 中的符号
  5. (可选)对接导航服务:实现 WebSocket 客户端连接,配置 mapLocatorSourceWidth/Height
  6. 替换 public/icons/public/images/ 中的标记图标
  7. 替换 map-data.json 中的 categorieslocationsroutes 为新游戏的数据
上一次更新已经跑远惹✨ 计算中...
(‾◡◝) 本内容里的一些消息,可能已经跟不上时间啦~
感谢您的支持
微信赞赏

微信扫一扫

支付宝赞赏

支付宝扫一扫



评论(0)

查看评论列表

暂无评论


发表评论

表情 颜文字
插入代码

北京时间 (Asia/Shanghai)

后退
前进
刷新
复制
粘贴
全选
删除
返回首页
0%
目录
顶部
底部
📖 文章导读