Skip to content

Commit 2fa1393

Browse files
feats: 加入对实时定位的支持
1 parent 2d07dd0 commit 2fa1393

5 files changed

Lines changed: 193 additions & 0 deletions

File tree

README.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,3 +107,29 @@ pixelY = worldOriginPixel.y - lat * pixelsPerWorldUnit
107107
npm run build
108108
npm run qa
109109
```
110+
111+
## MaaNTE 实时定位
112+
113+
页面会自动连接 `ws://127.0.0.1:8765`,接收 MaaNTE 的 Navi 定位状态,并在地图上展示玩家位置箭头、像素坐标和朝向角度。
114+
115+
在 MaaNTE 中运行 `MapLocator.json` 提供的 `NaviWebSocket` 节点即可同时启动 NCC 定位、方向预测和本地广播。
116+
117+
需要使用其他地址时,在构建前设置 `VITE_MAANTE_NAVI_WEBSOCKET_URL`。消息格式如下:
118+
119+
```json
120+
{
121+
"type": "maan-nav-state",
122+
"version": 1,
123+
"position": {
124+
"pixelX": 5788,
125+
"pixelY": 8902,
126+
"score": 0.82,
127+
"mode": "local",
128+
"sourceWidth": 11264,
129+
"sourceHeight": 11264
130+
},
131+
"angle": 123.4,
132+
"angleConfidence": 0.96,
133+
"timestamp": 1770000000.0
134+
}
135+
```
672 Bytes
Loading

src/App.vue

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
MAP_HEIGHT,
88
MAP_WIDTH,
99
TILE_SIZE,
10+
mapPixelToMapLatLng,
1011
mapLatLngToWorld,
1112
worldToMapLatLng,
1213
} from './data/locations'
@@ -26,6 +27,8 @@ const bounds = L.latLngBounds([-MAP_HEIGHT, 0], [0, MAP_WIDTH])
2627
const INITIAL_ZOOM = -3
2728
const MIN_ZOOM = -3
2829
const MARKER_FILTERS_STORAGE_KEY = 'nte-marker-filters'
30+
const NAVIGATION_WEBSOCKET_URL = import.meta.env.VITE_MAANTE_NAVI_WEBSOCKET_URL || 'ws://127.0.0.1:8765'
31+
const NAVIGATION_RECONNECT_DELAY = 2000
2932
3033
function readStoredIds(key) {
3134
try {
@@ -59,6 +62,17 @@ const routePanelOpen = ref(false)
5962
const activeRouteId = ref(null)
6063
const isAddingSegment = ref(false)
6164
const segmentMarkerIds = ref([])
65+
const navigationConnection = ref('disconnected')
66+
const navigationState = ref({
67+
position: null,
68+
angle: null,
69+
angleConfidence: 0,
70+
})
71+
const navigationConnectionLabel = computed(() => ({
72+
connected: 'CONNECTED',
73+
connecting: 'CONNECTING',
74+
disconnected: 'OFFLINE',
75+
})[navigationConnection.value])
6276
6377
const emptyLocationForm = () => ({
6478
name: '',
@@ -82,6 +96,11 @@ const editorCategoryGroups = computed(() => [...new Set(editorCategories.value.m
8296
let map
8397
let markerLayer
8498
let arrowLayer
99+
let navigationMarker
100+
let navigationSocket
101+
let navigationReconnectTimer
102+
let navigationClientStopped = false
103+
let navigationDisplayAngle = null
85104
const markerLookup = new Map()
86105
87106
const activeRoute = computed(() => routes.value.find((route) => route.id === activeRouteId.value) || null)
@@ -301,6 +320,102 @@ function renderRouteArrows() {
301320
activeRoute.value?.segments.forEach((segment, index) => drawMarkerPath(segment.markerIds, colors[index % colors.length]))
302321
}
303322
323+
function createNavigationIcon() {
324+
return L.divIcon({
325+
className: 'navigation-arrow-shell',
326+
html: `<div class="navigation-arrow"><img src="${publicAssetUrl('/images/map_webview_pointer.png')}" alt=""></div>`,
327+
iconSize: [30, 35],
328+
iconAnchor: [15, 18],
329+
})
330+
}
331+
332+
function updateNavigationMarkerAngle(angle) {
333+
if (!Number.isFinite(angle)) return
334+
if (navigationDisplayAngle === null) {
335+
navigationDisplayAngle = angle
336+
} else {
337+
const delta = ((angle - navigationDisplayAngle + 540) % 360) - 180
338+
navigationDisplayAngle += delta
339+
}
340+
const image = navigationMarker?.getElement()?.querySelector('.navigation-arrow img')
341+
if (image) image.style.transform = `rotate(${navigationDisplayAngle}deg)`
342+
}
343+
344+
function renderNavigationArrow() {
345+
if (!map || !navigationState.value.position) {
346+
navigationMarker?.setOpacity(0)
347+
return
348+
}
349+
if (!navigationMarker) {
350+
navigationMarker = L.marker(mapPixelToMapLatLng(navigationState.value.position), {
351+
icon: createNavigationIcon(),
352+
interactive: false,
353+
keyboard: false,
354+
zIndexOffset: 1000000,
355+
}).addTo(map)
356+
}
357+
navigationMarker.setLatLng(mapPixelToMapLatLng(navigationState.value.position))
358+
navigationMarker.setOpacity(1)
359+
const arrow = navigationMarker.getElement()?.querySelector('.navigation-arrow')
360+
if (arrow) {
361+
arrow.classList.toggle('navigation-arrow--angle-missing', navigationState.value.angle === null)
362+
}
363+
updateNavigationMarkerAngle(navigationState.value.angle)
364+
}
365+
366+
function handleNavigationMessage(event) {
367+
try {
368+
const payload = JSON.parse(event.data)
369+
if (payload.type !== 'maan-nav-state' || payload.version !== 1) return
370+
const pixelX = Number(payload.position?.pixelX)
371+
const pixelY = Number(payload.position?.pixelY)
372+
const sourceWidth = Number(payload.position?.sourceWidth)
373+
const sourceHeight = Number(payload.position?.sourceHeight)
374+
const angle = Number(payload.angle)
375+
navigationState.value = {
376+
position: Number.isFinite(pixelX) && Number.isFinite(pixelY)
377+
? {
378+
pixelX,
379+
pixelY,
380+
sourceWidth: Number.isFinite(sourceWidth) && sourceWidth > 0 ? sourceWidth : MAP_WIDTH,
381+
sourceHeight: Number.isFinite(sourceHeight) && sourceHeight > 0 ? sourceHeight : MAP_HEIGHT,
382+
}
383+
: null,
384+
angle: payload.angle !== null && Number.isFinite(angle) ? angle : null,
385+
angleConfidence: Number(payload.angleConfidence) || 0,
386+
}
387+
renderNavigationArrow()
388+
} catch {
389+
// Ignore malformed messages and continue consuming the local stream.
390+
}
391+
}
392+
393+
function scheduleNavigationReconnect() {
394+
if (navigationClientStopped || navigationReconnectTimer) return
395+
navigationReconnectTimer = window.setTimeout(() => {
396+
navigationReconnectTimer = null
397+
connectNavigationSocket()
398+
}, NAVIGATION_RECONNECT_DELAY)
399+
}
400+
401+
function connectNavigationSocket() {
402+
if (navigationClientStopped || navigationSocket) return
403+
navigationConnection.value = 'connecting'
404+
const socket = new WebSocket(NAVIGATION_WEBSOCKET_URL)
405+
navigationSocket = socket
406+
socket.addEventListener('open', () => {
407+
if (navigationSocket === socket) navigationConnection.value = 'connected'
408+
})
409+
socket.addEventListener('message', handleNavigationMessage)
410+
socket.addEventListener('close', () => {
411+
if (navigationSocket !== socket) return
412+
navigationSocket = null
413+
navigationConnection.value = 'disconnected'
414+
scheduleNavigationReconnect()
415+
})
416+
socket.addEventListener('error', () => socket.close())
417+
}
418+
304419
function focusSegment(segment) {
305420
if (!map) return
306421
const points = segment.markerIds.map((id) => locationLookup.value[id]).filter(Boolean).map(worldToMapLatLng)
@@ -577,10 +692,15 @@ onMounted(async () => {
577692
mapElement.value.dataset.minZoom = String(map.getMinZoom())
578693
mapElement.value.dataset.initialZoom = String(map.getZoom())
579694
renderMarkers()
695+
connectNavigationSocket()
580696
window.addEventListener('keydown', handleKeydown)
581697
})
582698
583699
onUnmounted(() => {
700+
navigationClientStopped = true
701+
if (navigationReconnectTimer) window.clearTimeout(navigationReconnectTimer)
702+
navigationSocket?.close()
703+
navigationMarker?.remove()
584704
window.removeEventListener('keydown', handleKeydown)
585705
map?.remove()
586706
})
@@ -721,6 +841,9 @@ onUnmounted(() => {
721841
<div class="map-hud glass-panel">
722842
<button type="button" @click="resetView">重置视野</button>
723843
<span>LAT {{ coordinates.lat.toFixed(2) }}</span><span>LNG {{ coordinates.lng.toFixed(2) }}</span>
844+
<span class="navigation-status" :class="`navigation-status--${navigationConnection}`">NAVI {{ navigationConnectionLabel }}</span>
845+
<span v-if="navigationState.position">POS {{ navigationState.position.pixelX.toFixed(0) }}, {{ navigationState.position.pixelY.toFixed(0) }}</span>
846+
<span v-if="navigationState.angle !== null">ANGLE {{ navigationState.angle.toFixed(1) }}°</span>
724847
</div>
725848
<div v-if="editorMode" class="editor-tip glass-panel">编辑模式:点击地图空白处添加点位</div>
726849
<div v-if="statusMessage" class="status-toast glass-panel">{{ statusMessage }}</div>

src/data/locations.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,3 +20,10 @@ export function mapLatLngToWorld({ lat, lng }) {
2020
lng: (lng - MAP_CONFIG.worldOriginPixel.x) / MAP_CONFIG.pixelsPerWorldUnit,
2121
}
2222
}
23+
24+
export function mapPixelToMapLatLng({ pixelX, pixelY, sourceWidth = MAP_WIDTH, sourceHeight = MAP_HEIGHT }) {
25+
return [
26+
-pixelY * MAP_HEIGHT / sourceHeight,
27+
pixelX * MAP_WIDTH / sourceWidth,
28+
]
29+
}

src/styles.css

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -794,6 +794,18 @@ kbd {
794794
font-size: 12px;
795795
}
796796

797+
.navigation-status {
798+
color: #a7a7a7;
799+
}
800+
801+
.navigation-status--connected {
802+
color: #b8fff2;
803+
}
804+
805+
.navigation-status--connecting {
806+
color: #ffe0a6;
807+
}
808+
797809
.detail-card + .map-hud {
798810
right: 400px;
799811
}
@@ -802,6 +814,31 @@ kbd {
802814
background: transparent;
803815
}
804816

817+
.navigation-arrow-shell {
818+
background: transparent;
819+
}
820+
821+
.navigation-arrow {
822+
display: flex;
823+
width: 30px;
824+
height: 35px;
825+
align-items: center;
826+
justify-content: center;
827+
}
828+
829+
.navigation-arrow img {
830+
width: 30px;
831+
height: 35px;
832+
filter: drop-shadow(0 2px 3px rgba(0, 0, 0, 0.65));
833+
image-rendering: pixelated;
834+
transform-origin: 50% 50%;
835+
transition: transform 100ms linear;
836+
}
837+
838+
.navigation-arrow--angle-missing {
839+
opacity: 0.62;
840+
}
841+
805842
.map-marker {
806843
position: relative;
807844
display: grid;

0 commit comments

Comments
 (0)