remoteMapConfig.ts 48 KB

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