remoteMapConfig.ts 34 KB

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