remoteMapConfig.ts 46 KB

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