remoteMapConfig.ts 44 KB

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