remoteMapConfig.ts 48 KB

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