remoteMapConfig.ts 37 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052
  1. import { lonLatToWorldTile, webMercatorToLonLat, type LonLatPoint } from './projection'
  2. import { parseOrienteeringCourseKml, type OrienteeringCourseData } from './orienteeringCourse'
  3. import { mergeGameAudioConfig, type AudioCueKey, type GameAudioConfig, type GameAudioConfigOverrides, type PartialAudioCueConfig } from '../game/audio/audioConfig'
  4. import { mergeTelemetryConfig, type TelemetryConfig } from '../game/telemetry/telemetryConfig'
  5. import {
  6. mergeGameHapticsConfig,
  7. mergeGameUiEffectsConfig,
  8. type FeedbackCueKey,
  9. type GameHapticsConfig,
  10. type GameHapticsConfigOverrides,
  11. type GameUiEffectsConfig,
  12. type GameUiEffectsConfigOverrides,
  13. type PartialHapticCueConfig,
  14. type PartialUiCueConfig,
  15. } from '../game/feedback/feedbackConfig'
  16. export interface TileZoomBounds {
  17. minX: number
  18. maxX: number
  19. minY: number
  20. maxY: number
  21. }
  22. export interface RemoteMapConfig {
  23. tileSource: string
  24. minZoom: number
  25. maxZoom: number
  26. defaultZoom: number
  27. initialCenterTileX: number
  28. initialCenterTileY: number
  29. projection: string
  30. projectionModeText: string
  31. magneticDeclinationDeg: number
  32. magneticDeclinationText: string
  33. tileFormat: string
  34. tileSize: number
  35. bounds: [number, number, number, number] | null
  36. tileBoundsByZoom: Record<number, TileZoomBounds>
  37. mapMetaUrl: string
  38. mapRootUrl: string
  39. courseUrl: string | null
  40. course: OrienteeringCourseData | null
  41. courseStatusText: string
  42. cpRadiusMeters: number
  43. gameMode: 'classic-sequential' | 'score-o'
  44. punchPolicy: 'enter' | 'enter-confirm'
  45. punchRadiusMeters: number
  46. autoFinishOnLastControl: boolean
  47. telemetryConfig: TelemetryConfig
  48. audioConfig: GameAudioConfig
  49. hapticsConfig: GameHapticsConfig
  50. uiEffectsConfig: GameUiEffectsConfig
  51. }
  52. interface ParsedGameConfig {
  53. mapRoot: string
  54. mapMeta: string
  55. course: string | null
  56. cpRadiusMeters: number
  57. gameMode: 'classic-sequential' | 'score-o'
  58. punchPolicy: 'enter' | 'enter-confirm'
  59. punchRadiusMeters: number
  60. autoFinishOnLastControl: boolean
  61. telemetryConfig: TelemetryConfig
  62. audioConfig: GameAudioConfig
  63. hapticsConfig: GameHapticsConfig
  64. uiEffectsConfig: GameUiEffectsConfig
  65. declinationDeg: number
  66. }
  67. interface ParsedMapMeta {
  68. tileSize: number
  69. minZoom: number
  70. maxZoom: number
  71. projection: string
  72. tileFormat: string
  73. tilePathTemplate: string
  74. bounds: [number, number, number, number] | null
  75. }
  76. function requestTextViaRequest(url: string): Promise<string> {
  77. return new Promise((resolve, reject) => {
  78. wx.request({
  79. url,
  80. method: 'GET',
  81. responseType: 'text' as any,
  82. success: (response) => {
  83. if (response.statusCode !== 200) {
  84. reject(new Error(`request失败: ${response.statusCode} ${url}`))
  85. return
  86. }
  87. if (typeof response.data === 'string') {
  88. resolve(response.data)
  89. return
  90. }
  91. resolve(JSON.stringify(response.data))
  92. },
  93. fail: () => {
  94. reject(new Error(`request失败: ${url}`))
  95. },
  96. })
  97. })
  98. }
  99. function requestTextViaDownload(url: string): Promise<string> {
  100. return new Promise((resolve, reject) => {
  101. const fileSystemManager = wx.getFileSystemManager()
  102. wx.downloadFile({
  103. url,
  104. success: (response) => {
  105. if (response.statusCode !== 200 || !response.tempFilePath) {
  106. reject(new Error(`download失败: ${response.statusCode} ${url}`))
  107. return
  108. }
  109. fileSystemManager.readFile({
  110. filePath: response.tempFilePath,
  111. encoding: 'utf8',
  112. success: (readResult) => {
  113. if (typeof readResult.data === 'string') {
  114. resolve(readResult.data)
  115. return
  116. }
  117. reject(new Error(`read失败: ${url}`))
  118. },
  119. fail: () => {
  120. reject(new Error(`read失败: ${url}`))
  121. },
  122. })
  123. },
  124. fail: () => {
  125. reject(new Error(`download失败: ${url}`))
  126. },
  127. })
  128. })
  129. }
  130. async function requestText(url: string): Promise<string> {
  131. try {
  132. return await requestTextViaRequest(url)
  133. } catch (requestError) {
  134. try {
  135. return await requestTextViaDownload(url)
  136. } catch (downloadError) {
  137. const requestMessage = requestError instanceof Error ? requestError.message : 'request失败'
  138. const downloadMessage = downloadError instanceof Error ? downloadError.message : 'download失败'
  139. throw new Error(`${requestMessage}; ${downloadMessage}`)
  140. }
  141. }
  142. }
  143. function clamp(value: number, min: number, max: number): number {
  144. return Math.max(min, Math.min(max, value))
  145. }
  146. function resolveUrl(baseUrl: string, relativePath: string): string {
  147. if (/^https?:\/\//i.test(relativePath)) {
  148. return relativePath
  149. }
  150. const originMatch = baseUrl.match(/^(https?:\/\/[^/]+)/i)
  151. const origin = originMatch ? originMatch[1] : ''
  152. if (relativePath.startsWith('/')) {
  153. return `${origin}${relativePath}`
  154. }
  155. const baseDir = baseUrl.slice(0, baseUrl.lastIndexOf('/') + 1)
  156. const normalizedRelativePath = relativePath.replace(/^\.\//, '')
  157. return `${baseDir}${normalizedRelativePath}`
  158. }
  159. function formatDeclinationText(declinationDeg: number): string {
  160. const suffix = declinationDeg < 0 ? 'W' : 'E'
  161. return `${Math.abs(declinationDeg).toFixed(2)}° ${suffix}`
  162. }
  163. function parseDeclinationValue(rawValue: unknown): number {
  164. const numericValue = Number(rawValue)
  165. return Number.isFinite(numericValue) ? -Math.abs(numericValue) : -6.91
  166. }
  167. function parsePositiveNumber(rawValue: unknown, fallbackValue: number): number {
  168. const numericValue = Number(rawValue)
  169. return Number.isFinite(numericValue) && numericValue > 0 ? numericValue : fallbackValue
  170. }
  171. function parseBoolean(rawValue: unknown, fallbackValue: boolean): boolean {
  172. if (typeof rawValue === 'boolean') {
  173. return rawValue
  174. }
  175. if (typeof rawValue === 'string') {
  176. const normalized = rawValue.trim().toLowerCase()
  177. if (normalized === 'true') {
  178. return true
  179. }
  180. if (normalized === 'false') {
  181. return false
  182. }
  183. }
  184. return fallbackValue
  185. }
  186. function parsePunchPolicy(rawValue: unknown): 'enter' | 'enter-confirm' {
  187. return rawValue === 'enter' ? 'enter' : 'enter-confirm'
  188. }
  189. function parseGameMode(rawValue: unknown): 'classic-sequential' | 'score-o' {
  190. if (typeof rawValue !== 'string') {
  191. return 'classic-sequential'
  192. }
  193. const normalized = rawValue.trim().toLowerCase()
  194. if (normalized === 'classic-sequential' || normalized === 'classic' || normalized === 'sequential') {
  195. return 'classic-sequential'
  196. }
  197. if (normalized === 'score-o' || normalized === 'scoreo' || normalized === 'score') {
  198. return 'score-o'
  199. }
  200. throw new Error(`暂不支持的 game.mode: ${rawValue}`)
  201. }
  202. function parseTelemetryConfig(rawValue: unknown): TelemetryConfig {
  203. const normalized = normalizeObjectRecord(rawValue)
  204. if (!Object.keys(normalized).length) {
  205. return mergeTelemetryConfig()
  206. }
  207. const rawHeartRate = getFirstDefined(normalized, ['heartrate', 'heart_rate'])
  208. const normalizedHeartRate = normalizeObjectRecord(rawHeartRate)
  209. const ageRaw = getFirstDefined(normalizedHeartRate, ['age', 'userage', 'heartrateage', 'hrage']) !== undefined
  210. ? getFirstDefined(normalizedHeartRate, ['age', 'userage', 'heartrateage', 'hrage'])
  211. : getFirstDefined(normalized, ['age', 'userage', 'heartrateage', 'hrage'])
  212. const restingHeartRateRaw = getFirstDefined(normalizedHeartRate, ['restingheartratebpm', 'restingheartrate', 'restinghr', 'resting'])
  213. !== undefined
  214. ? getFirstDefined(normalizedHeartRate, ['restingheartratebpm', 'restingheartrate', 'restinghr', 'resting'])
  215. : getFirstDefined(normalized, ['restingheartratebpm', 'restingheartrate', 'restinghr', 'resting'])
  216. const userWeightRaw = getFirstDefined(normalizedHeartRate, ['userweightkg', 'weightkg', 'weight'])
  217. !== undefined
  218. ? getFirstDefined(normalizedHeartRate, ['userweightkg', 'weightkg', 'weight'])
  219. : getFirstDefined(normalized, ['userweightkg', 'weightkg', 'weight'])
  220. const telemetryOverrides: Partial<TelemetryConfig> = {}
  221. if (ageRaw !== undefined) {
  222. telemetryOverrides.heartRateAge = Number(ageRaw)
  223. }
  224. if (restingHeartRateRaw !== undefined) {
  225. telemetryOverrides.restingHeartRateBpm = Number(restingHeartRateRaw)
  226. }
  227. if (userWeightRaw !== undefined) {
  228. telemetryOverrides.userWeightKg = Number(userWeightRaw)
  229. }
  230. return mergeTelemetryConfig(telemetryOverrides)
  231. }
  232. function normalizeObjectRecord(rawValue: unknown): Record<string, unknown> {
  233. if (!rawValue || typeof rawValue !== 'object' || Array.isArray(rawValue)) {
  234. return {}
  235. }
  236. const normalized: Record<string, unknown> = {}
  237. const keys = Object.keys(rawValue as Record<string, unknown>)
  238. for (const key of keys) {
  239. normalized[key.toLowerCase()] = (rawValue as Record<string, unknown>)[key]
  240. }
  241. return normalized
  242. }
  243. function getFirstDefined(record: Record<string, unknown>, keys: string[]): unknown {
  244. for (const key of keys) {
  245. if (record[key] !== undefined) {
  246. return record[key]
  247. }
  248. }
  249. return undefined
  250. }
  251. function resolveAudioSrc(baseUrl: string, rawValue: unknown): string | undefined {
  252. if (typeof rawValue !== 'string') {
  253. return undefined
  254. }
  255. const trimmed = rawValue.trim()
  256. if (!trimmed) {
  257. return undefined
  258. }
  259. if (/^https?:\/\//i.test(trimmed)) {
  260. return trimmed
  261. }
  262. if (trimmed.startsWith('/assets/')) {
  263. return trimmed
  264. }
  265. if (trimmed.startsWith('assets/')) {
  266. return `/${trimmed}`
  267. }
  268. return resolveUrl(baseUrl, trimmed)
  269. }
  270. function buildAudioCueOverride(rawValue: unknown, baseUrl: string): PartialAudioCueConfig | null {
  271. if (typeof rawValue === 'string') {
  272. const src = resolveAudioSrc(baseUrl, rawValue)
  273. return src ? { src } : null
  274. }
  275. const normalized = normalizeObjectRecord(rawValue)
  276. if (!Object.keys(normalized).length) {
  277. return null
  278. }
  279. const src = resolveAudioSrc(baseUrl, getFirstDefined(normalized, ['src', 'url', 'path']))
  280. const volumeRaw = getFirstDefined(normalized, ['volume'])
  281. const loopRaw = getFirstDefined(normalized, ['loop'])
  282. const loopGapRaw = getFirstDefined(normalized, ['loopgapms', 'loopgap'])
  283. const cue: PartialAudioCueConfig = {}
  284. if (src) {
  285. cue.src = src
  286. }
  287. if (volumeRaw !== undefined) {
  288. cue.volume = parsePositiveNumber(volumeRaw, 1)
  289. }
  290. if (loopRaw !== undefined) {
  291. cue.loop = parseBoolean(loopRaw, false)
  292. }
  293. if (loopGapRaw !== undefined) {
  294. cue.loopGapMs = parsePositiveNumber(loopGapRaw, 0)
  295. }
  296. return cue.src || cue.volume !== undefined || cue.loop !== undefined || cue.loopGapMs !== undefined ? cue : null
  297. }
  298. function parseAudioConfig(rawValue: unknown, baseUrl: string): GameAudioConfig {
  299. const normalized = normalizeObjectRecord(rawValue)
  300. if (!Object.keys(normalized).length) {
  301. return mergeGameAudioConfig()
  302. }
  303. const normalizedCues = normalizeObjectRecord(getFirstDefined(normalized, ['cues', 'events']))
  304. const cueMap: Array<{ key: AudioCueKey; aliases: string[] }> = [
  305. { key: 'session_started', aliases: ['session_started', 'sessionstarted', 'session-started', 'start', 'session_start'] },
  306. { key: 'control_completed:start', aliases: ['control_completed:start', 'controlcompleted:start', 'start_completed', 'startcomplete', 'start-complete'] },
  307. { key: 'control_completed:control', aliases: ['control_completed:control', 'controlcompleted:control', 'control_completed', 'controlcompleted', 'control_complete', 'controlcomplete'] },
  308. { key: 'control_completed:finish', aliases: ['control_completed:finish', 'controlcompleted:finish', 'finish_completed', 'finishcomplete', 'finish-complete'] },
  309. { key: 'punch_feedback:warning', aliases: ['punch_feedback:warning', 'punchfeedback:warning', 'warning', 'punch_warning', 'punchwarning'] },
  310. { key: 'guidance:searching', aliases: ['guidance:searching', 'guidance_searching', 'searching', 'search', 'normal_search'] },
  311. { key: 'guidance:approaching', aliases: ['guidance:approaching', 'guidance_approaching', 'approaching', 'approach', 'near'] },
  312. { key: 'guidance:ready', aliases: ['guidance:ready', 'guidance_ready', 'ready', 'punch_ready', 'can_punch'] },
  313. ]
  314. const cues: GameAudioConfigOverrides['cues'] = {}
  315. for (const cueDef of cueMap) {
  316. const cueRaw = getFirstDefined(normalizedCues, cueDef.aliases)
  317. const cue = buildAudioCueOverride(cueRaw, baseUrl)
  318. if (cue) {
  319. cues[cueDef.key] = cue
  320. }
  321. }
  322. return mergeGameAudioConfig({
  323. enabled: normalized.enabled !== undefined ? parseBoolean(normalized.enabled, true) : undefined,
  324. masterVolume: normalized.mastervolume !== undefined
  325. ? parsePositiveNumber(normalized.mastervolume, 1)
  326. : normalized.volume !== undefined
  327. ? parsePositiveNumber(normalized.volume, 1)
  328. : undefined,
  329. obeyMuteSwitch: normalized.obeymuteswitch !== undefined ? parseBoolean(normalized.obeymuteswitch, true) : undefined,
  330. approachDistanceMeters: normalized.approachdistancemeters !== undefined
  331. ? parsePositiveNumber(normalized.approachdistancemeters, 20)
  332. : normalized.approachdistance !== undefined
  333. ? parsePositiveNumber(normalized.approachdistance, 20)
  334. : undefined,
  335. cues,
  336. })
  337. }
  338. function parseLooseJsonObject(text: string): Record<string, unknown> {
  339. const parsed: Record<string, unknown> = {}
  340. const pairPattern = /"([^"]+)"\s*:\s*("([^"]*)"|-?\d+(?:\.\d+)?|true|false|null)/g
  341. let match: RegExpExecArray | null
  342. while ((match = pairPattern.exec(text))) {
  343. const rawValue = match[2]
  344. let value: unknown = rawValue
  345. if (rawValue === 'true' || rawValue === 'false') {
  346. value = rawValue === 'true'
  347. } else if (rawValue === 'null') {
  348. value = null
  349. } else if (rawValue.startsWith('"') && rawValue.endsWith('"')) {
  350. value = match[3] || ''
  351. } else {
  352. const numericValue = Number(rawValue)
  353. value = Number.isFinite(numericValue) ? numericValue : rawValue
  354. }
  355. parsed[match[1]] = value
  356. }
  357. return parsed
  358. }
  359. function parseHapticPattern(rawValue: unknown): 'short' | 'long' | undefined {
  360. if (rawValue === 'short' || rawValue === 'long') {
  361. return rawValue
  362. }
  363. if (typeof rawValue === 'string') {
  364. const normalized = rawValue.trim().toLowerCase()
  365. if (normalized === 'short' || normalized === 'long') {
  366. return normalized
  367. }
  368. }
  369. return undefined
  370. }
  371. function parsePunchFeedbackMotion(rawValue: unknown): 'none' | 'pop' | 'success' | 'warning' | undefined {
  372. if (rawValue === 'none' || rawValue === 'pop' || rawValue === 'success' || rawValue === 'warning') {
  373. return rawValue
  374. }
  375. if (typeof rawValue === 'string') {
  376. const normalized = rawValue.trim().toLowerCase()
  377. if (normalized === 'none' || normalized === 'pop' || normalized === 'success' || normalized === 'warning') {
  378. return normalized
  379. }
  380. }
  381. return undefined
  382. }
  383. function parseContentCardMotion(rawValue: unknown): 'none' | 'pop' | 'finish' | undefined {
  384. if (rawValue === 'none' || rawValue === 'pop' || rawValue === 'finish') {
  385. return rawValue
  386. }
  387. if (typeof rawValue === 'string') {
  388. const normalized = rawValue.trim().toLowerCase()
  389. if (normalized === 'none' || normalized === 'pop' || normalized === 'finish') {
  390. return normalized
  391. }
  392. }
  393. return undefined
  394. }
  395. function parsePunchButtonMotion(rawValue: unknown): 'none' | 'ready' | 'warning' | undefined {
  396. if (rawValue === 'none' || rawValue === 'ready' || rawValue === 'warning') {
  397. return rawValue
  398. }
  399. if (typeof rawValue === 'string') {
  400. const normalized = rawValue.trim().toLowerCase()
  401. if (normalized === 'none' || normalized === 'ready' || normalized === 'warning') {
  402. return normalized
  403. }
  404. }
  405. return undefined
  406. }
  407. function parseMapPulseMotion(rawValue: unknown): 'none' | 'ready' | 'control' | 'finish' | undefined {
  408. if (rawValue === 'none' || rawValue === 'ready' || rawValue === 'control' || rawValue === 'finish') {
  409. return rawValue
  410. }
  411. if (typeof rawValue === 'string') {
  412. const normalized = rawValue.trim().toLowerCase()
  413. if (normalized === 'none' || normalized === 'ready' || normalized === 'control' || normalized === 'finish') {
  414. return normalized
  415. }
  416. }
  417. return undefined
  418. }
  419. function parseStageMotion(rawValue: unknown): 'none' | 'finish' | undefined {
  420. if (rawValue === 'none' || rawValue === 'finish') {
  421. return rawValue
  422. }
  423. if (typeof rawValue === 'string') {
  424. const normalized = rawValue.trim().toLowerCase()
  425. if (normalized === 'none' || normalized === 'finish') {
  426. return normalized
  427. }
  428. }
  429. return undefined
  430. }
  431. function buildHapticsCueOverride(rawValue: unknown): PartialHapticCueConfig | null {
  432. if (typeof rawValue === 'boolean') {
  433. return { enabled: rawValue }
  434. }
  435. const pattern = parseHapticPattern(rawValue)
  436. if (pattern) {
  437. return { enabled: true, pattern }
  438. }
  439. const normalized = normalizeObjectRecord(rawValue)
  440. if (!Object.keys(normalized).length) {
  441. return null
  442. }
  443. const cue: PartialHapticCueConfig = {}
  444. if (normalized.enabled !== undefined) {
  445. cue.enabled = parseBoolean(normalized.enabled, true)
  446. }
  447. const parsedPattern = parseHapticPattern(getFirstDefined(normalized, ['pattern', 'type']))
  448. if (parsedPattern) {
  449. cue.pattern = parsedPattern
  450. }
  451. return cue.enabled !== undefined || cue.pattern !== undefined ? cue : null
  452. }
  453. function buildUiCueOverride(rawValue: unknown): PartialUiCueConfig | null {
  454. const normalized = normalizeObjectRecord(rawValue)
  455. if (!Object.keys(normalized).length) {
  456. return null
  457. }
  458. const cue: PartialUiCueConfig = {}
  459. if (normalized.enabled !== undefined) {
  460. cue.enabled = parseBoolean(normalized.enabled, true)
  461. }
  462. const punchFeedbackMotion = parsePunchFeedbackMotion(getFirstDefined(normalized, ['punchfeedbackmotion', 'feedbackmotion', 'toastmotion']))
  463. if (punchFeedbackMotion) {
  464. cue.punchFeedbackMotion = punchFeedbackMotion
  465. }
  466. const contentCardMotion = parseContentCardMotion(getFirstDefined(normalized, ['contentcardmotion', 'cardmotion']))
  467. if (contentCardMotion) {
  468. cue.contentCardMotion = contentCardMotion
  469. }
  470. const punchButtonMotion = parsePunchButtonMotion(getFirstDefined(normalized, ['punchbuttonmotion', 'buttonmotion']))
  471. if (punchButtonMotion) {
  472. cue.punchButtonMotion = punchButtonMotion
  473. }
  474. const mapPulseMotion = parseMapPulseMotion(getFirstDefined(normalized, ['mappulsemotion', 'mapmotion']))
  475. if (mapPulseMotion) {
  476. cue.mapPulseMotion = mapPulseMotion
  477. }
  478. const stageMotion = parseStageMotion(getFirstDefined(normalized, ['stagemotion', 'screenmotion']))
  479. if (stageMotion) {
  480. cue.stageMotion = stageMotion
  481. }
  482. const durationRaw = getFirstDefined(normalized, ['durationms', 'duration'])
  483. if (durationRaw !== undefined) {
  484. cue.durationMs = parsePositiveNumber(durationRaw, 0)
  485. }
  486. return cue.enabled !== undefined ||
  487. cue.punchFeedbackMotion !== undefined ||
  488. cue.contentCardMotion !== undefined ||
  489. cue.punchButtonMotion !== undefined ||
  490. cue.mapPulseMotion !== undefined ||
  491. cue.stageMotion !== undefined ||
  492. cue.durationMs !== undefined
  493. ? cue
  494. : null
  495. }
  496. function parseHapticsConfig(rawValue: unknown): GameHapticsConfig {
  497. const normalized = normalizeObjectRecord(rawValue)
  498. if (!Object.keys(normalized).length) {
  499. return mergeGameHapticsConfig()
  500. }
  501. const normalizedCues = normalizeObjectRecord(getFirstDefined(normalized, ['cues', 'events']))
  502. const cueMap: Array<{ key: FeedbackCueKey; aliases: string[] }> = [
  503. { key: 'session_started', aliases: ['session_started', 'sessionstarted', 'session-started', 'start'] },
  504. { key: 'session_finished', aliases: ['session_finished', 'sessionfinished', 'session-finished', 'finish'] },
  505. { key: 'control_completed:start', aliases: ['control_completed:start', 'start_completed', 'startcomplete', 'start-complete'] },
  506. { key: 'control_completed:control', aliases: ['control_completed:control', 'control_completed', 'controlcomplete', 'control_complete'] },
  507. { key: 'control_completed:finish', aliases: ['control_completed:finish', 'finish_completed', 'finishcomplete', 'finish-complete'] },
  508. { key: 'punch_feedback:warning', aliases: ['punch_feedback:warning', 'warning', 'punch_warning', 'punchwarning'] },
  509. { key: 'guidance:searching', aliases: ['guidance:searching', 'searching', 'search'] },
  510. { key: 'guidance:approaching', aliases: ['guidance:approaching', 'approaching', 'approach', 'near'] },
  511. { key: 'guidance:ready', aliases: ['guidance:ready', 'ready', 'punch_ready', 'can_punch'] },
  512. ]
  513. const cues: GameHapticsConfigOverrides['cues'] = {}
  514. for (const cueDef of cueMap) {
  515. const cue = buildHapticsCueOverride(getFirstDefined(normalizedCues, cueDef.aliases))
  516. if (cue) {
  517. cues[cueDef.key] = cue
  518. }
  519. }
  520. return mergeGameHapticsConfig({
  521. enabled: normalized.enabled !== undefined ? parseBoolean(normalized.enabled, true) : undefined,
  522. cues,
  523. })
  524. }
  525. function parseUiEffectsConfig(rawValue: unknown): GameUiEffectsConfig {
  526. const normalized = normalizeObjectRecord(rawValue)
  527. if (!Object.keys(normalized).length) {
  528. return mergeGameUiEffectsConfig()
  529. }
  530. const normalizedCues = normalizeObjectRecord(getFirstDefined(normalized, ['cues', 'events']))
  531. const cueMap: Array<{ key: FeedbackCueKey; aliases: string[] }> = [
  532. { key: 'session_started', aliases: ['session_started', 'sessionstarted', 'session-started', 'start'] },
  533. { key: 'session_finished', aliases: ['session_finished', 'sessionfinished', 'session-finished', 'finish'] },
  534. { key: 'control_completed:start', aliases: ['control_completed:start', 'start_completed', 'startcomplete', 'start-complete'] },
  535. { key: 'control_completed:control', aliases: ['control_completed:control', 'control_completed', 'controlcomplete', 'control_complete'] },
  536. { key: 'control_completed:finish', aliases: ['control_completed:finish', 'finish_completed', 'finishcomplete', 'finish-complete'] },
  537. { key: 'punch_feedback:warning', aliases: ['punch_feedback:warning', 'warning', 'punch_warning', 'punchwarning'] },
  538. { key: 'guidance:searching', aliases: ['guidance:searching', 'searching', 'search'] },
  539. { key: 'guidance:approaching', aliases: ['guidance:approaching', 'approaching', 'approach', 'near'] },
  540. { key: 'guidance:ready', aliases: ['guidance:ready', 'ready', 'punch_ready', 'can_punch'] },
  541. ]
  542. const cues: GameUiEffectsConfigOverrides['cues'] = {}
  543. for (const cueDef of cueMap) {
  544. const cue = buildUiCueOverride(getFirstDefined(normalizedCues, cueDef.aliases))
  545. if (cue) {
  546. cues[cueDef.key] = cue
  547. }
  548. }
  549. return mergeGameUiEffectsConfig({
  550. enabled: normalized.enabled !== undefined ? parseBoolean(normalized.enabled, true) : undefined,
  551. cues,
  552. })
  553. }
  554. function parseGameConfigFromJson(text: string, gameConfigUrl: string): ParsedGameConfig {
  555. let parsed: Record<string, unknown>
  556. try {
  557. parsed = JSON.parse(text)
  558. } catch {
  559. parsed = parseLooseJsonObject(text)
  560. }
  561. const normalized: Record<string, unknown> = {}
  562. const keys = Object.keys(parsed)
  563. for (const key of keys) {
  564. normalized[key.toLowerCase()] = parsed[key]
  565. }
  566. const rawGame = parsed.game && typeof parsed.game === 'object' && !Array.isArray(parsed.game)
  567. ? parsed.game as Record<string, unknown>
  568. : null
  569. const normalizedGame: Record<string, unknown> = {}
  570. if (rawGame) {
  571. const gameKeys = Object.keys(rawGame)
  572. for (const key of gameKeys) {
  573. normalizedGame[key.toLowerCase()] = rawGame[key]
  574. }
  575. }
  576. const rawAudio = rawGame && rawGame.audio !== undefined ? rawGame.audio : parsed.audio
  577. const rawTelemetry = rawGame && rawGame.telemetry !== undefined ? rawGame.telemetry : parsed.telemetry
  578. const rawHaptics = rawGame && rawGame.haptics !== undefined ? rawGame.haptics : parsed.haptics
  579. const rawUiEffects = rawGame && rawGame.uiEffects !== undefined
  580. ? rawGame.uiEffects
  581. : rawGame && rawGame.uieffects !== undefined
  582. ? rawGame.uieffects
  583. : rawGame && rawGame.ui !== undefined
  584. ? rawGame.ui
  585. : (parsed as Record<string, unknown>).uiEffects !== undefined
  586. ? (parsed as Record<string, unknown>).uiEffects
  587. : (parsed as Record<string, unknown>).uieffects !== undefined
  588. ? (parsed as Record<string, unknown>).uieffects
  589. : (parsed as Record<string, unknown>).ui
  590. const mapRoot = typeof normalized.map === 'string' ? normalized.map : ''
  591. const mapMeta = typeof normalized.mapmeta === 'string' ? normalized.mapmeta : ''
  592. if (!mapRoot || !mapMeta) {
  593. throw new Error('game.json 缺少 map 或 mapmeta 字段')
  594. }
  595. const modeValue = typeof normalizedGame.mode === 'string' ? normalizedGame.mode : normalized.gamemode
  596. const gameMode = parseGameMode(modeValue)
  597. return {
  598. mapRoot,
  599. mapMeta,
  600. course: typeof normalized.course === 'string' ? normalized.course : null,
  601. cpRadiusMeters: parsePositiveNumber(normalized.cpradius, 5),
  602. gameMode,
  603. punchPolicy: parsePunchPolicy(normalizedGame.punchpolicy !== undefined ? normalizedGame.punchpolicy : normalized.punchpolicy),
  604. punchRadiusMeters: parsePositiveNumber(
  605. normalizedGame.punchradiusmeters !== undefined
  606. ? normalizedGame.punchradiusmeters
  607. : normalizedGame.punchradius !== undefined
  608. ? normalizedGame.punchradius
  609. : normalized.punchradiusmeters !== undefined
  610. ? normalized.punchradiusmeters
  611. : normalized.punchradius,
  612. 5,
  613. ),
  614. autoFinishOnLastControl: parseBoolean(
  615. normalizedGame.autofinishonlastcontrol !== undefined ? normalizedGame.autofinishonlastcontrol : normalized.autofinishonlastcontrol,
  616. true,
  617. ),
  618. telemetryConfig: parseTelemetryConfig(rawTelemetry),
  619. audioConfig: parseAudioConfig(rawAudio, gameConfigUrl),
  620. hapticsConfig: parseHapticsConfig(rawHaptics),
  621. uiEffectsConfig: parseUiEffectsConfig(rawUiEffects),
  622. declinationDeg: parseDeclinationValue(normalized.declination),
  623. }
  624. }
  625. function parseGameConfigFromYaml(text: string, gameConfigUrl: string): ParsedGameConfig {
  626. const config: Record<string, string> = {}
  627. const lines = text.split(/\r?\n/)
  628. for (const rawLine of lines) {
  629. const line = rawLine.trim()
  630. if (!line || line.startsWith('#')) {
  631. continue
  632. }
  633. const match = line.match(/^([A-Za-z0-9_-]+)\s*(?:=|:)\s*(.+)$/)
  634. if (!match) {
  635. continue
  636. }
  637. config[match[1].trim().toLowerCase()] = match[2].trim()
  638. }
  639. const mapRoot = config.map
  640. const mapMeta = config.mapmeta
  641. if (!mapRoot || !mapMeta) {
  642. throw new Error('game.yaml 缺少 map 或 mapmeta 字段')
  643. }
  644. const gameMode = parseGameMode(config.gamemode)
  645. return {
  646. mapRoot,
  647. mapMeta,
  648. course: typeof config.course === 'string' ? config.course : null,
  649. cpRadiusMeters: parsePositiveNumber(config.cpradius, 5),
  650. gameMode,
  651. punchPolicy: parsePunchPolicy(config.punchpolicy),
  652. punchRadiusMeters: parsePositiveNumber(
  653. config.punchradiusmeters !== undefined ? config.punchradiusmeters : config.punchradius,
  654. 5,
  655. ),
  656. autoFinishOnLastControl: parseBoolean(config.autofinishonlastcontrol, true),
  657. telemetryConfig: parseTelemetryConfig({
  658. heartRate: {
  659. age: config.heartrateage !== undefined ? config.heartrateage : config.telemetryheartrateage,
  660. restingHeartRateBpm: config.restingheartratebpm !== undefined
  661. ? config.restingheartratebpm
  662. : config.restingheartrate !== undefined
  663. ? config.restingheartrate
  664. : config.telemetryrestingheartratebpm !== undefined
  665. ? config.telemetryrestingheartratebpm
  666. : config.telemetryrestingheartrate,
  667. },
  668. }),
  669. audioConfig: parseAudioConfig({
  670. enabled: config.audioenabled,
  671. masterVolume: config.audiomastervolume,
  672. obeyMuteSwitch: config.audioobeymuteswitch,
  673. approachDistanceMeters: config.audioapproachdistancemeters !== undefined ? config.audioapproachdistancemeters : config.audioapproachdistance,
  674. cues: {
  675. session_started: config.audiosessionstarted,
  676. 'control_completed:start': config.audiostartcomplete,
  677. 'control_completed:control': config.audiocontrolcomplete,
  678. 'control_completed:finish': config.audiofinishcomplete,
  679. 'punch_feedback:warning': config.audiowarning,
  680. 'guidance:searching': config.audiosearching,
  681. 'guidance:approaching': config.audioapproaching,
  682. 'guidance:ready': config.audioready,
  683. },
  684. }, gameConfigUrl),
  685. hapticsConfig: parseHapticsConfig({
  686. enabled: config.hapticsenabled,
  687. cues: {
  688. session_started: config.hapticsstart,
  689. session_finished: config.hapticsfinish,
  690. 'control_completed:start': config.hapticsstartcomplete,
  691. 'control_completed:control': config.hapticscontrolcomplete,
  692. 'control_completed:finish': config.hapticsfinishcomplete,
  693. 'punch_feedback:warning': config.hapticswarning,
  694. 'guidance:searching': config.hapticssearching,
  695. 'guidance:approaching': config.hapticsapproaching,
  696. 'guidance:ready': config.hapticsready,
  697. },
  698. }),
  699. uiEffectsConfig: parseUiEffectsConfig({
  700. enabled: config.uieffectsenabled,
  701. cues: {
  702. session_started: { enabled: config.uistartenabled, punchButtonMotion: config.uistartbuttonmotion },
  703. session_finished: { enabled: config.uifinishenabled, contentCardMotion: config.uifinishcardmotion },
  704. 'control_completed:start': { enabled: config.uistartcompleteenabled, contentCardMotion: config.uistartcompletecardmotion, punchFeedbackMotion: config.uistartcompletetoastmotion },
  705. 'control_completed:control': { enabled: config.uicontrolcompleteenabled, contentCardMotion: config.uicontrolcompletecardmotion, punchFeedbackMotion: config.uicontrolcompletetoastmotion },
  706. 'control_completed:finish': { enabled: config.uifinishcompleteenabled, contentCardMotion: config.uifinishcompletecardmotion, punchFeedbackMotion: config.uifinishcompletetoastmotion },
  707. 'punch_feedback:warning': { enabled: config.uiwarningenabled, punchFeedbackMotion: config.uiwarningtoastmotion, punchButtonMotion: config.uiwarningbuttonmotion, durationMs: config.uiwarningdurationms },
  708. 'guidance:ready': { enabled: config.uireadyenabled, punchButtonMotion: config.uireadybuttonmotion, durationMs: config.uireadydurationms },
  709. },
  710. }),
  711. declinationDeg: parseDeclinationValue(config.declination),
  712. }
  713. }
  714. function parseGameConfig(text: string, gameConfigUrl: string): ParsedGameConfig {
  715. const trimmedText = text.trim()
  716. const isJson =
  717. trimmedText.startsWith('{') ||
  718. trimmedText.startsWith('[') ||
  719. /\.json(?:[?#].*)?$/i.test(gameConfigUrl)
  720. return isJson ? parseGameConfigFromJson(trimmedText, gameConfigUrl) : parseGameConfigFromYaml(trimmedText, gameConfigUrl)
  721. }
  722. function extractStringField(text: string, key: string): string | null {
  723. const pattern = new RegExp(`"${key}"\\s*:\\s*"([^"]+)"`)
  724. const match = text.match(pattern)
  725. return match ? match[1] : null
  726. }
  727. function extractNumberField(text: string, key: string): number | null {
  728. const pattern = new RegExp(`"${key}"\\s*:\\s*(-?\\d+(?:\\.\\d+)?)`)
  729. const match = text.match(pattern)
  730. if (!match) {
  731. return null
  732. }
  733. const value = Number(match[1])
  734. return Number.isFinite(value) ? value : null
  735. }
  736. function extractNumberArrayField(text: string, key: string): number[] | null {
  737. const pattern = new RegExp(`"${key}"\\s*:\\s*\\[([^\\]]+)\\]`)
  738. const match = text.match(pattern)
  739. if (!match) {
  740. return null
  741. }
  742. const numberMatches = match[1].match(/-?\d+(?:\.\d+)?/g)
  743. if (!numberMatches || !numberMatches.length) {
  744. return null
  745. }
  746. const values = numberMatches
  747. .map((item) => Number(item))
  748. .filter((item) => Number.isFinite(item))
  749. return values.length ? values : null
  750. }
  751. function parseMapMeta(text: string): ParsedMapMeta {
  752. const tileSizeField = extractNumberField(text, 'tileSize')
  753. const tileSize = tileSizeField === null ? 256 : tileSizeField
  754. const minZoom = extractNumberField(text, 'minZoom')
  755. const maxZoom = extractNumberField(text, 'maxZoom')
  756. const projectionField = extractStringField(text, 'projection')
  757. const projection = projectionField === null ? 'EPSG:3857' : projectionField
  758. const tilePathTemplate = extractStringField(text, 'tilePathTemplate')
  759. const tileFormatFromField = extractStringField(text, 'tileFormat')
  760. const boundsValues = extractNumberArrayField(text, 'bounds')
  761. if (!Number.isFinite(minZoom) || !Number.isFinite(maxZoom) || !tilePathTemplate) {
  762. throw new Error('meta.json 缺少必要字段')
  763. }
  764. let tileFormat = tileFormatFromField || ''
  765. if (!tileFormat) {
  766. const extensionMatch = tilePathTemplate.match(/\.([A-Za-z0-9]+)$/)
  767. tileFormat = extensionMatch ? extensionMatch[1].toLowerCase() : 'png'
  768. }
  769. return {
  770. tileSize,
  771. minZoom: minZoom as number,
  772. maxZoom: maxZoom as number,
  773. projection,
  774. tileFormat,
  775. tilePathTemplate,
  776. bounds: boundsValues && boundsValues.length >= 4
  777. ? [boundsValues[0], boundsValues[1], boundsValues[2], boundsValues[3]]
  778. : null,
  779. }
  780. }
  781. function getBoundsCorners(
  782. bounds: [number, number, number, number],
  783. projection: string,
  784. ): { northWest: LonLatPoint; southEast: LonLatPoint; center: LonLatPoint } {
  785. if (projection === 'EPSG:3857') {
  786. const minX = bounds[0]
  787. const minY = bounds[1]
  788. const maxX = bounds[2]
  789. const maxY = bounds[3]
  790. return {
  791. northWest: webMercatorToLonLat({ x: minX, y: maxY }),
  792. southEast: webMercatorToLonLat({ x: maxX, y: minY }),
  793. center: webMercatorToLonLat({ x: (minX + maxX) / 2, y: (minY + maxY) / 2 }),
  794. }
  795. }
  796. if (projection === 'EPSG:4326') {
  797. const minLon = bounds[0]
  798. const minLat = bounds[1]
  799. const maxLon = bounds[2]
  800. const maxLat = bounds[3]
  801. return {
  802. northWest: { lon: minLon, lat: maxLat },
  803. southEast: { lon: maxLon, lat: minLat },
  804. center: { lon: (minLon + maxLon) / 2, lat: (minLat + maxLat) / 2 },
  805. }
  806. }
  807. throw new Error(`暂不支持的投影: ${projection}`)
  808. }
  809. function buildTileBoundsByZoom(
  810. bounds: [number, number, number, number] | null,
  811. projection: string,
  812. minZoom: number,
  813. maxZoom: number,
  814. ): Record<number, TileZoomBounds> {
  815. const boundsByZoom: Record<number, TileZoomBounds> = {}
  816. if (!bounds) {
  817. return boundsByZoom
  818. }
  819. const corners = getBoundsCorners(bounds, projection)
  820. for (let zoom = minZoom; zoom <= maxZoom; zoom += 1) {
  821. const northWestWorld = lonLatToWorldTile(corners.northWest, zoom)
  822. const southEastWorld = lonLatToWorldTile(corners.southEast, zoom)
  823. const minX = Math.floor(Math.min(northWestWorld.x, southEastWorld.x))
  824. const maxX = Math.ceil(Math.max(northWestWorld.x, southEastWorld.x)) - 1
  825. const minY = Math.floor(Math.min(northWestWorld.y, southEastWorld.y))
  826. const maxY = Math.ceil(Math.max(northWestWorld.y, southEastWorld.y)) - 1
  827. boundsByZoom[zoom] = {
  828. minX,
  829. maxX,
  830. minY,
  831. maxY,
  832. }
  833. }
  834. return boundsByZoom
  835. }
  836. function getProjectionModeText(projection: string): string {
  837. return `${projection} -> XYZ Tile -> Camera -> Screen`
  838. }
  839. export function isTileWithinBounds(
  840. tileBoundsByZoom: Record<number, TileZoomBounds> | null | undefined,
  841. zoom: number,
  842. x: number,
  843. y: number,
  844. ): boolean {
  845. if (!tileBoundsByZoom) {
  846. return true
  847. }
  848. const bounds = tileBoundsByZoom[zoom]
  849. if (!bounds) {
  850. return true
  851. }
  852. return x >= bounds.minX && x <= bounds.maxX && y >= bounds.minY && y <= bounds.maxY
  853. }
  854. export async function loadRemoteMapConfig(gameConfigUrl: string): Promise<RemoteMapConfig> {
  855. const gameConfigText = await requestText(gameConfigUrl)
  856. const gameConfig = parseGameConfig(gameConfigText, gameConfigUrl)
  857. const mapMetaUrl = resolveUrl(gameConfigUrl, gameConfig.mapMeta)
  858. const mapRootUrl = resolveUrl(gameConfigUrl, gameConfig.mapRoot)
  859. const courseUrl = gameConfig.course ? resolveUrl(gameConfigUrl, gameConfig.course) : null
  860. const mapMetaText = await requestText(mapMetaUrl)
  861. const mapMeta = parseMapMeta(mapMetaText)
  862. let course: OrienteeringCourseData | null = null
  863. let courseStatusText = courseUrl ? '路线待加载' : '未配置路线'
  864. if (courseUrl) {
  865. try {
  866. const courseText = await requestText(courseUrl)
  867. course = parseOrienteeringCourseKml(courseText)
  868. courseStatusText = `路线已载入 (${course.layers.controls.length} controls)`
  869. } catch (error) {
  870. const message = error instanceof Error ? error.message : '未知错误'
  871. courseStatusText = `路线加载失败: ${message}`
  872. }
  873. }
  874. const defaultZoom = clamp(17, mapMeta.minZoom, mapMeta.maxZoom)
  875. const boundsCorners = mapMeta.bounds ? getBoundsCorners(mapMeta.bounds, mapMeta.projection) : null
  876. const centerWorldTile = boundsCorners
  877. ? lonLatToWorldTile(boundsCorners.center, defaultZoom)
  878. : { x: 0, y: 0 }
  879. return {
  880. tileSource: resolveUrl(mapRootUrl, mapMeta.tilePathTemplate),
  881. minZoom: mapMeta.minZoom,
  882. maxZoom: mapMeta.maxZoom,
  883. defaultZoom,
  884. initialCenterTileX: Math.round(centerWorldTile.x),
  885. initialCenterTileY: Math.round(centerWorldTile.y),
  886. projection: mapMeta.projection,
  887. projectionModeText: getProjectionModeText(mapMeta.projection),
  888. magneticDeclinationDeg: gameConfig.declinationDeg,
  889. magneticDeclinationText: formatDeclinationText(gameConfig.declinationDeg),
  890. tileFormat: mapMeta.tileFormat,
  891. tileSize: mapMeta.tileSize,
  892. bounds: mapMeta.bounds,
  893. tileBoundsByZoom: buildTileBoundsByZoom(mapMeta.bounds, mapMeta.projection, mapMeta.minZoom, mapMeta.maxZoom),
  894. mapMetaUrl,
  895. mapRootUrl,
  896. courseUrl,
  897. course,
  898. courseStatusText,
  899. cpRadiusMeters: gameConfig.cpRadiusMeters,
  900. gameMode: gameConfig.gameMode,
  901. punchPolicy: gameConfig.punchPolicy,
  902. punchRadiusMeters: gameConfig.punchRadiusMeters,
  903. autoFinishOnLastControl: gameConfig.autoFinishOnLastControl,
  904. telemetryConfig: gameConfig.telemetryConfig,
  905. audioConfig: gameConfig.audioConfig,
  906. hapticsConfig: gameConfig.hapticsConfig,
  907. uiEffectsConfig: gameConfig.uiEffectsConfig,
  908. }
  909. }