remoteMapConfig.ts 37 KB

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