koishi-plugin-haulmp-tools 1.0.2 → 1.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/command/locate.js +142 -7
- package/lib/command/menu.js +5 -4
- package/lib/index.js +1 -1
- package/lib/resource/map/fallback.html +241 -0
- package/package.json +1 -1
- package/readme.md +37 -1
package/lib/command/locate.js
CHANGED
|
@@ -62,6 +62,29 @@ let mapPage = null
|
|
|
62
62
|
let mapPageReady = false
|
|
63
63
|
let mapPageInit = null
|
|
64
64
|
let mapPageLock = Promise.resolve()
|
|
65
|
+
// 环境不支持 WebGL2 时(无头 Linux 常见)不再反复尝试:命中后冷却一段时间再试
|
|
66
|
+
let mapUnsupportedAt = 0
|
|
67
|
+
const MAP_RETRY_MS = 30 * 60 * 1000
|
|
68
|
+
|
|
69
|
+
// 等页面就绪;期间若出现 WebGL 初始化错误则立即失败(不必干等 30 秒)
|
|
70
|
+
function waitMapReady(page, readErr) {
|
|
71
|
+
return new Promise((resolve, reject) => {
|
|
72
|
+
let done = false
|
|
73
|
+
const finish = (fn, v) => {
|
|
74
|
+
if (done) return
|
|
75
|
+
done = true
|
|
76
|
+
clearInterval(timer)
|
|
77
|
+
fn(v)
|
|
78
|
+
}
|
|
79
|
+
const timer = setInterval(() => {
|
|
80
|
+
const e = readErr()
|
|
81
|
+
if (e) finish(reject, new Error(`WebGL2 不可用(${String(e).slice(0, 60)}),该环境无法渲染地图`))
|
|
82
|
+
}, 250)
|
|
83
|
+
page.waitForFunction(() => window.__ready === true && typeof window.getTileUrl === 'function', { timeout: 30000 })
|
|
84
|
+
.then(() => finish(resolve))
|
|
85
|
+
.catch((e) => finish(reject, e))
|
|
86
|
+
})
|
|
87
|
+
}
|
|
65
88
|
|
|
66
89
|
function withTimeout(promise, ms, onTimeout) {
|
|
67
90
|
let timer
|
|
@@ -151,6 +174,7 @@ async function doGetMapPage(ctx) {
|
|
|
151
174
|
puppeteer = ctx.get('puppeteer')
|
|
152
175
|
} catch (e) { /* ignore */ }
|
|
153
176
|
if (!puppeteer) return null
|
|
177
|
+
if (mapUnsupportedAt && Date.now() - mapUnsupportedAt < MAP_RETRY_MS) return null
|
|
154
178
|
const port = await startStaticServer(ctx)
|
|
155
179
|
if (!port) return null
|
|
156
180
|
if (mapPage && mapPageReady) {
|
|
@@ -174,23 +198,33 @@ async function doGetMapPage(ctx) {
|
|
|
174
198
|
const t0 = Date.now()
|
|
175
199
|
mapPageReady = false
|
|
176
200
|
mapPage = await puppeteer.page()
|
|
201
|
+
let pageErr = null
|
|
202
|
+
const readErr = () => pageErr
|
|
203
|
+
mapPage.on('pageerror', (err) => {
|
|
204
|
+
const msg = err && err.message ? err.message : String(err)
|
|
205
|
+
if (/webgl|gpuinitialization/i.test(msg)) pageErr = msg
|
|
206
|
+
ctx.logger('haulmp').warn('地图页面脚本错误: %s', msg)
|
|
207
|
+
})
|
|
177
208
|
try {
|
|
178
|
-
mapPage.on('pageerror', (err) => {
|
|
179
|
-
ctx.logger('haulmp').warn('地图页面脚本错误: %s', err && err.message ? err.message : String(err))
|
|
180
|
-
})
|
|
181
209
|
await mapPage.setViewport({ width: 720, height: 500, deviceScaleFactor: 2 })
|
|
182
210
|
await mapPage.goto(`http://127.0.0.1:${port}/`, { waitUntil: 'domcontentloaded', timeout: 30000 })
|
|
183
211
|
// __ready(底图 load 事件)与 getTileUrl(模块执行完成)都满足才算就绪
|
|
184
|
-
await mapPage
|
|
212
|
+
await waitMapReady(mapPage, readErr)
|
|
185
213
|
mapPageReady = true
|
|
214
|
+
mapUnsupportedAt = 0
|
|
186
215
|
ctx.logger('haulmp').info('地图页面构建完成 %dms', Date.now() - t0)
|
|
187
216
|
preloadEurope(mapPage).catch(() => {})
|
|
188
217
|
return mapPage
|
|
189
218
|
} catch (e) {
|
|
190
|
-
ctx.logger('haulmp').warn('地图页面初始化失败: %s', e.message)
|
|
191
219
|
try { mapPage.close().catch(() => {}) } catch (err) { /* ignore */ }
|
|
192
220
|
mapPage = null
|
|
193
221
|
mapPageReady = false
|
|
222
|
+
if (/WebGL2 不可用/.test(e.message)) {
|
|
223
|
+
mapUnsupportedAt = Date.now()
|
|
224
|
+
ctx.logger('haulmp').warn('地图渲染不可用:当前浏览器缺少 WebGL2(无头 Linux 常见)。定位指令将退回文字结果;如需地图请在 puppeteer 服务配置 args 中加入:--use-gl=angle --use-angle=swiftshader --enable-unsafe-swiftshader(去掉 --disable-gpu),已暂停 30 分钟内重试')
|
|
225
|
+
} else {
|
|
226
|
+
ctx.logger('haulmp').warn('地图页面初始化失败: %s', e.message)
|
|
227
|
+
}
|
|
194
228
|
return null
|
|
195
229
|
}
|
|
196
230
|
}
|
|
@@ -217,10 +251,104 @@ async function mapShot(ctx, info, p, nearby) {
|
|
|
217
251
|
return run
|
|
218
252
|
}
|
|
219
253
|
|
|
254
|
+
// ---- Canvas 2D 兜底页(不依赖 WebGL,任何无头环境都能出图)----
|
|
255
|
+
let fbPage = null
|
|
256
|
+
let fbReady = false
|
|
257
|
+
let fbLock = Promise.resolve()
|
|
258
|
+
|
|
259
|
+
async function getFallbackPage(ctx) {
|
|
260
|
+
const run = fbLock.then(() => doGetFallbackPage(ctx), () => doGetFallbackPage(ctx))
|
|
261
|
+
fbLock = run.then(() => {}, () => {})
|
|
262
|
+
return run
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
async function doGetFallbackPage(ctx) {
|
|
266
|
+
let puppeteer = null
|
|
267
|
+
try {
|
|
268
|
+
puppeteer = ctx.get('puppeteer')
|
|
269
|
+
} catch (e) { /* ignore */ }
|
|
270
|
+
if (!puppeteer) return null
|
|
271
|
+
const port = await startStaticServer(ctx)
|
|
272
|
+
if (!port) return null
|
|
273
|
+
if (fbPage && fbReady) {
|
|
274
|
+
try {
|
|
275
|
+
if (!fbPage.isClosed()) {
|
|
276
|
+
ctx.logger('haulmp').info('使用已就绪兜底页面')
|
|
277
|
+
return fbPage
|
|
278
|
+
}
|
|
279
|
+
} catch (e) { /* ignore */ }
|
|
280
|
+
fbPage = null
|
|
281
|
+
fbReady = false
|
|
282
|
+
}
|
|
283
|
+
if (fbPage) {
|
|
284
|
+
try { fbPage.close().catch(() => {}) } catch (e) { /* ignore */ }
|
|
285
|
+
fbPage = null
|
|
286
|
+
fbReady = false
|
|
287
|
+
}
|
|
288
|
+
ctx.logger('haulmp').info('重建兜底页面…')
|
|
289
|
+
const t0 = Date.now()
|
|
290
|
+
fbPage = await puppeteer.page()
|
|
291
|
+
try {
|
|
292
|
+
fbPage.on('pageerror', (err) => {
|
|
293
|
+
ctx.logger('haulmp').warn('兜底页面脚本错误: %s', err && err.message ? err.message : String(err))
|
|
294
|
+
})
|
|
295
|
+
await fbPage.setViewport({ width: 760, height: 560, deviceScaleFactor: 2 })
|
|
296
|
+
await fbPage.goto(`http://127.0.0.1:${port}/fallback.html`, { waitUntil: 'domcontentloaded', timeout: 20000 })
|
|
297
|
+
await fbPage.waitForFunction(() => window.__ready === true && typeof window.drawView === 'function', { timeout: 15000 })
|
|
298
|
+
fbReady = true
|
|
299
|
+
ctx.logger('haulmp').info('兜底页面构建完成 %dms', Date.now() - t0)
|
|
300
|
+
return fbPage
|
|
301
|
+
} catch (e) {
|
|
302
|
+
ctx.logger('haulmp').warn('兜底页面初始化失败: %s', e.message)
|
|
303
|
+
try { fbPage.close().catch(() => {}) } catch (err) { /* ignore */ }
|
|
304
|
+
fbPage = null
|
|
305
|
+
fbReady = false
|
|
306
|
+
return null
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
async function fallbackShot(ctx, info, nearby) {
|
|
311
|
+
try {
|
|
312
|
+
const page = await getFallbackPage(ctx)
|
|
313
|
+
if (!page) return null
|
|
314
|
+
const reset = () => {
|
|
315
|
+
try { page.close().catch(() => {}) } catch (e) { /* ignore */ }
|
|
316
|
+
fbPage = null
|
|
317
|
+
fbReady = false
|
|
318
|
+
}
|
|
319
|
+
await withTimeout(
|
|
320
|
+
page.evaluate((data) => window.drawView(data), {
|
|
321
|
+
info,
|
|
322
|
+
nearby: nearby.map((n) => ({ lng: n.lon, lat: n.lat, name: n.name, km: n.km })),
|
|
323
|
+
self: { lng: info.lng, lat: info.lat, bearing: info.bearing },
|
|
324
|
+
}),
|
|
325
|
+
10000,
|
|
326
|
+
reset,
|
|
327
|
+
)
|
|
328
|
+
const el = await page.$('#shot')
|
|
329
|
+
const buf = await withTimeout(
|
|
330
|
+
el ? el.screenshot({ encoding: 'binary', type: 'png' }) : page.screenshot({ encoding: 'binary', type: 'png' }),
|
|
331
|
+
10000,
|
|
332
|
+
reset,
|
|
333
|
+
)
|
|
334
|
+
ctx.logger('haulmp').info('兜底方位图渲染完成')
|
|
335
|
+
return buf
|
|
336
|
+
} catch (e) {
|
|
337
|
+
ctx.logger('haulmp').warn('兜底方位图渲染失败: %s', e.message)
|
|
338
|
+
if (fbPage) {
|
|
339
|
+
try { fbPage.close().catch(() => {}) } catch (err) { /* ignore */ }
|
|
340
|
+
fbPage = null
|
|
341
|
+
fbReady = false
|
|
342
|
+
}
|
|
343
|
+
return null
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
220
347
|
async function doMapShot(ctx, info, p, nearby) {
|
|
221
348
|
try {
|
|
222
349
|
const page = await getMapPage(ctx)
|
|
223
|
-
|
|
350
|
+
// 该环境不支持 WebGL2(无 GPU 的无头服务器)→ 退回 Canvas 方位图,仍能出图
|
|
351
|
+
if (!page) return await fallbackShot(ctx, info, nearby)
|
|
224
352
|
const [lng, lat] = gameToLngLat(p.x, p.z)
|
|
225
353
|
const resetPage = () => {
|
|
226
354
|
try { page.close().catch(() => {}) } catch (e) { /* ignore */ }
|
|
@@ -371,6 +499,13 @@ function apply(ctx, config) {
|
|
|
371
499
|
city: cityDisplay,
|
|
372
500
|
dist: near.km.toFixed(0),
|
|
373
501
|
id: p.id,
|
|
502
|
+
// 供 Canvas 兜底页使用的数值字段
|
|
503
|
+
lng,
|
|
504
|
+
lat,
|
|
505
|
+
bearing: bearingDeg,
|
|
506
|
+
bearingText,
|
|
507
|
+
speed: p.speed,
|
|
508
|
+
model,
|
|
374
509
|
}
|
|
375
510
|
|
|
376
511
|
let card = null
|
|
@@ -393,4 +528,4 @@ function apply(ctx, config) {
|
|
|
393
528
|
})
|
|
394
529
|
}
|
|
395
530
|
|
|
396
|
-
module.exports = { apply, gameToLngLat, nearestCity, nearbyPlayers, loadPlaces, matchPlayer, headingBearing, mapShot, syncTileVersion }
|
|
531
|
+
module.exports = { apply, gameToLngLat, nearestCity, nearbyPlayers, loadPlaces, matchPlayer, headingBearing, mapShot, fallbackShot, syncTileVersion }
|
package/lib/command/menu.js
CHANGED
|
@@ -53,7 +53,7 @@ function menuCard() {
|
|
|
53
53
|
</style></head><body><div id="card">${CARD_DECO}
|
|
54
54
|
<div class="head"><span class="title">HaulMP 功能菜单</span><span class="brand">MENU</span></div>
|
|
55
55
|
<div class="mgrid">${sections}</div>
|
|
56
|
-
<div class="foot"><span>HaulMP · 指令末尾 H / h 通用</span><span
|
|
56
|
+
<div class="foot"><span>HaulMP · 指令末尾 H / h 通用</span><span>发送「菜单H」查看本菜单</span></div>
|
|
57
57
|
</div></body></html>`
|
|
58
58
|
}
|
|
59
59
|
|
|
@@ -64,13 +64,14 @@ function menuText() {
|
|
|
64
64
|
for (const [cmd, desc] of items) lines.push(` · ${cmd} — ${desc}`)
|
|
65
65
|
}
|
|
66
66
|
lines.push('━━━━━━━━━━━━')
|
|
67
|
-
lines.push('(指令末尾 H/h
|
|
67
|
+
lines.push('(指令末尾 H/h 通用;看本菜单请发送「菜单H」)')
|
|
68
68
|
return lines.join('\n')
|
|
69
69
|
}
|
|
70
70
|
|
|
71
71
|
function apply(ctx, config) {
|
|
72
|
-
|
|
73
|
-
|
|
72
|
+
// 只注册带 H 的写法(菜单H / 菜单h / menuh),不注册裸「菜单」——避免与其它插件的同名指令冲突
|
|
73
|
+
ctx.command('菜单H', 'HaulMP 功能菜单')
|
|
74
|
+
.alias('menuh', '菜单h')
|
|
74
75
|
.action(async ({ session }) => {
|
|
75
76
|
if (!session) return
|
|
76
77
|
if (!isText(config, 'menu')) {
|
package/lib/index.js
CHANGED
|
@@ -48,7 +48,7 @@ const Config = Schema.object({
|
|
|
48
48
|
status: cardMode('服务器状态'),
|
|
49
49
|
companyBind: cardMode('车队绑定'),
|
|
50
50
|
companyBoard: cardMode('今日里程'),
|
|
51
|
-
menu: cardMode('菜单'),
|
|
51
|
+
menu: cardMode('菜单H'),
|
|
52
52
|
changelog: cardMode('客户端更新推送'),
|
|
53
53
|
announce: cardMode('车队公告推送'),
|
|
54
54
|
locate: cardMode('实时定位'),
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html>
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<title>HaulMP 定位(Canvas 兜底)</title>
|
|
6
|
+
<style>
|
|
7
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
8
|
+
body {
|
|
9
|
+
background-color: #eef2fc;
|
|
10
|
+
background-image:
|
|
11
|
+
radial-gradient(48% 42% at 8% 2%, rgba(129,140,248,.62), transparent 64%),
|
|
12
|
+
radial-gradient(44% 40% at 94% 6%, rgba(56,189,248,.52), transparent 62%),
|
|
13
|
+
radial-gradient(50% 46% at 90% 94%, rgba(52,211,153,.48), transparent 64%),
|
|
14
|
+
radial-gradient(46% 40% at 6% 98%, rgba(251,191,36,.44), transparent 62%),
|
|
15
|
+
radial-gradient(40% 36% at 50% 50%, rgba(244,114,182,.30), transparent 66%);
|
|
16
|
+
font-family: "Segoe UI","Droid Sans Fallback","Noto Sans CJK SC","Microsoft YaHei",sans-serif;
|
|
17
|
+
}
|
|
18
|
+
#shot {
|
|
19
|
+
--t1: #16203a; --t2: #48587a; --t3: #7b8aa5;
|
|
20
|
+
--glass: rgba(255,255,255,.62); --glass-2: rgba(255,255,255,.52);
|
|
21
|
+
--stroke: rgba(255,255,255,.95); --edge: rgba(124,144,196,.22);
|
|
22
|
+
--shadow: 0 12px 30px -16px rgba(58,76,140,.36), inset 0 1px 0 rgba(255,255,255,.95);
|
|
23
|
+
width: 720px; padding: 22px; color: var(--t2);
|
|
24
|
+
}
|
|
25
|
+
.card {
|
|
26
|
+
border-radius: 26px; padding: 20px;
|
|
27
|
+
background: linear-gradient(150deg, rgba(255,255,255,.60), rgba(248,251,255,.38) 48%, rgba(255,255,255,.54));
|
|
28
|
+
border: 1px solid var(--stroke);
|
|
29
|
+
box-shadow: 0 26px 64px -24px rgba(58,76,140,.50), inset 0 1px 0 rgba(255,255,255,.98);
|
|
30
|
+
backdrop-filter: saturate(190%) blur(26px); -webkit-backdrop-filter: saturate(190%) blur(26px);
|
|
31
|
+
position: relative; overflow: hidden;
|
|
32
|
+
}
|
|
33
|
+
.glow { position: absolute; top: 0; left: 0; right: 0; height: 4px;
|
|
34
|
+
background: linear-gradient(90deg, transparent, #4f46e5 26%, #0ea5e9 74%, transparent); opacity: .9; }
|
|
35
|
+
.head { display: flex; align-items: center; gap: 14px; padding-bottom: 14px; border-bottom: 1px solid var(--edge); }
|
|
36
|
+
.ava { width: 58px; height: 58px; border-radius: 18px; flex: none; display: flex; align-items: center; justify-content: center;
|
|
37
|
+
font-size: 24px; font-weight: 700; color: #3730a3; border: 1px solid var(--stroke);
|
|
38
|
+
background: linear-gradient(150deg, rgba(99,102,241,.30), rgba(14,165,233,.24)); box-shadow: var(--shadow); }
|
|
39
|
+
.who { flex: 1; min-width: 0; }
|
|
40
|
+
.name { font-size: 21px; font-weight: 700; color: var(--t1); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
41
|
+
.sub { font-size: 13px; color: var(--t3); margin-top: 3px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
42
|
+
.loc { text-align: right; flex: none; }
|
|
43
|
+
.city { font-size: 17px; font-weight: 700; color: #3730a3; }
|
|
44
|
+
.dist { font-size: 12px; color: var(--t3); margin-top: 3px; }
|
|
45
|
+
.chips { display: flex; gap: 8px; margin-top: 14px; }
|
|
46
|
+
.chip { font-size: 12.5px; color: #3730a3; background: var(--glass-2); border: 1px solid var(--stroke);
|
|
47
|
+
border-radius: 999px; padding: 4px 12px; box-shadow: var(--shadow); white-space: nowrap; }
|
|
48
|
+
.chip b { color: var(--t1); }
|
|
49
|
+
.plotwrap { margin-top: 14px; border-radius: 20px; border: 1px solid var(--stroke); background: var(--glass-2);
|
|
50
|
+
box-shadow: var(--shadow); overflow: hidden; }
|
|
51
|
+
canvas { display: block; }
|
|
52
|
+
.foot { margin-top: 14px; padding-top: 12px; border-top: 1px solid var(--edge);
|
|
53
|
+
display: flex; justify-content: space-between; font-size: 11.5px; color: var(--t3); }
|
|
54
|
+
</style>
|
|
55
|
+
</head>
|
|
56
|
+
<body>
|
|
57
|
+
<div id="shot">
|
|
58
|
+
<div class="card">
|
|
59
|
+
<div class="glow"></div>
|
|
60
|
+
<div class="head">
|
|
61
|
+
<div class="ava" id="ava">?</div>
|
|
62
|
+
<div class="who">
|
|
63
|
+
<div class="name" id="name">-</div>
|
|
64
|
+
<div class="sub" id="sub"></div>
|
|
65
|
+
</div>
|
|
66
|
+
<div class="loc">
|
|
67
|
+
<div class="city" id="city">-</div>
|
|
68
|
+
<div class="dist" id="dist"></div>
|
|
69
|
+
</div>
|
|
70
|
+
</div>
|
|
71
|
+
<div class="chips" id="chips"></div>
|
|
72
|
+
<div class="plotwrap"><canvas id="plot" width="1336" height="600" style="width:668px;height:300px"></canvas></div>
|
|
73
|
+
<div class="foot"><span id="near">周边 0 人</span><span id="fid">HaulMP</span></div>
|
|
74
|
+
</div>
|
|
75
|
+
</div>
|
|
76
|
+
<script>
|
|
77
|
+
window.__ready = true
|
|
78
|
+
|
|
79
|
+
var RANGE = 30 // 图幅半径(km)
|
|
80
|
+
var PAD = 34 // 画布内边距
|
|
81
|
+
var W = 668, H = 300
|
|
82
|
+
|
|
83
|
+
function drawRadar(data) {
|
|
84
|
+
var cv = document.getElementById('plot')
|
|
85
|
+
var ctx = cv.getContext('2d')
|
|
86
|
+
var S = 2
|
|
87
|
+
ctx.setTransform(S, 0, 0, S, 0, 0)
|
|
88
|
+
ctx.clearRect(0, 0, W, H)
|
|
89
|
+
|
|
90
|
+
var cx = W / 2, cy = H / 2
|
|
91
|
+
var R = Math.min(W, H) / 2 - PAD
|
|
92
|
+
var scale = R / RANGE
|
|
93
|
+
|
|
94
|
+
// 量程环
|
|
95
|
+
ctx.save()
|
|
96
|
+
for (var i = 1; i <= 3; i++) {
|
|
97
|
+
var r = R * i / 3
|
|
98
|
+
ctx.beginPath()
|
|
99
|
+
ctx.arc(cx, cy, r, 0, Math.PI * 2)
|
|
100
|
+
ctx.setLineDash(i === 3 ? [] : [4, 5])
|
|
101
|
+
ctx.lineWidth = 1
|
|
102
|
+
ctx.strokeStyle = i === 3 ? 'rgba(124,144,196,.45)' : 'rgba(124,144,196,.28)'
|
|
103
|
+
ctx.stroke()
|
|
104
|
+
ctx.setLineDash([])
|
|
105
|
+
ctx.font = '11px "Microsoft YaHei",sans-serif'
|
|
106
|
+
ctx.fillStyle = 'rgba(123,138,165,.95)'
|
|
107
|
+
ctx.textAlign = 'left'
|
|
108
|
+
ctx.fillText((RANGE * i / 3) + ' km', cx + r + 5, cy - 4)
|
|
109
|
+
}
|
|
110
|
+
// 十字轴
|
|
111
|
+
ctx.beginPath()
|
|
112
|
+
ctx.moveTo(cx - R, cy); ctx.lineTo(cx + R, cy)
|
|
113
|
+
ctx.moveTo(cx, cy - R); ctx.lineTo(cx, cy + R)
|
|
114
|
+
ctx.strokeStyle = 'rgba(124,144,196,.30)'
|
|
115
|
+
ctx.lineWidth = 1
|
|
116
|
+
ctx.stroke()
|
|
117
|
+
// 方位
|
|
118
|
+
ctx.font = '11px "Microsoft YaHei",sans-serif'
|
|
119
|
+
ctx.fillStyle = 'rgba(72,88,122,.9)'
|
|
120
|
+
ctx.textAlign = 'center'
|
|
121
|
+
ctx.fillText('北', cx, cy - R - 10)
|
|
122
|
+
ctx.fillText('南', cx, cy + R + 15)
|
|
123
|
+
ctx.textAlign = 'right'; ctx.fillText('西', cx - R - 5, cy + 4)
|
|
124
|
+
ctx.textAlign = 'left'; ctx.fillText('东', cx + R + 5, cy + 4)
|
|
125
|
+
ctx.restore()
|
|
126
|
+
|
|
127
|
+
// 相对坐标(等距圆柱近似,km)
|
|
128
|
+
var sx = data.self.lng != null ? data.self.lng : data.self.lon
|
|
129
|
+
var sy = data.self.lat
|
|
130
|
+
var kx = 111.32 * Math.cos(sy * Math.PI / 180)
|
|
131
|
+
var ky = 111.32
|
|
132
|
+
function pt(lon, lat) {
|
|
133
|
+
return [cx + (lon - sx) * kx * scale, cy - (lat - sy) * ky * scale]
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// 附近玩家
|
|
137
|
+
var nearby = data.nearby || []
|
|
138
|
+
for (var j = 0; j < nearby.length; j++) {
|
|
139
|
+
var n = nearby[j]
|
|
140
|
+
var q = pt(n.lng, n.lat)
|
|
141
|
+
ctx.beginPath()
|
|
142
|
+
ctx.arc(q[0], q[1], 5, 0, Math.PI * 2)
|
|
143
|
+
ctx.fillStyle = '#0ea5e9'
|
|
144
|
+
ctx.fill()
|
|
145
|
+
ctx.lineWidth = 2
|
|
146
|
+
ctx.strokeStyle = 'rgba(255,255,255,.95)'
|
|
147
|
+
ctx.stroke()
|
|
148
|
+
|
|
149
|
+
var label = n.name + (n.km != null ? ' · ' + Number(n.km).toFixed(1) + ' km' : '')
|
|
150
|
+
ctx.font = '11px "Microsoft YaHei",sans-serif'
|
|
151
|
+
var tw = ctx.measureText(label).width
|
|
152
|
+
var lx = q[0] + 9, ly = q[1] - 20
|
|
153
|
+
if (lx + tw + 12 > W - 6) lx = q[0] - tw - 21
|
|
154
|
+
if (ly < 4) ly = q[1] + 8
|
|
155
|
+
ctx.fillStyle = 'rgba(255,255,255,.88)'
|
|
156
|
+
ctx.strokeStyle = 'rgba(124,144,196,.30)'
|
|
157
|
+
ctx.lineWidth = 1
|
|
158
|
+
roundRect(ctx, lx, ly, tw + 12, 19, 9)
|
|
159
|
+
ctx.fill(); ctx.stroke()
|
|
160
|
+
ctx.fillStyle = '#48587a'
|
|
161
|
+
ctx.textAlign = 'left'
|
|
162
|
+
ctx.fillText(label, lx + 6, ly + 13.5)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// 自己(中心标记 + 朝向箭头)
|
|
166
|
+
ctx.save()
|
|
167
|
+
ctx.translate(cx, cy)
|
|
168
|
+
var bearing = Number(data.self.bearing)
|
|
169
|
+
if (isFinite(bearing)) {
|
|
170
|
+
ctx.save()
|
|
171
|
+
ctx.rotate(bearing * Math.PI / 180)
|
|
172
|
+
ctx.beginPath()
|
|
173
|
+
ctx.moveTo(0, -30)
|
|
174
|
+
ctx.lineTo(-9, -11)
|
|
175
|
+
ctx.lineTo(9, -11)
|
|
176
|
+
ctx.closePath()
|
|
177
|
+
ctx.fillStyle = 'rgba(79,70,229,.85)'
|
|
178
|
+
ctx.fill()
|
|
179
|
+
ctx.restore()
|
|
180
|
+
}
|
|
181
|
+
ctx.beginPath()
|
|
182
|
+
ctx.arc(0, 0, 26, 0, Math.PI * 2)
|
|
183
|
+
ctx.fillStyle = 'rgba(79,70,229,.14)'
|
|
184
|
+
ctx.fill()
|
|
185
|
+
ctx.beginPath()
|
|
186
|
+
ctx.arc(0, 0, 8, 0, Math.PI * 2)
|
|
187
|
+
ctx.fillStyle = '#4f46e5'
|
|
188
|
+
ctx.fill()
|
|
189
|
+
ctx.lineWidth = 2.5
|
|
190
|
+
ctx.strokeStyle = 'rgba(255,255,255,.95)'
|
|
191
|
+
ctx.stroke()
|
|
192
|
+
ctx.restore()
|
|
193
|
+
|
|
194
|
+
if (!nearby.length) {
|
|
195
|
+
ctx.font = '13px "Microsoft YaHei",sans-serif'
|
|
196
|
+
ctx.fillStyle = 'rgba(123,138,165,.95)'
|
|
197
|
+
ctx.textAlign = 'center'
|
|
198
|
+
ctx.fillText('30 km 内暂时没有其他玩家', cx, cy + R - 14)
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function roundRect(ctx, x, y, w, h, r) {
|
|
203
|
+
ctx.beginPath()
|
|
204
|
+
ctx.moveTo(x + r, y)
|
|
205
|
+
ctx.lineTo(x + w - r, y)
|
|
206
|
+
ctx.quadraticCurveTo(x + w, y, x + w, y + r)
|
|
207
|
+
ctx.lineTo(x + w, y + h - r)
|
|
208
|
+
ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h)
|
|
209
|
+
ctx.lineTo(x + r, y + h)
|
|
210
|
+
ctx.quadraticCurveTo(x, y + h, x, y + h - r)
|
|
211
|
+
ctx.lineTo(x, y + r)
|
|
212
|
+
ctx.quadraticCurveTo(x, y, x + r, y)
|
|
213
|
+
ctx.closePath()
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
window.drawView = function (data) {
|
|
217
|
+
var info = data.info || {}
|
|
218
|
+
document.getElementById('ava').textContent = info.initial || '?'
|
|
219
|
+
document.getElementById('name').textContent = info.name || '-'
|
|
220
|
+
document.getElementById('sub').textContent = info.sub || ''
|
|
221
|
+
document.getElementById('city').textContent = info.city || '-'
|
|
222
|
+
document.getElementById('dist').textContent = '距市中心 ' + (info.dist != null ? info.dist : '—') + ' km'
|
|
223
|
+
|
|
224
|
+
var chips = []
|
|
225
|
+
if (info.speed != null) chips.push('速度 <b>' + info.speed + ' km/h</b>')
|
|
226
|
+
if (info.bearingText) chips.push('朝向 <b>' + info.bearingText + '</b>')
|
|
227
|
+
chips.push('车型 <b>' + (info.model || '未知') + '</b>')
|
|
228
|
+
document.getElementById('chips').innerHTML = chips.map(function (c) {
|
|
229
|
+
return '<span class="chip">' + c + '</span>'
|
|
230
|
+
}).join('')
|
|
231
|
+
|
|
232
|
+
var n = (data.nearby || []).length
|
|
233
|
+
document.getElementById('near').textContent = '周边 ' + n + ' 人(' + RANGE + ' km 内)'
|
|
234
|
+
document.getElementById('fid').textContent = 'ID ' + (info.id != null ? info.id : '—') + ' · HaulMP · 方位图模式'
|
|
235
|
+
|
|
236
|
+
drawRadar(data)
|
|
237
|
+
return true
|
|
238
|
+
}
|
|
239
|
+
</script>
|
|
240
|
+
</body>
|
|
241
|
+
</html>
|
package/package.json
CHANGED
package/readme.md
CHANGED
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
| 车队 | `绑定车队H <车队ID或链接>` | 绑定本群车队(仅队长可绑定) |
|
|
26
26
|
| 车队 | `解绑车队H` | 解除本群车队绑定(绑定人或队长) |
|
|
27
27
|
| 车队 | `今日里程H` | 本群车队当天里程排行(每日自动建立基准) |
|
|
28
|
-
| 其他 |
|
|
28
|
+
| 其他 | `菜单H` | 功能菜单(只注册带 H 的写法,避免与其它插件的「菜单」指令冲突) |
|
|
29
29
|
|
|
30
30
|
自动推送(无需指令):
|
|
31
31
|
|
|
@@ -78,6 +78,42 @@ src/
|
|
|
78
78
|
- 数据库服务(`database`)
|
|
79
79
|
- `puppeteer` 服务(卡片图片渲染;未启用时自动退回文字输出)
|
|
80
80
|
|
|
81
|
+
## 部署注意:定位地图需要 WebGL2
|
|
82
|
+
|
|
83
|
+
`定位H` 的地图由 maplibre 渲染,**需要浏览器支持 WebGL2**。无头 Linux 服务器默认没有 GPU,
|
|
84
|
+
浏览器会以「WebGL2 is required to display this map / GPUInitializationError」失败,
|
|
85
|
+
此时定位指令会自动退回文字结果(地图页面 30 分钟内不再重试)。
|
|
86
|
+
|
|
87
|
+
需要地图出图,请在 **puppeteer 服务的配置**里加上软件渲染参数:
|
|
88
|
+
|
|
89
|
+
```yaml
|
|
90
|
+
puppeteer:
|
|
91
|
+
args:
|
|
92
|
+
- --no-sandbox
|
|
93
|
+
- --disable-setuid-sandbox
|
|
94
|
+
- --disable-dev-shm-usage
|
|
95
|
+
- --use-gl=angle
|
|
96
|
+
- --use-angle=swiftshader
|
|
97
|
+
- --enable-unsafe-swiftshader
|
|
98
|
+
- --ignore-gpu-blocklist
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
- 新版 Chrome 已不接受 `--use-gl=swiftshader`,必须用 `--use-gl=angle --use-angle=swiftshader`。
|
|
102
|
+
- `--enable-unsafe-swiftshader` 不能省:Chrome 默认禁止用 SwiftShader 提供 WebGL,缺了它拿到的仍是空 canvas。
|
|
103
|
+
- **不要**加 `--disable-gpu`,那会彻底关掉 WebGL。
|
|
104
|
+
- `headless` 需保持 `true`(新版无头模式);远程浏览器模式(`remote: true`)要把参数加在**远端浏览器的启动命令**上。
|
|
105
|
+
|
|
106
|
+
### 没有 WebGL 也能出图:Canvas 方位图模式
|
|
107
|
+
|
|
108
|
+
若服务器确实无法开启软件渲染(WebGL/WebGL2 都拿不到),`定位H` **会自动退回纯 Canvas 2D 的方位图**
|
|
109
|
+
(不依赖任何 GL,任何无头环境都能画):
|
|
110
|
+
|
|
111
|
+
- 中心是玩家本人(带朝向箭头),周围 30 km 内的其他玩家按真实相对方位与距离落点并标注名字/距离;
|
|
112
|
+
- 环形量程 10 / 20 / 30 km、十字方位轴、玻璃质感信息栏(速度 / 朝向 / 车型 / 所在城市 / ID);
|
|
113
|
+
- 与真实地图的差别:**没有路网**,只有相对方位关系(页面右下角标注「方位图模式」)。
|
|
114
|
+
|
|
115
|
+
一旦 WebGL 可用(例如按上面配好启动参数),会自动改回真实地图,无需改任何配置。
|
|
116
|
+
|
|
81
117
|
## License
|
|
82
118
|
|
|
83
119
|
[MIT](./LICENSE)
|