remoteMapConfig.ts 73 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853
  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. import {
  21. DEFAULT_COURSE_STYLE_CONFIG,
  22. type ControlPointStyleEntry,
  23. type ControlPointStyleId,
  24. type CourseLegStyleEntry,
  25. type CourseLegStyleId,
  26. type CourseStyleConfig,
  27. type ScoreBandStyleEntry,
  28. } from '../game/presentation/courseStyleConfig'
  29. import {
  30. DEFAULT_TRACK_VISUALIZATION_CONFIG,
  31. TRACK_COLOR_PRESET_MAP,
  32. TRACK_TAIL_LENGTH_METERS,
  33. type TrackColorPreset,
  34. type TrackDisplayMode,
  35. type TrackTailLengthPreset,
  36. type TrackStyleProfile,
  37. type TrackVisualizationConfig,
  38. } from '../game/presentation/trackStyleConfig'
  39. import {
  40. DEFAULT_GPS_MARKER_STYLE_CONFIG,
  41. GPS_MARKER_COLOR_PRESET_MAP,
  42. type GpsMarkerAnimationProfile,
  43. type GpsMarkerColorPreset,
  44. type GpsMarkerSizePreset,
  45. type GpsMarkerStyleConfig,
  46. type GpsMarkerStyleId,
  47. } from '../game/presentation/gpsMarkerStyleConfig'
  48. export interface TileZoomBounds {
  49. minX: number
  50. maxX: number
  51. minY: number
  52. maxY: number
  53. }
  54. export interface RemoteMapConfig {
  55. configTitle: string
  56. configAppId: string
  57. configSchemaVersion: string
  58. configVersion: string
  59. tileSource: string
  60. minZoom: number
  61. maxZoom: number
  62. defaultZoom: number
  63. initialCenterTileX: number
  64. initialCenterTileY: number
  65. projection: string
  66. projectionModeText: string
  67. magneticDeclinationDeg: number
  68. magneticDeclinationText: string
  69. tileFormat: string
  70. tileSize: number
  71. bounds: [number, number, number, number] | null
  72. tileBoundsByZoom: Record<number, TileZoomBounds>
  73. mapMetaUrl: string
  74. mapRootUrl: string
  75. courseUrl: string | null
  76. course: OrienteeringCourseData | null
  77. courseStatusText: string
  78. cpRadiusMeters: number
  79. gameMode: 'classic-sequential' | 'score-o'
  80. punchPolicy: 'enter' | 'enter-confirm'
  81. punchRadiusMeters: number
  82. requiresFocusSelection: boolean
  83. skipEnabled: boolean
  84. skipRadiusMeters: number
  85. skipRequiresConfirm: boolean
  86. autoFinishOnLastControl: boolean
  87. controlScoreOverrides: Record<string, number>
  88. controlContentOverrides: Record<string, GameControlDisplayContentOverride>
  89. controlPointStyleOverrides: Record<string, ControlPointStyleEntry>
  90. legStyleOverrides: Record<number, CourseLegStyleEntry>
  91. defaultControlScore: number | null
  92. courseStyleConfig: CourseStyleConfig
  93. trackStyleConfig: TrackVisualizationConfig
  94. gpsMarkerStyleConfig: GpsMarkerStyleConfig
  95. telemetryConfig: TelemetryConfig
  96. audioConfig: GameAudioConfig
  97. hapticsConfig: GameHapticsConfig
  98. uiEffectsConfig: GameUiEffectsConfig
  99. }
  100. interface ParsedGameConfig {
  101. title: string
  102. appId: string
  103. schemaVersion: string
  104. version: string
  105. mapRoot: string
  106. mapMeta: string
  107. course: string | null
  108. cpRadiusMeters: number
  109. defaultZoom: number | null
  110. gameMode: 'classic-sequential' | 'score-o'
  111. punchPolicy: 'enter' | 'enter-confirm'
  112. punchRadiusMeters: number
  113. requiresFocusSelection: boolean
  114. skipEnabled: boolean
  115. skipRadiusMeters: number
  116. skipRequiresConfirm: boolean
  117. autoFinishOnLastControl: boolean
  118. controlScoreOverrides: Record<string, number>
  119. controlContentOverrides: Record<string, GameControlDisplayContentOverride>
  120. controlPointStyleOverrides: Record<string, ControlPointStyleEntry>
  121. legStyleOverrides: Record<number, CourseLegStyleEntry>
  122. defaultControlScore: number | null
  123. courseStyleConfig: CourseStyleConfig
  124. trackStyleConfig: TrackVisualizationConfig
  125. gpsMarkerStyleConfig: GpsMarkerStyleConfig
  126. telemetryConfig: TelemetryConfig
  127. audioConfig: GameAudioConfig
  128. hapticsConfig: GameHapticsConfig
  129. uiEffectsConfig: GameUiEffectsConfig
  130. declinationDeg: number
  131. }
  132. interface ParsedMapMeta {
  133. tileSize: number
  134. minZoom: number
  135. maxZoom: number
  136. projection: string
  137. tileFormat: string
  138. tilePathTemplate: string
  139. bounds: [number, number, number, number] | null
  140. }
  141. function requestTextViaRequest(url: string): Promise<string> {
  142. return new Promise((resolve, reject) => {
  143. wx.request({
  144. url,
  145. method: 'GET',
  146. responseType: 'text' as any,
  147. success: (response) => {
  148. if (response.statusCode !== 200) {
  149. reject(new Error(`request失败: ${response.statusCode} ${url}`))
  150. return
  151. }
  152. if (typeof response.data === 'string') {
  153. resolve(response.data)
  154. return
  155. }
  156. resolve(JSON.stringify(response.data))
  157. },
  158. fail: () => {
  159. reject(new Error(`request失败: ${url}`))
  160. },
  161. })
  162. })
  163. }
  164. function requestTextViaDownload(url: string): Promise<string> {
  165. return new Promise((resolve, reject) => {
  166. const fileSystemManager = wx.getFileSystemManager()
  167. wx.downloadFile({
  168. url,
  169. success: (response) => {
  170. if (response.statusCode !== 200 || !response.tempFilePath) {
  171. reject(new Error(`download失败: ${response.statusCode} ${url}`))
  172. return
  173. }
  174. fileSystemManager.readFile({
  175. filePath: response.tempFilePath,
  176. encoding: 'utf8',
  177. success: (readResult) => {
  178. if (typeof readResult.data === 'string') {
  179. resolve(readResult.data)
  180. return
  181. }
  182. reject(new Error(`read失败: ${url}`))
  183. },
  184. fail: () => {
  185. reject(new Error(`read失败: ${url}`))
  186. },
  187. })
  188. },
  189. fail: () => {
  190. reject(new Error(`download失败: ${url}`))
  191. },
  192. })
  193. })
  194. }
  195. async function requestText(url: string): Promise<string> {
  196. try {
  197. return await requestTextViaRequest(url)
  198. } catch (requestError) {
  199. try {
  200. return await requestTextViaDownload(url)
  201. } catch (downloadError) {
  202. const requestMessage = requestError instanceof Error ? requestError.message : 'request失败'
  203. const downloadMessage = downloadError instanceof Error ? downloadError.message : 'download失败'
  204. throw new Error(`${requestMessage}; ${downloadMessage}`)
  205. }
  206. }
  207. }
  208. function clamp(value: number, min: number, max: number): number {
  209. return Math.max(min, Math.min(max, value))
  210. }
  211. function resolveUrl(baseUrl: string, relativePath: string): string {
  212. if (/^https?:\/\//i.test(relativePath)) {
  213. return relativePath
  214. }
  215. const originMatch = baseUrl.match(/^(https?:\/\/[^/]+)/i)
  216. const origin = originMatch ? originMatch[1] : ''
  217. if (relativePath.startsWith('/')) {
  218. return `${origin}${relativePath}`
  219. }
  220. const baseDir = baseUrl.slice(0, baseUrl.lastIndexOf('/') + 1)
  221. const normalizedRelativePath = relativePath.replace(/^\.\//, '')
  222. return `${baseDir}${normalizedRelativePath}`
  223. }
  224. function formatDeclinationText(declinationDeg: number): string {
  225. const suffix = declinationDeg < 0 ? 'W' : 'E'
  226. return `${Math.abs(declinationDeg).toFixed(2)}° ${suffix}`
  227. }
  228. function parseDeclinationValue(rawValue: unknown): number {
  229. const numericValue = Number(rawValue)
  230. return Number.isFinite(numericValue) ? -Math.abs(numericValue) : -6.91
  231. }
  232. function parsePositiveNumber(rawValue: unknown, fallbackValue: number): number {
  233. const numericValue = Number(rawValue)
  234. return Number.isFinite(numericValue) && numericValue > 0 ? numericValue : fallbackValue
  235. }
  236. function parseNumber(rawValue: unknown, fallbackValue: number): number {
  237. const numericValue = Number(rawValue)
  238. return Number.isFinite(numericValue) ? numericValue : fallbackValue
  239. }
  240. function parseBoolean(rawValue: unknown, fallbackValue: boolean): boolean {
  241. if (typeof rawValue === 'boolean') {
  242. return rawValue
  243. }
  244. if (typeof rawValue === 'string') {
  245. const normalized = rawValue.trim().toLowerCase()
  246. if (normalized === 'true') {
  247. return true
  248. }
  249. if (normalized === 'false') {
  250. return false
  251. }
  252. }
  253. return fallbackValue
  254. }
  255. function parsePunchPolicy(rawValue: unknown): 'enter' | 'enter-confirm' {
  256. return rawValue === 'enter' ? 'enter' : 'enter-confirm'
  257. }
  258. function parseContentExperienceOverride(
  259. rawValue: unknown,
  260. baseUrl: string,
  261. ): GameContentExperienceConfigOverride | undefined {
  262. const normalized = normalizeObjectRecord(rawValue)
  263. if (!Object.keys(normalized).length) {
  264. return undefined
  265. }
  266. const typeValue = typeof normalized.type === 'string' ? normalized.type.trim().toLowerCase() : ''
  267. if (typeValue === 'native') {
  268. return {
  269. type: 'native',
  270. fallback: 'native',
  271. presentation: 'sheet',
  272. }
  273. }
  274. if (typeValue !== 'h5') {
  275. return undefined
  276. }
  277. const rawUrl = typeof normalized.url === 'string' ? normalized.url.trim() : ''
  278. if (!rawUrl) {
  279. return undefined
  280. }
  281. const bridgeValue = typeof normalized.bridge === 'string' && normalized.bridge.trim()
  282. ? normalized.bridge.trim()
  283. : 'content-v1'
  284. const rawPresentation = typeof normalized.presentation === 'string'
  285. ? normalized.presentation.trim().toLowerCase()
  286. : ''
  287. const presentationValue = rawPresentation === 'dialog' || rawPresentation === 'fullscreen'
  288. ? rawPresentation
  289. : 'sheet'
  290. return {
  291. type: 'h5',
  292. url: resolveUrl(baseUrl, rawUrl),
  293. bridge: bridgeValue,
  294. fallback: 'native',
  295. presentation: presentationValue,
  296. }
  297. }
  298. function parseGameMode(rawValue: unknown): 'classic-sequential' | 'score-o' {
  299. if (typeof rawValue !== 'string') {
  300. return 'classic-sequential'
  301. }
  302. const normalized = rawValue.trim().toLowerCase()
  303. if (normalized === 'classic-sequential' || normalized === 'classic' || normalized === 'sequential') {
  304. return 'classic-sequential'
  305. }
  306. if (normalized === 'score-o' || normalized === 'scoreo' || normalized === 'score') {
  307. return 'score-o'
  308. }
  309. throw new Error(`暂不支持的 game.mode: ${rawValue}`)
  310. }
  311. function parseTrackDisplayMode(rawValue: unknown, fallbackValue: TrackDisplayMode): TrackDisplayMode {
  312. if (rawValue === 'none' || rawValue === 'full' || rawValue === 'tail') {
  313. return rawValue
  314. }
  315. if (typeof rawValue === 'string') {
  316. const normalized = rawValue.trim().toLowerCase()
  317. if (normalized === 'none' || normalized === 'full' || normalized === 'tail') {
  318. return normalized
  319. }
  320. }
  321. return fallbackValue
  322. }
  323. function parseTrackStyleProfile(rawValue: unknown, fallbackValue: TrackStyleProfile): TrackStyleProfile {
  324. if (rawValue === 'classic' || rawValue === 'neon') {
  325. return rawValue
  326. }
  327. if (typeof rawValue === 'string') {
  328. const normalized = rawValue.trim().toLowerCase()
  329. if (normalized === 'classic' || normalized === 'neon') {
  330. return normalized
  331. }
  332. }
  333. return fallbackValue
  334. }
  335. function parseTrackTailLengthPreset(rawValue: unknown, fallbackValue: TrackTailLengthPreset): TrackTailLengthPreset {
  336. if (rawValue === 'short' || rawValue === 'medium' || rawValue === 'long') {
  337. return rawValue
  338. }
  339. if (typeof rawValue === 'string') {
  340. const normalized = rawValue.trim().toLowerCase()
  341. if (normalized === 'short' || normalized === 'medium' || normalized === 'long') {
  342. return normalized
  343. }
  344. }
  345. return fallbackValue
  346. }
  347. function parseTrackColorPreset(rawValue: unknown, fallbackValue: TrackColorPreset): TrackColorPreset {
  348. if (
  349. rawValue === 'mint'
  350. || rawValue === 'cyan'
  351. || rawValue === 'sky'
  352. || rawValue === 'blue'
  353. || rawValue === 'violet'
  354. || rawValue === 'pink'
  355. || rawValue === 'orange'
  356. || rawValue === 'yellow'
  357. ) {
  358. return rawValue
  359. }
  360. if (typeof rawValue === 'string') {
  361. const normalized = rawValue.trim().toLowerCase()
  362. if (
  363. normalized === 'mint'
  364. || normalized === 'cyan'
  365. || normalized === 'sky'
  366. || normalized === 'blue'
  367. || normalized === 'violet'
  368. || normalized === 'pink'
  369. || normalized === 'orange'
  370. || normalized === 'yellow'
  371. ) {
  372. return normalized
  373. }
  374. }
  375. return fallbackValue
  376. }
  377. function parseTrackVisualizationConfig(rawValue: unknown): TrackVisualizationConfig {
  378. const normalized = normalizeObjectRecord(rawValue)
  379. if (!Object.keys(normalized).length) {
  380. return DEFAULT_TRACK_VISUALIZATION_CONFIG
  381. }
  382. const fallback = DEFAULT_TRACK_VISUALIZATION_CONFIG
  383. const tailLength = parseTrackTailLengthPreset(getFirstDefined(normalized, ['taillength', 'tailpreset']), fallback.tailLength)
  384. const colorPreset = parseTrackColorPreset(getFirstDefined(normalized, ['colorpreset', 'palette']), fallback.colorPreset)
  385. const presetColors = TRACK_COLOR_PRESET_MAP[colorPreset]
  386. const rawTailMeters = getFirstDefined(normalized, ['tailmeters'])
  387. const rawColorHex = getFirstDefined(normalized, ['color', 'colorhex'])
  388. const rawHeadColorHex = getFirstDefined(normalized, ['headcolor', 'headcolorhex'])
  389. return {
  390. mode: parseTrackDisplayMode(getFirstDefined(normalized, ['mode']), fallback.mode),
  391. style: parseTrackStyleProfile(getFirstDefined(normalized, ['style', 'profile']), fallback.style),
  392. tailLength,
  393. colorPreset,
  394. tailMeters: rawTailMeters !== undefined
  395. ? parsePositiveNumber(rawTailMeters, TRACK_TAIL_LENGTH_METERS[tailLength])
  396. : TRACK_TAIL_LENGTH_METERS[tailLength],
  397. tailMaxSeconds: parsePositiveNumber(getFirstDefined(normalized, ['tailmaxseconds', 'maxseconds']), fallback.tailMaxSeconds),
  398. fadeOutWhenStill: parseBoolean(getFirstDefined(normalized, ['fadeoutwhenstill', 'fadewhenstill']), fallback.fadeOutWhenStill),
  399. stillSpeedKmh: parsePositiveNumber(getFirstDefined(normalized, ['stillspeedkmh', 'stillspeed']), fallback.stillSpeedKmh),
  400. fadeOutDurationMs: parsePositiveNumber(getFirstDefined(normalized, ['fadeoutdurationms', 'fadeoutms']), fallback.fadeOutDurationMs),
  401. colorHex: normalizeHexColor(rawColorHex, presetColors.colorHex),
  402. headColorHex: normalizeHexColor(rawHeadColorHex, presetColors.headColorHex),
  403. widthPx: parsePositiveNumber(getFirstDefined(normalized, ['widthpx', 'width']), fallback.widthPx),
  404. headWidthPx: parsePositiveNumber(getFirstDefined(normalized, ['headwidthpx', 'headwidth']), fallback.headWidthPx),
  405. glowStrength: clamp(parseNumber(getFirstDefined(normalized, ['glowstrength']), fallback.glowStrength), 0, 1.5),
  406. }
  407. }
  408. function parseGpsMarkerStyleId(rawValue: unknown, fallbackValue: GpsMarkerStyleId): GpsMarkerStyleId {
  409. if (rawValue === 'dot' || rawValue === 'beacon' || rawValue === 'disc' || rawValue === 'badge') {
  410. return rawValue
  411. }
  412. if (typeof rawValue === 'string') {
  413. const normalized = rawValue.trim().toLowerCase()
  414. if (normalized === 'dot' || normalized === 'beacon' || normalized === 'disc' || normalized === 'badge') {
  415. return normalized
  416. }
  417. }
  418. return fallbackValue
  419. }
  420. function parseGpsMarkerSizePreset(rawValue: unknown, fallbackValue: GpsMarkerSizePreset): GpsMarkerSizePreset {
  421. if (rawValue === 'small' || rawValue === 'medium' || rawValue === 'large') {
  422. return rawValue
  423. }
  424. if (typeof rawValue === 'string') {
  425. const normalized = rawValue.trim().toLowerCase()
  426. if (normalized === 'small' || normalized === 'medium' || normalized === 'large') {
  427. return normalized
  428. }
  429. }
  430. return fallbackValue
  431. }
  432. function parseGpsMarkerColorPreset(rawValue: unknown, fallbackValue: GpsMarkerColorPreset): GpsMarkerColorPreset {
  433. if (
  434. rawValue === 'mint'
  435. || rawValue === 'cyan'
  436. || rawValue === 'sky'
  437. || rawValue === 'blue'
  438. || rawValue === 'violet'
  439. || rawValue === 'pink'
  440. || rawValue === 'orange'
  441. || rawValue === 'yellow'
  442. ) {
  443. return rawValue
  444. }
  445. if (typeof rawValue === 'string') {
  446. const normalized = rawValue.trim().toLowerCase()
  447. if (
  448. normalized === 'mint'
  449. || normalized === 'cyan'
  450. || normalized === 'sky'
  451. || normalized === 'blue'
  452. || normalized === 'violet'
  453. || normalized === 'pink'
  454. || normalized === 'orange'
  455. || normalized === 'yellow'
  456. ) {
  457. return normalized
  458. }
  459. }
  460. return fallbackValue
  461. }
  462. function parseGpsMarkerAnimationProfile(
  463. rawValue: unknown,
  464. fallbackValue: GpsMarkerAnimationProfile,
  465. ): GpsMarkerAnimationProfile {
  466. if (rawValue === 'minimal' || rawValue === 'dynamic-runner' || rawValue === 'warning-reactive') {
  467. return rawValue
  468. }
  469. return fallbackValue
  470. }
  471. function parseGpsMarkerStyleConfig(rawValue: unknown): GpsMarkerStyleConfig {
  472. const normalized = normalizeObjectRecord(rawValue)
  473. if (!Object.keys(normalized).length) {
  474. return DEFAULT_GPS_MARKER_STYLE_CONFIG
  475. }
  476. const fallback = DEFAULT_GPS_MARKER_STYLE_CONFIG
  477. const colorPreset = parseGpsMarkerColorPreset(getFirstDefined(normalized, ['colorpreset', 'palette']), fallback.colorPreset)
  478. const presetColors = GPS_MARKER_COLOR_PRESET_MAP[colorPreset]
  479. const rawColorHex = getFirstDefined(normalized, ['color', 'colorhex'])
  480. const rawRingColorHex = getFirstDefined(normalized, ['ringcolor', 'ringcolorhex'])
  481. const rawIndicatorColorHex = getFirstDefined(normalized, ['indicatorcolor', 'indicatorcolorhex'])
  482. return {
  483. visible: parseBoolean(getFirstDefined(normalized, ['visible', 'show']), fallback.visible),
  484. style: parseGpsMarkerStyleId(getFirstDefined(normalized, ['style', 'profile']), fallback.style),
  485. size: parseGpsMarkerSizePreset(getFirstDefined(normalized, ['size']), fallback.size),
  486. colorPreset,
  487. colorHex: typeof rawColorHex === 'string' && rawColorHex.trim() ? rawColorHex.trim() : presetColors.colorHex,
  488. ringColorHex: typeof rawRingColorHex === 'string' && rawRingColorHex.trim() ? rawRingColorHex.trim() : presetColors.ringColorHex,
  489. indicatorColorHex: typeof rawIndicatorColorHex === 'string' && rawIndicatorColorHex.trim() ? rawIndicatorColorHex.trim() : presetColors.indicatorColorHex,
  490. showHeadingIndicator: parseBoolean(getFirstDefined(normalized, ['showheadingindicator', 'showindicator']), fallback.showHeadingIndicator),
  491. animationProfile: parseGpsMarkerAnimationProfile(
  492. getFirstDefined(normalized, ['animationprofile', 'motionprofile']),
  493. fallback.animationProfile,
  494. ),
  495. motionState: fallback.motionState,
  496. motionIntensity: fallback.motionIntensity,
  497. pulseStrength: fallback.pulseStrength,
  498. headingAlpha: fallback.headingAlpha,
  499. effectScale: fallback.effectScale,
  500. wakeStrength: fallback.wakeStrength,
  501. warningGlowStrength: fallback.warningGlowStrength,
  502. indicatorScale: fallback.indicatorScale,
  503. logoScale: fallback.logoScale,
  504. logoUrl: typeof getFirstDefined(normalized, ['logourl']) === 'string' ? String(getFirstDefined(normalized, ['logourl'])).trim() : '',
  505. logoMode: 'center-badge',
  506. }
  507. }
  508. function parseTelemetryConfig(rawValue: unknown): TelemetryConfig {
  509. const normalized = normalizeObjectRecord(rawValue)
  510. if (!Object.keys(normalized).length) {
  511. return mergeTelemetryConfig()
  512. }
  513. const rawHeartRate = getFirstDefined(normalized, ['heartrate', 'heart_rate'])
  514. const normalizedHeartRate = normalizeObjectRecord(rawHeartRate)
  515. const ageRaw = getFirstDefined(normalizedHeartRate, ['age', 'userage', 'heartrateage', 'hrage']) !== undefined
  516. ? getFirstDefined(normalizedHeartRate, ['age', 'userage', 'heartrateage', 'hrage'])
  517. : getFirstDefined(normalized, ['age', 'userage', 'heartrateage', 'hrage'])
  518. const restingHeartRateRaw = getFirstDefined(normalizedHeartRate, ['restingheartratebpm', 'restingheartrate', 'restinghr', 'resting'])
  519. !== undefined
  520. ? getFirstDefined(normalizedHeartRate, ['restingheartratebpm', 'restingheartrate', 'restinghr', 'resting'])
  521. : getFirstDefined(normalized, ['restingheartratebpm', 'restingheartrate', 'restinghr', 'resting'])
  522. const userWeightRaw = getFirstDefined(normalizedHeartRate, ['userweightkg', 'weightkg', 'weight'])
  523. !== undefined
  524. ? getFirstDefined(normalizedHeartRate, ['userweightkg', 'weightkg', 'weight'])
  525. : getFirstDefined(normalized, ['userweightkg', 'weightkg', 'weight'])
  526. const telemetryOverrides: Partial<TelemetryConfig> = {}
  527. if (ageRaw !== undefined) {
  528. telemetryOverrides.heartRateAge = Number(ageRaw)
  529. }
  530. if (restingHeartRateRaw !== undefined) {
  531. telemetryOverrides.restingHeartRateBpm = Number(restingHeartRateRaw)
  532. }
  533. if (userWeightRaw !== undefined) {
  534. telemetryOverrides.userWeightKg = Number(userWeightRaw)
  535. }
  536. return mergeTelemetryConfig(telemetryOverrides)
  537. }
  538. function normalizeObjectRecord(rawValue: unknown): Record<string, unknown> {
  539. if (!rawValue || typeof rawValue !== 'object' || Array.isArray(rawValue)) {
  540. return {}
  541. }
  542. const normalized: Record<string, unknown> = {}
  543. const keys = Object.keys(rawValue as Record<string, unknown>)
  544. for (const key of keys) {
  545. normalized[key.toLowerCase()] = (rawValue as Record<string, unknown>)[key]
  546. }
  547. return normalized
  548. }
  549. function getFirstDefined(record: Record<string, unknown>, keys: string[]): unknown {
  550. for (const key of keys) {
  551. if (record[key] !== undefined) {
  552. return record[key]
  553. }
  554. }
  555. return undefined
  556. }
  557. function resolveAudioSrc(baseUrl: string, rawValue: unknown): string | undefined {
  558. if (typeof rawValue !== 'string') {
  559. return undefined
  560. }
  561. const trimmed = rawValue.trim()
  562. if (!trimmed) {
  563. return undefined
  564. }
  565. if (/^https?:\/\//i.test(trimmed)) {
  566. return trimmed
  567. }
  568. if (trimmed.startsWith('/assets/')) {
  569. return trimmed
  570. }
  571. if (trimmed.startsWith('assets/')) {
  572. return `/${trimmed}`
  573. }
  574. return resolveUrl(baseUrl, trimmed)
  575. }
  576. function buildAudioCueOverride(rawValue: unknown, baseUrl: string): PartialAudioCueConfig | null {
  577. if (typeof rawValue === 'string') {
  578. const src = resolveAudioSrc(baseUrl, rawValue)
  579. return src ? { src } : null
  580. }
  581. const normalized = normalizeObjectRecord(rawValue)
  582. if (!Object.keys(normalized).length) {
  583. return null
  584. }
  585. const src = resolveAudioSrc(baseUrl, getFirstDefined(normalized, ['src', 'url', 'path']))
  586. const volumeRaw = getFirstDefined(normalized, ['volume'])
  587. const loopRaw = getFirstDefined(normalized, ['loop'])
  588. const loopGapRaw = getFirstDefined(normalized, ['loopgapms', 'loopgap'])
  589. const cue: PartialAudioCueConfig = {}
  590. if (src) {
  591. cue.src = src
  592. }
  593. if (volumeRaw !== undefined) {
  594. cue.volume = parsePositiveNumber(volumeRaw, 1)
  595. }
  596. if (loopRaw !== undefined) {
  597. cue.loop = parseBoolean(loopRaw, false)
  598. }
  599. if (loopGapRaw !== undefined) {
  600. cue.loopGapMs = parsePositiveNumber(loopGapRaw, 0)
  601. }
  602. return cue.src || cue.volume !== undefined || cue.loop !== undefined || cue.loopGapMs !== undefined ? cue : null
  603. }
  604. function parseAudioConfig(rawValue: unknown, baseUrl: string): GameAudioConfig {
  605. const normalized = normalizeObjectRecord(rawValue)
  606. if (!Object.keys(normalized).length) {
  607. return mergeGameAudioConfig()
  608. }
  609. const normalizedCues = normalizeObjectRecord(getFirstDefined(normalized, ['cues', 'events']))
  610. const cueMap: Array<{ key: AudioCueKey; aliases: string[] }> = [
  611. { key: 'session_started', aliases: ['session_started', 'sessionstarted', 'session-started', 'start', 'session_start'] },
  612. { key: 'control_completed:start', aliases: ['control_completed:start', 'controlcompleted:start', 'start_completed', 'startcomplete', 'start-complete'] },
  613. { key: 'control_completed:control', aliases: ['control_completed:control', 'controlcompleted:control', 'control_completed', 'controlcompleted', 'control_complete', 'controlcomplete'] },
  614. { key: 'control_completed:finish', aliases: ['control_completed:finish', 'controlcompleted:finish', 'finish_completed', 'finishcomplete', 'finish-complete'] },
  615. { key: 'punch_feedback:warning', aliases: ['punch_feedback:warning', 'punchfeedback:warning', 'warning', 'punch_warning', 'punchwarning'] },
  616. { key: 'guidance:searching', aliases: ['guidance:searching', 'guidance_searching', 'searching', 'search', 'normal_search'] },
  617. { key: 'guidance:approaching', aliases: ['guidance:approaching', 'guidance_approaching', 'approaching', 'approach', 'near'] },
  618. { key: 'guidance:ready', aliases: ['guidance:ready', 'guidance_ready', 'ready', 'punch_ready', 'can_punch'] },
  619. ]
  620. const cues: GameAudioConfigOverrides['cues'] = {}
  621. for (const cueDef of cueMap) {
  622. const cueRaw = getFirstDefined(normalizedCues, cueDef.aliases)
  623. const cue = buildAudioCueOverride(cueRaw, baseUrl)
  624. if (cue) {
  625. cues[cueDef.key] = cue
  626. }
  627. }
  628. return mergeGameAudioConfig({
  629. enabled: normalized.enabled !== undefined ? parseBoolean(normalized.enabled, true) : undefined,
  630. masterVolume: normalized.mastervolume !== undefined
  631. ? parsePositiveNumber(normalized.mastervolume, 1)
  632. : normalized.volume !== undefined
  633. ? parsePositiveNumber(normalized.volume, 1)
  634. : undefined,
  635. obeyMuteSwitch: normalized.obeymuteswitch !== undefined ? parseBoolean(normalized.obeymuteswitch, true) : undefined,
  636. approachDistanceMeters: normalized.approachdistancemeters !== undefined
  637. ? parsePositiveNumber(normalized.approachdistancemeters, 20)
  638. : normalized.approachdistance !== undefined
  639. ? parsePositiveNumber(normalized.approachdistance, 20)
  640. : undefined,
  641. cues,
  642. })
  643. }
  644. function parseLooseJsonObject(text: string): Record<string, unknown> {
  645. const parsed: Record<string, unknown> = {}
  646. const pairPattern = /"([^"]+)"\s*:\s*("([^"]*)"|-?\d+(?:\.\d+)?|true|false|null)/g
  647. let match: RegExpExecArray | null
  648. while ((match = pairPattern.exec(text))) {
  649. const rawValue = match[2]
  650. let value: unknown = rawValue
  651. if (rawValue === 'true' || rawValue === 'false') {
  652. value = rawValue === 'true'
  653. } else if (rawValue === 'null') {
  654. value = null
  655. } else if (rawValue.startsWith('"') && rawValue.endsWith('"')) {
  656. value = match[3] || ''
  657. } else {
  658. const numericValue = Number(rawValue)
  659. value = Number.isFinite(numericValue) ? numericValue : rawValue
  660. }
  661. parsed[match[1]] = value
  662. }
  663. return parsed
  664. }
  665. function parseHapticPattern(rawValue: unknown): 'short' | 'long' | undefined {
  666. if (rawValue === 'short' || rawValue === 'long') {
  667. return rawValue
  668. }
  669. if (typeof rawValue === 'string') {
  670. const normalized = rawValue.trim().toLowerCase()
  671. if (normalized === 'short' || normalized === 'long') {
  672. return normalized
  673. }
  674. }
  675. return undefined
  676. }
  677. function parsePunchFeedbackMotion(rawValue: unknown): 'none' | 'pop' | 'success' | 'warning' | undefined {
  678. if (rawValue === 'none' || rawValue === 'pop' || rawValue === 'success' || rawValue === 'warning') {
  679. return rawValue
  680. }
  681. if (typeof rawValue === 'string') {
  682. const normalized = rawValue.trim().toLowerCase()
  683. if (normalized === 'none' || normalized === 'pop' || normalized === 'success' || normalized === 'warning') {
  684. return normalized
  685. }
  686. }
  687. return undefined
  688. }
  689. function parseContentCardMotion(rawValue: unknown): 'none' | 'pop' | 'finish' | undefined {
  690. if (rawValue === 'none' || rawValue === 'pop' || rawValue === 'finish') {
  691. return rawValue
  692. }
  693. if (typeof rawValue === 'string') {
  694. const normalized = rawValue.trim().toLowerCase()
  695. if (normalized === 'none' || normalized === 'pop' || normalized === 'finish') {
  696. return normalized
  697. }
  698. }
  699. return undefined
  700. }
  701. function parsePunchButtonMotion(rawValue: unknown): 'none' | 'ready' | 'warning' | undefined {
  702. if (rawValue === 'none' || rawValue === 'ready' || rawValue === 'warning') {
  703. return rawValue
  704. }
  705. if (typeof rawValue === 'string') {
  706. const normalized = rawValue.trim().toLowerCase()
  707. if (normalized === 'none' || normalized === 'ready' || normalized === 'warning') {
  708. return normalized
  709. }
  710. }
  711. return undefined
  712. }
  713. function parseMapPulseMotion(rawValue: unknown): 'none' | 'ready' | 'control' | 'finish' | undefined {
  714. if (rawValue === 'none' || rawValue === 'ready' || rawValue === 'control' || rawValue === 'finish') {
  715. return rawValue
  716. }
  717. if (typeof rawValue === 'string') {
  718. const normalized = rawValue.trim().toLowerCase()
  719. if (normalized === 'none' || normalized === 'ready' || normalized === 'control' || normalized === 'finish') {
  720. return normalized
  721. }
  722. }
  723. return undefined
  724. }
  725. function normalizeHexColor(rawValue: unknown, fallbackValue: string): string {
  726. if (typeof rawValue !== 'string') {
  727. return fallbackValue
  728. }
  729. const trimmed = rawValue.trim()
  730. if (!trimmed) {
  731. return fallbackValue
  732. }
  733. if (/^#[0-9a-fA-F]{6}$/.test(trimmed) || /^#[0-9a-fA-F]{8}$/.test(trimmed)) {
  734. return trimmed.toLowerCase()
  735. }
  736. return fallbackValue
  737. }
  738. function parseControlPointStyleId(rawValue: unknown, fallbackValue: ControlPointStyleId): ControlPointStyleId {
  739. if (rawValue === 'classic-ring' || rawValue === 'solid-dot' || rawValue === 'double-ring' || rawValue === 'badge' || rawValue === 'pulse-core') {
  740. return rawValue
  741. }
  742. return fallbackValue
  743. }
  744. function parseCourseLegStyleId(rawValue: unknown, fallbackValue: CourseLegStyleId): CourseLegStyleId {
  745. if (rawValue === 'classic-leg' || rawValue === 'dashed-leg' || rawValue === 'glow-leg' || rawValue === 'progress-leg') {
  746. return rawValue
  747. }
  748. return fallbackValue
  749. }
  750. function parseControlPointStyleEntry(rawValue: unknown, fallbackValue: ControlPointStyleEntry): ControlPointStyleEntry {
  751. const normalized = normalizeObjectRecord(rawValue)
  752. const sizeScale = parsePositiveNumber(getFirstDefined(normalized, ['sizescale']), fallbackValue.sizeScale || 1)
  753. const accentRingScale = parsePositiveNumber(getFirstDefined(normalized, ['accentringscale']), fallbackValue.accentRingScale || 0)
  754. const glowStrength = parseNumber(getFirstDefined(normalized, ['glowstrength']), fallbackValue.glowStrength || 0)
  755. const labelScale = parsePositiveNumber(getFirstDefined(normalized, ['labelscale']), fallbackValue.labelScale || 1)
  756. return {
  757. style: parseControlPointStyleId(getFirstDefined(normalized, ['style']), fallbackValue.style),
  758. colorHex: normalizeHexColor(getFirstDefined(normalized, ['color', 'colorhex']), fallbackValue.colorHex),
  759. sizeScale,
  760. accentRingScale,
  761. glowStrength: clamp(glowStrength, 0, 1.2),
  762. labelScale,
  763. labelColorHex: normalizeHexColor(getFirstDefined(normalized, ['labelcolor', 'labelcolorhex']), fallbackValue.labelColorHex || ''),
  764. }
  765. }
  766. function parseCourseLegStyleEntry(rawValue: unknown, fallbackValue: CourseLegStyleEntry): CourseLegStyleEntry {
  767. const normalized = normalizeObjectRecord(rawValue)
  768. const widthScale = parsePositiveNumber(getFirstDefined(normalized, ['widthscale']), fallbackValue.widthScale || 1)
  769. const glowStrength = parseNumber(getFirstDefined(normalized, ['glowstrength']), fallbackValue.glowStrength || 0)
  770. return {
  771. style: parseCourseLegStyleId(getFirstDefined(normalized, ['style']), fallbackValue.style),
  772. colorHex: normalizeHexColor(getFirstDefined(normalized, ['color', 'colorhex']), fallbackValue.colorHex),
  773. widthScale,
  774. glowStrength: clamp(glowStrength, 0, 1.2),
  775. }
  776. }
  777. function parseScoreBandStyleEntries(rawValue: unknown, fallbackValue: ScoreBandStyleEntry[]): ScoreBandStyleEntry[] {
  778. if (!Array.isArray(rawValue) || !rawValue.length) {
  779. return fallbackValue
  780. }
  781. const parsed: ScoreBandStyleEntry[] = []
  782. for (let index = 0; index < rawValue.length; index += 1) {
  783. const item = rawValue[index]
  784. if (!item || typeof item !== 'object' || Array.isArray(item)) {
  785. continue
  786. }
  787. const normalized = normalizeObjectRecord(item)
  788. const fallbackItem = fallbackValue[Math.min(index, fallbackValue.length - 1)]
  789. const minValue = Number(getFirstDefined(normalized, ['min']))
  790. const maxValue = Number(getFirstDefined(normalized, ['max']))
  791. parsed.push({
  792. min: Number.isFinite(minValue) ? Math.round(minValue) : fallbackItem.min,
  793. max: Number.isFinite(maxValue) ? Math.round(maxValue) : fallbackItem.max,
  794. style: parseControlPointStyleId(getFirstDefined(normalized, ['style']), fallbackItem.style),
  795. colorHex: normalizeHexColor(getFirstDefined(normalized, ['color', 'colorhex']), fallbackItem.colorHex),
  796. sizeScale: parsePositiveNumber(getFirstDefined(normalized, ['sizescale']), fallbackItem.sizeScale || 1),
  797. accentRingScale: parsePositiveNumber(getFirstDefined(normalized, ['accentringscale']), fallbackItem.accentRingScale || 0),
  798. glowStrength: clamp(parseNumber(getFirstDefined(normalized, ['glowstrength']), fallbackItem.glowStrength || 0), 0, 1.2),
  799. labelScale: parsePositiveNumber(getFirstDefined(normalized, ['labelscale']), fallbackItem.labelScale || 1),
  800. labelColorHex: normalizeHexColor(getFirstDefined(normalized, ['labelcolor', 'labelcolorhex']), fallbackItem.labelColorHex || ''),
  801. })
  802. }
  803. return parsed.length ? parsed : fallbackValue
  804. }
  805. function parseCourseStyleConfig(rawValue: unknown): CourseStyleConfig {
  806. const normalized = normalizeObjectRecord(rawValue)
  807. const sequential = normalizeObjectRecord(getFirstDefined(normalized, ['sequential', 'classicsequential', 'classic']))
  808. const sequentialControls = normalizeObjectRecord(getFirstDefined(sequential, ['controls']))
  809. const sequentialLegs = normalizeObjectRecord(getFirstDefined(sequential, ['legs']))
  810. const scoreO = normalizeObjectRecord(getFirstDefined(normalized, ['scoreo', 'score']))
  811. const scoreOControls = normalizeObjectRecord(getFirstDefined(scoreO, ['controls']))
  812. return {
  813. sequential: {
  814. controls: {
  815. default: parseControlPointStyleEntry(getFirstDefined(sequentialControls, ['default']), DEFAULT_COURSE_STYLE_CONFIG.sequential.controls.default),
  816. current: parseControlPointStyleEntry(getFirstDefined(sequentialControls, ['current', 'active']), DEFAULT_COURSE_STYLE_CONFIG.sequential.controls.current),
  817. completed: parseControlPointStyleEntry(getFirstDefined(sequentialControls, ['completed']), DEFAULT_COURSE_STYLE_CONFIG.sequential.controls.completed),
  818. skipped: parseControlPointStyleEntry(getFirstDefined(sequentialControls, ['skipped']), DEFAULT_COURSE_STYLE_CONFIG.sequential.controls.skipped),
  819. start: parseControlPointStyleEntry(getFirstDefined(sequentialControls, ['start']), DEFAULT_COURSE_STYLE_CONFIG.sequential.controls.start),
  820. finish: parseControlPointStyleEntry(getFirstDefined(sequentialControls, ['finish']), DEFAULT_COURSE_STYLE_CONFIG.sequential.controls.finish),
  821. },
  822. legs: {
  823. default: parseCourseLegStyleEntry(getFirstDefined(sequentialLegs, ['default']), DEFAULT_COURSE_STYLE_CONFIG.sequential.legs.default),
  824. completed: parseCourseLegStyleEntry(getFirstDefined(sequentialLegs, ['completed']), DEFAULT_COURSE_STYLE_CONFIG.sequential.legs.completed),
  825. },
  826. },
  827. scoreO: {
  828. controls: {
  829. default: parseControlPointStyleEntry(getFirstDefined(scoreOControls, ['default']), DEFAULT_COURSE_STYLE_CONFIG.scoreO.controls.default),
  830. focused: parseControlPointStyleEntry(getFirstDefined(scoreOControls, ['focused', 'active']), DEFAULT_COURSE_STYLE_CONFIG.scoreO.controls.focused),
  831. collected: parseControlPointStyleEntry(getFirstDefined(scoreOControls, ['collected', 'completed']), DEFAULT_COURSE_STYLE_CONFIG.scoreO.controls.collected),
  832. start: parseControlPointStyleEntry(getFirstDefined(scoreOControls, ['start']), DEFAULT_COURSE_STYLE_CONFIG.scoreO.controls.start),
  833. finish: parseControlPointStyleEntry(getFirstDefined(scoreOControls, ['finish']), DEFAULT_COURSE_STYLE_CONFIG.scoreO.controls.finish),
  834. scoreBands: parseScoreBandStyleEntries(getFirstDefined(scoreOControls, ['scorebands', 'bands']), DEFAULT_COURSE_STYLE_CONFIG.scoreO.controls.scoreBands),
  835. },
  836. },
  837. }
  838. }
  839. function parseIndexedLegOverrideKey(rawKey: string): number | null {
  840. if (typeof rawKey !== 'string') {
  841. return null
  842. }
  843. const normalized = rawKey.trim().toLowerCase()
  844. const legMatch = normalized.match(/^leg-(\d+)$/)
  845. if (legMatch) {
  846. const oneBasedIndex = Number(legMatch[1])
  847. return Number.isFinite(oneBasedIndex) && oneBasedIndex > 0 ? oneBasedIndex - 1 : null
  848. }
  849. const numericIndex = Number(normalized)
  850. return Number.isFinite(numericIndex) && numericIndex >= 0 ? Math.floor(numericIndex) : null
  851. }
  852. function parseContentCardCtas(rawValue: unknown): GameControlDisplayContentOverride['ctas'] | undefined {
  853. if (!Array.isArray(rawValue)) {
  854. return undefined
  855. }
  856. const parsed = rawValue
  857. .map((item) => {
  858. const normalized = normalizeObjectRecord(item)
  859. if (!Object.keys(normalized).length) {
  860. return null
  861. }
  862. const typeValue = typeof normalized.type === 'string' ? normalized.type.trim().toLowerCase() : ''
  863. if (typeValue !== 'detail' && typeValue !== 'photo' && typeValue !== 'audio' && typeValue !== 'quiz') {
  864. return null
  865. }
  866. const labelValue = typeof normalized.label === 'string' ? normalized.label.trim() : ''
  867. if (typeValue !== 'quiz') {
  868. return {
  869. type: typeValue as 'detail' | 'photo' | 'audio',
  870. ...(labelValue ? { label: labelValue } : {}),
  871. }
  872. }
  873. const quizRaw = {
  874. ...normalizeObjectRecord(normalized.quiz),
  875. ...(normalized.bonusScore !== undefined ? { bonusScore: normalized.bonusScore } : {}),
  876. ...(normalized.countdownSeconds !== undefined ? { countdownSeconds: normalized.countdownSeconds } : {}),
  877. ...(normalized.minValue !== undefined ? { minValue: normalized.minValue } : {}),
  878. ...(normalized.maxValue !== undefined ? { maxValue: normalized.maxValue } : {}),
  879. ...(normalized.allowSubtraction !== undefined ? { allowSubtraction: normalized.allowSubtraction } : {}),
  880. }
  881. const minValue = Number(quizRaw.minValue)
  882. const maxValue = Number(quizRaw.maxValue)
  883. const countdownSeconds = Number(quizRaw.countdownSeconds)
  884. const bonusScore = Number(quizRaw.bonusScore)
  885. return {
  886. type: 'quiz' as const,
  887. ...(labelValue ? { label: labelValue } : {}),
  888. ...(Number.isFinite(minValue) ? { minValue: Math.max(10, Math.round(minValue)) } : {}),
  889. ...(Number.isFinite(maxValue) ? { maxValue: Math.max(99, Math.round(maxValue)) } : {}),
  890. ...(typeof quizRaw.allowSubtraction === 'boolean' ? { allowSubtraction: quizRaw.allowSubtraction } : {}),
  891. ...(Number.isFinite(countdownSeconds) ? { countdownSeconds: Math.max(3, Math.round(countdownSeconds)) } : {}),
  892. ...(Number.isFinite(bonusScore) ? { bonusScore: Math.max(0, Math.round(bonusScore)) } : {}),
  893. }
  894. })
  895. .filter((item): item is NonNullable<typeof item> => !!item)
  896. return parsed.length ? parsed : undefined
  897. }
  898. function parseStageMotion(rawValue: unknown): 'none' | 'finish' | undefined {
  899. if (rawValue === 'none' || rawValue === 'finish') {
  900. return rawValue
  901. }
  902. if (typeof rawValue === 'string') {
  903. const normalized = rawValue.trim().toLowerCase()
  904. if (normalized === 'none' || normalized === 'finish') {
  905. return normalized
  906. }
  907. }
  908. return undefined
  909. }
  910. function buildHapticsCueOverride(rawValue: unknown): PartialHapticCueConfig | null {
  911. if (typeof rawValue === 'boolean') {
  912. return { enabled: rawValue }
  913. }
  914. const pattern = parseHapticPattern(rawValue)
  915. if (pattern) {
  916. return { enabled: true, pattern }
  917. }
  918. const normalized = normalizeObjectRecord(rawValue)
  919. if (!Object.keys(normalized).length) {
  920. return null
  921. }
  922. const cue: PartialHapticCueConfig = {}
  923. if (normalized.enabled !== undefined) {
  924. cue.enabled = parseBoolean(normalized.enabled, true)
  925. }
  926. const parsedPattern = parseHapticPattern(getFirstDefined(normalized, ['pattern', 'type']))
  927. if (parsedPattern) {
  928. cue.pattern = parsedPattern
  929. }
  930. return cue.enabled !== undefined || cue.pattern !== undefined ? cue : null
  931. }
  932. function buildUiCueOverride(rawValue: unknown): PartialUiCueConfig | null {
  933. const normalized = normalizeObjectRecord(rawValue)
  934. if (!Object.keys(normalized).length) {
  935. return null
  936. }
  937. const cue: PartialUiCueConfig = {}
  938. if (normalized.enabled !== undefined) {
  939. cue.enabled = parseBoolean(normalized.enabled, true)
  940. }
  941. const punchFeedbackMotion = parsePunchFeedbackMotion(getFirstDefined(normalized, ['punchfeedbackmotion', 'feedbackmotion', 'toastmotion']))
  942. if (punchFeedbackMotion) {
  943. cue.punchFeedbackMotion = punchFeedbackMotion
  944. }
  945. const contentCardMotion = parseContentCardMotion(getFirstDefined(normalized, ['contentcardmotion', 'cardmotion']))
  946. if (contentCardMotion) {
  947. cue.contentCardMotion = contentCardMotion
  948. }
  949. const punchButtonMotion = parsePunchButtonMotion(getFirstDefined(normalized, ['punchbuttonmotion', 'buttonmotion']))
  950. if (punchButtonMotion) {
  951. cue.punchButtonMotion = punchButtonMotion
  952. }
  953. const mapPulseMotion = parseMapPulseMotion(getFirstDefined(normalized, ['mappulsemotion', 'mapmotion']))
  954. if (mapPulseMotion) {
  955. cue.mapPulseMotion = mapPulseMotion
  956. }
  957. const stageMotion = parseStageMotion(getFirstDefined(normalized, ['stagemotion', 'screenmotion']))
  958. if (stageMotion) {
  959. cue.stageMotion = stageMotion
  960. }
  961. const durationRaw = getFirstDefined(normalized, ['durationms', 'duration'])
  962. if (durationRaw !== undefined) {
  963. cue.durationMs = parsePositiveNumber(durationRaw, 0)
  964. }
  965. return cue.enabled !== undefined ||
  966. cue.punchFeedbackMotion !== undefined ||
  967. cue.contentCardMotion !== undefined ||
  968. cue.punchButtonMotion !== undefined ||
  969. cue.mapPulseMotion !== undefined ||
  970. cue.stageMotion !== undefined ||
  971. cue.durationMs !== undefined
  972. ? cue
  973. : null
  974. }
  975. function parseHapticsConfig(rawValue: unknown): GameHapticsConfig {
  976. const normalized = normalizeObjectRecord(rawValue)
  977. if (!Object.keys(normalized).length) {
  978. return mergeGameHapticsConfig()
  979. }
  980. const normalizedCues = normalizeObjectRecord(getFirstDefined(normalized, ['cues', 'events']))
  981. const cueMap: Array<{ key: FeedbackCueKey; aliases: string[] }> = [
  982. { key: 'session_started', aliases: ['session_started', 'sessionstarted', 'session-started', 'start'] },
  983. { key: 'session_finished', aliases: ['session_finished', 'sessionfinished', 'session-finished', 'finish'] },
  984. { key: 'control_completed:start', aliases: ['control_completed:start', 'start_completed', 'startcomplete', 'start-complete'] },
  985. { key: 'control_completed:control', aliases: ['control_completed:control', 'control_completed', 'controlcomplete', 'control_complete'] },
  986. { key: 'control_completed:finish', aliases: ['control_completed:finish', 'finish_completed', 'finishcomplete', 'finish-complete'] },
  987. { key: 'punch_feedback:warning', aliases: ['punch_feedback:warning', 'warning', 'punch_warning', 'punchwarning'] },
  988. { key: 'guidance:searching', aliases: ['guidance:searching', 'searching', 'search'] },
  989. { key: 'guidance:approaching', aliases: ['guidance:approaching', 'approaching', 'approach', 'near'] },
  990. { key: 'guidance:ready', aliases: ['guidance:ready', 'ready', 'punch_ready', 'can_punch'] },
  991. ]
  992. const cues: GameHapticsConfigOverrides['cues'] = {}
  993. for (const cueDef of cueMap) {
  994. const cue = buildHapticsCueOverride(getFirstDefined(normalizedCues, cueDef.aliases))
  995. if (cue) {
  996. cues[cueDef.key] = cue
  997. }
  998. }
  999. return mergeGameHapticsConfig({
  1000. enabled: normalized.enabled !== undefined ? parseBoolean(normalized.enabled, true) : undefined,
  1001. cues,
  1002. })
  1003. }
  1004. function parseUiEffectsConfig(rawValue: unknown): GameUiEffectsConfig {
  1005. const normalized = normalizeObjectRecord(rawValue)
  1006. if (!Object.keys(normalized).length) {
  1007. return mergeGameUiEffectsConfig()
  1008. }
  1009. const normalizedCues = normalizeObjectRecord(getFirstDefined(normalized, ['cues', 'events']))
  1010. const cueMap: Array<{ key: FeedbackCueKey; aliases: string[] }> = [
  1011. { key: 'session_started', aliases: ['session_started', 'sessionstarted', 'session-started', 'start'] },
  1012. { key: 'session_finished', aliases: ['session_finished', 'sessionfinished', 'session-finished', 'finish'] },
  1013. { key: 'control_completed:start', aliases: ['control_completed:start', 'start_completed', 'startcomplete', 'start-complete'] },
  1014. { key: 'control_completed:control', aliases: ['control_completed:control', 'control_completed', 'controlcomplete', 'control_complete'] },
  1015. { key: 'control_completed:finish', aliases: ['control_completed:finish', 'finish_completed', 'finishcomplete', 'finish-complete'] },
  1016. { key: 'punch_feedback:warning', aliases: ['punch_feedback:warning', 'warning', 'punch_warning', 'punchwarning'] },
  1017. { key: 'guidance:searching', aliases: ['guidance:searching', 'searching', 'search'] },
  1018. { key: 'guidance:approaching', aliases: ['guidance:approaching', 'approaching', 'approach', 'near'] },
  1019. { key: 'guidance:ready', aliases: ['guidance:ready', 'ready', 'punch_ready', 'can_punch'] },
  1020. ]
  1021. const cues: GameUiEffectsConfigOverrides['cues'] = {}
  1022. for (const cueDef of cueMap) {
  1023. const cue = buildUiCueOverride(getFirstDefined(normalizedCues, cueDef.aliases))
  1024. if (cue) {
  1025. cues[cueDef.key] = cue
  1026. }
  1027. }
  1028. return mergeGameUiEffectsConfig({
  1029. enabled: normalized.enabled !== undefined ? parseBoolean(normalized.enabled, true) : undefined,
  1030. cues,
  1031. })
  1032. }
  1033. function parseGameConfigFromJson(text: string, gameConfigUrl: string): ParsedGameConfig {
  1034. let parsed: Record<string, unknown>
  1035. try {
  1036. parsed = JSON.parse(text)
  1037. } catch {
  1038. parsed = parseLooseJsonObject(text)
  1039. }
  1040. const normalized: Record<string, unknown> = {}
  1041. const keys = Object.keys(parsed)
  1042. for (const key of keys) {
  1043. normalized[key.toLowerCase()] = parsed[key]
  1044. }
  1045. const rawGame = parsed.game && typeof parsed.game === 'object' && !Array.isArray(parsed.game)
  1046. ? parsed.game as Record<string, unknown>
  1047. : null
  1048. const rawApp = parsed.app && typeof parsed.app === 'object' && !Array.isArray(parsed.app)
  1049. ? parsed.app as Record<string, unknown>
  1050. : null
  1051. const rawMap = parsed.map && typeof parsed.map === 'object' && !Array.isArray(parsed.map)
  1052. ? parsed.map as Record<string, unknown>
  1053. : null
  1054. const rawPlayfield = parsed.playfield && typeof parsed.playfield === 'object' && !Array.isArray(parsed.playfield)
  1055. ? parsed.playfield as Record<string, unknown>
  1056. : null
  1057. const rawPlayfieldSource = rawPlayfield && rawPlayfield.source && typeof rawPlayfield.source === 'object' && !Array.isArray(rawPlayfield.source)
  1058. ? rawPlayfield.source as Record<string, unknown>
  1059. : null
  1060. const rawGamePresentation = rawGame && rawGame.presentation && typeof rawGame.presentation === 'object' && !Array.isArray(rawGame.presentation)
  1061. ? rawGame.presentation as Record<string, unknown>
  1062. : null
  1063. const normalizedGamePresentation = normalizeObjectRecord(rawGamePresentation)
  1064. const normalizedGame: Record<string, unknown> = {}
  1065. if (rawGame) {
  1066. const gameKeys = Object.keys(rawGame)
  1067. for (const key of gameKeys) {
  1068. normalizedGame[key.toLowerCase()] = rawGame[key]
  1069. }
  1070. }
  1071. const rawAudio = rawGame && rawGame.audio !== undefined ? rawGame.audio : parsed.audio
  1072. const rawTelemetry = rawGame && rawGame.telemetry !== undefined ? rawGame.telemetry : parsed.telemetry
  1073. const rawHaptics = rawGame && rawGame.haptics !== undefined ? rawGame.haptics : parsed.haptics
  1074. const rawUiEffects = rawGame && rawGame.uiEffects !== undefined
  1075. ? rawGame.uiEffects
  1076. : rawGame && rawGame.uieffects !== undefined
  1077. ? rawGame.uieffects
  1078. : rawGame && rawGame.ui !== undefined
  1079. ? rawGame.ui
  1080. : (parsed as Record<string, unknown>).uiEffects !== undefined
  1081. ? (parsed as Record<string, unknown>).uiEffects
  1082. : (parsed as Record<string, unknown>).uieffects !== undefined
  1083. ? (parsed as Record<string, unknown>).uieffects
  1084. : (parsed as Record<string, unknown>).ui
  1085. const rawSession = rawGame && rawGame.session && typeof rawGame.session === 'object' && !Array.isArray(rawGame.session)
  1086. ? rawGame.session as Record<string, unknown>
  1087. : null
  1088. const rawPunch = rawGame && rawGame.punch && typeof rawGame.punch === 'object' && !Array.isArray(rawGame.punch)
  1089. ? rawGame.punch as Record<string, unknown>
  1090. : null
  1091. const rawSequence = rawGame && rawGame.sequence && typeof rawGame.sequence === 'object' && !Array.isArray(rawGame.sequence)
  1092. ? rawGame.sequence as Record<string, unknown>
  1093. : null
  1094. const rawSkip = rawSequence && rawSequence.skip && typeof rawSequence.skip === 'object' && !Array.isArray(rawSequence.skip)
  1095. ? rawSequence.skip as Record<string, unknown>
  1096. : null
  1097. const rawScoring = rawGame && rawGame.scoring && typeof rawGame.scoring === 'object' && !Array.isArray(rawGame.scoring)
  1098. ? rawGame.scoring as Record<string, unknown>
  1099. : null
  1100. const mapRoot = rawMap && typeof rawMap.tiles === 'string'
  1101. ? rawMap.tiles
  1102. : typeof normalized.map === 'string'
  1103. ? normalized.map
  1104. : ''
  1105. const mapMeta = rawMap && typeof rawMap.mapmeta === 'string'
  1106. ? rawMap.mapmeta
  1107. : typeof normalized.mapmeta === 'string'
  1108. ? normalized.mapmeta
  1109. : ''
  1110. if (!mapRoot || !mapMeta) {
  1111. throw new Error('game.json 缺少 map 或 mapmeta 字段')
  1112. }
  1113. const modeValue = typeof normalizedGame.mode === 'string' ? normalizedGame.mode : normalized.gamemode
  1114. const gameMode = parseGameMode(modeValue)
  1115. const rawControlOverrides = rawPlayfield && rawPlayfield.controlOverrides && typeof rawPlayfield.controlOverrides === 'object' && !Array.isArray(rawPlayfield.controlOverrides)
  1116. ? rawPlayfield.controlOverrides as Record<string, unknown>
  1117. : null
  1118. const controlScoreOverrides: Record<string, number> = {}
  1119. const controlContentOverrides: Record<string, GameControlDisplayContentOverride> = {}
  1120. const controlPointStyleOverrides: Record<string, ControlPointStyleEntry> = {}
  1121. if (rawControlOverrides) {
  1122. const keys = Object.keys(rawControlOverrides)
  1123. for (const key of keys) {
  1124. const item = rawControlOverrides[key]
  1125. if (!item || typeof item !== 'object' || Array.isArray(item)) {
  1126. continue
  1127. }
  1128. const scoreValue = Number((item as Record<string, unknown>).score)
  1129. if (Number.isFinite(scoreValue)) {
  1130. controlScoreOverrides[key] = scoreValue
  1131. }
  1132. const rawPointStyle = getFirstDefined(item as Record<string, unknown>, ['pointStyle'])
  1133. const rawPointColor = getFirstDefined(item as Record<string, unknown>, ['pointColorHex'])
  1134. const rawPointSizeScale = getFirstDefined(item as Record<string, unknown>, ['pointSizeScale'])
  1135. const rawPointAccentRingScale = getFirstDefined(item as Record<string, unknown>, ['pointAccentRingScale'])
  1136. const rawPointGlowStrength = getFirstDefined(item as Record<string, unknown>, ['pointGlowStrength'])
  1137. const rawPointLabelScale = getFirstDefined(item as Record<string, unknown>, ['pointLabelScale'])
  1138. const rawPointLabelColor = getFirstDefined(item as Record<string, unknown>, ['pointLabelColorHex'])
  1139. if (
  1140. rawPointStyle !== undefined
  1141. || rawPointColor !== undefined
  1142. || rawPointSizeScale !== undefined
  1143. || rawPointAccentRingScale !== undefined
  1144. || rawPointGlowStrength !== undefined
  1145. || rawPointLabelScale !== undefined
  1146. || rawPointLabelColor !== undefined
  1147. ) {
  1148. const fallbackPointStyle = gameMode === 'score-o'
  1149. ? DEFAULT_COURSE_STYLE_CONFIG.scoreO.controls.default
  1150. : DEFAULT_COURSE_STYLE_CONFIG.sequential.controls.default
  1151. controlPointStyleOverrides[key] = {
  1152. style: parseControlPointStyleId(rawPointStyle, fallbackPointStyle.style),
  1153. colorHex: normalizeHexColor(rawPointColor, fallbackPointStyle.colorHex),
  1154. sizeScale: parsePositiveNumber(rawPointSizeScale, fallbackPointStyle.sizeScale || 1),
  1155. accentRingScale: parsePositiveNumber(rawPointAccentRingScale, fallbackPointStyle.accentRingScale || 0),
  1156. glowStrength: clamp(parseNumber(rawPointGlowStrength, fallbackPointStyle.glowStrength || 0), 0, 1.2),
  1157. labelScale: parsePositiveNumber(rawPointLabelScale, fallbackPointStyle.labelScale || 1),
  1158. labelColorHex: normalizeHexColor(rawPointLabelColor, fallbackPointStyle.labelColorHex || ''),
  1159. }
  1160. }
  1161. const titleValue = typeof (item as Record<string, unknown>).title === 'string'
  1162. ? ((item as Record<string, unknown>).title as string).trim()
  1163. : ''
  1164. const templateRaw = typeof (item as Record<string, unknown>).template === 'string'
  1165. ? ((item as Record<string, unknown>).template as string).trim().toLowerCase()
  1166. : ''
  1167. const templateValue = templateRaw === 'minimal' || templateRaw === 'story' || templateRaw === 'focus'
  1168. ? templateRaw
  1169. : ''
  1170. const bodyValue = typeof (item as Record<string, unknown>).body === 'string'
  1171. ? ((item as Record<string, unknown>).body as string).trim()
  1172. : ''
  1173. const clickTitleValue = typeof (item as Record<string, unknown>).clickTitle === 'string'
  1174. ? ((item as Record<string, unknown>).clickTitle as string).trim()
  1175. : ''
  1176. const clickBodyValue = typeof (item as Record<string, unknown>).clickBody === 'string'
  1177. ? ((item as Record<string, unknown>).clickBody as string).trim()
  1178. : ''
  1179. const autoPopupValue = (item as Record<string, unknown>).autoPopup
  1180. const onceValue = (item as Record<string, unknown>).once
  1181. const priorityNumeric = Number((item as Record<string, unknown>).priority)
  1182. const ctasValue = parseContentCardCtas((item as Record<string, unknown>).ctas)
  1183. const contentExperienceValue = parseContentExperienceOverride((item as Record<string, unknown>).contentExperience, gameConfigUrl)
  1184. const clickExperienceValue = parseContentExperienceOverride((item as Record<string, unknown>).clickExperience, gameConfigUrl)
  1185. const hasAutoPopup = typeof autoPopupValue === 'boolean'
  1186. const hasOnce = typeof onceValue === 'boolean'
  1187. const hasPriority = Number.isFinite(priorityNumeric)
  1188. if (
  1189. templateValue
  1190. || titleValue
  1191. || bodyValue
  1192. || clickTitleValue
  1193. || clickBodyValue
  1194. || hasAutoPopup
  1195. || hasOnce
  1196. || hasPriority
  1197. || ctasValue
  1198. || contentExperienceValue
  1199. || clickExperienceValue
  1200. ) {
  1201. controlContentOverrides[key] = {
  1202. ...(templateValue ? { template: templateValue } : {}),
  1203. ...(titleValue ? { title: titleValue } : {}),
  1204. ...(bodyValue ? { body: bodyValue } : {}),
  1205. ...(clickTitleValue ? { clickTitle: clickTitleValue } : {}),
  1206. ...(clickBodyValue ? { clickBody: clickBodyValue } : {}),
  1207. ...(hasAutoPopup ? { autoPopup: !!autoPopupValue } : {}),
  1208. ...(hasOnce ? { once: !!onceValue } : {}),
  1209. ...(hasPriority ? { priority: Math.max(0, Math.round(priorityNumeric)) } : {}),
  1210. ...(ctasValue ? { ctas: ctasValue } : {}),
  1211. ...(contentExperienceValue ? { contentExperience: contentExperienceValue } : {}),
  1212. ...(clickExperienceValue ? { clickExperience: clickExperienceValue } : {}),
  1213. }
  1214. }
  1215. }
  1216. }
  1217. const rawLegOverrides = rawPlayfield && rawPlayfield.legOverrides && typeof rawPlayfield.legOverrides === 'object' && !Array.isArray(rawPlayfield.legOverrides)
  1218. ? rawPlayfield.legOverrides as Record<string, unknown>
  1219. : null
  1220. const legStyleOverrides: Record<number, CourseLegStyleEntry> = {}
  1221. if (rawLegOverrides) {
  1222. const legKeys = Object.keys(rawLegOverrides)
  1223. for (const rawKey of legKeys) {
  1224. const item = rawLegOverrides[rawKey]
  1225. const index = parseIndexedLegOverrideKey(rawKey)
  1226. if (index === null || !item || typeof item !== 'object' || Array.isArray(item)) {
  1227. continue
  1228. }
  1229. const normalized = normalizeObjectRecord(item)
  1230. const rawStyle = getFirstDefined(normalized, ['style'])
  1231. const rawColor = getFirstDefined(normalized, ['color', 'colorhex'])
  1232. const rawWidthScale = getFirstDefined(normalized, ['widthscale'])
  1233. const rawGlowStrength = getFirstDefined(normalized, ['glowstrength'])
  1234. if (rawStyle === undefined && rawColor === undefined && rawWidthScale === undefined && rawGlowStrength === undefined) {
  1235. continue
  1236. }
  1237. legStyleOverrides[index] = {
  1238. style: parseCourseLegStyleId(rawStyle, DEFAULT_COURSE_STYLE_CONFIG.sequential.legs.default.style),
  1239. colorHex: normalizeHexColor(rawColor, DEFAULT_COURSE_STYLE_CONFIG.sequential.legs.default.colorHex),
  1240. widthScale: parsePositiveNumber(rawWidthScale, DEFAULT_COURSE_STYLE_CONFIG.sequential.legs.default.widthScale || 1),
  1241. glowStrength: clamp(parseNumber(rawGlowStrength, DEFAULT_COURSE_STYLE_CONFIG.sequential.legs.default.glowStrength || 0), 0, 1.2),
  1242. }
  1243. }
  1244. }
  1245. return {
  1246. title: rawApp && typeof rawApp.title === 'string' ? rawApp.title : '',
  1247. appId: rawApp && typeof rawApp.id === 'string' ? rawApp.id : '',
  1248. schemaVersion: typeof parsed.schemaVersion === 'string' ? parsed.schemaVersion : '1',
  1249. version: typeof parsed.version === 'string' ? parsed.version : '',
  1250. mapRoot,
  1251. mapMeta,
  1252. course: rawPlayfieldSource && typeof rawPlayfieldSource.url === 'string'
  1253. ? rawPlayfieldSource.url
  1254. : typeof normalized.course === 'string'
  1255. ? normalized.course
  1256. : null,
  1257. cpRadiusMeters: parsePositiveNumber(
  1258. rawPlayfield && rawPlayfield.CPRadius !== undefined ? rawPlayfield.CPRadius : normalized.cpradius,
  1259. 5,
  1260. ),
  1261. defaultZoom: rawMap && rawMap.initialView && typeof rawMap.initialView === 'object' && !Array.isArray(rawMap.initialView)
  1262. ? parsePositiveNumber((rawMap.initialView as Record<string, unknown>).zoom, 17)
  1263. : null,
  1264. gameMode,
  1265. punchPolicy: parsePunchPolicy(
  1266. rawPunch && rawPunch.policy !== undefined
  1267. ? rawPunch.policy
  1268. : normalizedGame.punchpolicy !== undefined
  1269. ? normalizedGame.punchpolicy
  1270. : normalized.punchpolicy,
  1271. ),
  1272. punchRadiusMeters: parsePositiveNumber(
  1273. rawPunch && rawPunch.radiusMeters !== undefined
  1274. ? rawPunch.radiusMeters
  1275. : normalizedGame.punchradiusmeters !== undefined
  1276. ? normalizedGame.punchradiusmeters
  1277. : normalizedGame.punchradius !== undefined
  1278. ? normalizedGame.punchradius
  1279. : normalized.punchradiusmeters !== undefined
  1280. ? normalized.punchradiusmeters
  1281. : normalized.punchradius,
  1282. 5,
  1283. ),
  1284. requiresFocusSelection: parseBoolean(
  1285. rawPunch && rawPunch.requiresFocusSelection !== undefined
  1286. ? rawPunch.requiresFocusSelection
  1287. : normalizedGame.requiresfocusselection !== undefined
  1288. ? normalizedGame.requiresfocusselection
  1289. : rawPunch && (rawPunch as Record<string, unknown>).requiresfocusselection !== undefined
  1290. ? (rawPunch as Record<string, unknown>).requiresfocusselection
  1291. : normalized.requiresfocusselection,
  1292. false,
  1293. ),
  1294. skipEnabled: parseBoolean(
  1295. rawSkip && rawSkip.enabled !== undefined
  1296. ? rawSkip.enabled
  1297. : normalizedGame.skipenabled !== undefined
  1298. ? normalizedGame.skipenabled
  1299. : normalized.skipenabled,
  1300. false,
  1301. ),
  1302. skipRadiusMeters: parsePositiveNumber(
  1303. rawSkip && rawSkip.radiusMeters !== undefined
  1304. ? rawSkip.radiusMeters
  1305. : normalizedGame.skipradiusmeters !== undefined
  1306. ? normalizedGame.skipradiusmeters
  1307. : normalizedGame.skipradius !== undefined
  1308. ? normalizedGame.skipradius
  1309. : normalized.skipradiusmeters !== undefined
  1310. ? normalized.skipradiusmeters
  1311. : normalized.skipradius,
  1312. 30,
  1313. ),
  1314. skipRequiresConfirm: parseBoolean(
  1315. rawSkip && rawSkip.requiresConfirm !== undefined
  1316. ? rawSkip.requiresConfirm
  1317. : normalizedGame.skiprequiresconfirm !== undefined
  1318. ? normalizedGame.skiprequiresconfirm
  1319. : normalized.skiprequiresconfirm,
  1320. true,
  1321. ),
  1322. autoFinishOnLastControl: parseBoolean(
  1323. rawSession && rawSession.autoFinishOnLastControl !== undefined
  1324. ? rawSession.autoFinishOnLastControl
  1325. : normalizedGame.autofinishonlastcontrol !== undefined
  1326. ? normalizedGame.autofinishonlastcontrol
  1327. : normalized.autofinishonlastcontrol,
  1328. true,
  1329. ),
  1330. controlScoreOverrides,
  1331. controlContentOverrides,
  1332. controlPointStyleOverrides,
  1333. legStyleOverrides,
  1334. defaultControlScore: rawScoring && rawScoring.defaultControlScore !== undefined
  1335. ? parsePositiveNumber(rawScoring.defaultControlScore, 10)
  1336. : null,
  1337. courseStyleConfig: parseCourseStyleConfig(rawGamePresentation),
  1338. trackStyleConfig: parseTrackVisualizationConfig(getFirstDefined(normalizedGamePresentation, ['track'])),
  1339. gpsMarkerStyleConfig: parseGpsMarkerStyleConfig(getFirstDefined(normalizedGamePresentation, ['gpsmarker', 'gps'])),
  1340. telemetryConfig: parseTelemetryConfig(rawTelemetry),
  1341. audioConfig: parseAudioConfig(rawAudio, gameConfigUrl),
  1342. hapticsConfig: parseHapticsConfig(rawHaptics),
  1343. uiEffectsConfig: parseUiEffectsConfig(rawUiEffects),
  1344. declinationDeg: parseDeclinationValue(rawMap && rawMap.declination !== undefined ? rawMap.declination : normalized.declination),
  1345. }
  1346. }
  1347. function parseGameConfigFromYaml(text: string, gameConfigUrl: string): ParsedGameConfig {
  1348. const config: Record<string, string> = {}
  1349. const lines = text.split(/\r?\n/)
  1350. for (const rawLine of lines) {
  1351. const line = rawLine.trim()
  1352. if (!line || line.startsWith('#')) {
  1353. continue
  1354. }
  1355. const match = line.match(/^([A-Za-z0-9_-]+)\s*(?:=|:)\s*(.+)$/)
  1356. if (!match) {
  1357. continue
  1358. }
  1359. config[match[1].trim().toLowerCase()] = match[2].trim()
  1360. }
  1361. const mapRoot = config.map
  1362. const mapMeta = config.mapmeta
  1363. if (!mapRoot || !mapMeta) {
  1364. throw new Error('game.yaml 缺少 map 或 mapmeta 字段')
  1365. }
  1366. const gameMode = parseGameMode(config.gamemode)
  1367. return {
  1368. title: '',
  1369. appId: '',
  1370. schemaVersion: '1',
  1371. version: '',
  1372. mapRoot,
  1373. mapMeta,
  1374. course: typeof config.course === 'string' ? config.course : null,
  1375. cpRadiusMeters: parsePositiveNumber(config.cpradius, 5),
  1376. defaultZoom: null,
  1377. gameMode,
  1378. punchPolicy: parsePunchPolicy(config.punchpolicy),
  1379. punchRadiusMeters: parsePositiveNumber(
  1380. config.punchradiusmeters !== undefined ? config.punchradiusmeters : config.punchradius,
  1381. 5,
  1382. ),
  1383. requiresFocusSelection: parseBoolean(config.requiresfocusselection, false),
  1384. skipEnabled: parseBoolean(config.skipenabled, false),
  1385. skipRadiusMeters: parsePositiveNumber(
  1386. config.skipradiusmeters !== undefined ? config.skipradiusmeters : config.skipradius,
  1387. 30,
  1388. ),
  1389. skipRequiresConfirm: parseBoolean(config.skiprequiresconfirm, true),
  1390. autoFinishOnLastControl: parseBoolean(config.autofinishonlastcontrol, true),
  1391. controlScoreOverrides: {},
  1392. controlContentOverrides: {},
  1393. controlPointStyleOverrides: {},
  1394. legStyleOverrides: {},
  1395. defaultControlScore: null,
  1396. courseStyleConfig: DEFAULT_COURSE_STYLE_CONFIG,
  1397. trackStyleConfig: DEFAULT_TRACK_VISUALIZATION_CONFIG,
  1398. gpsMarkerStyleConfig: DEFAULT_GPS_MARKER_STYLE_CONFIG,
  1399. telemetryConfig: parseTelemetryConfig({
  1400. heartRate: {
  1401. age: config.heartrateage !== undefined ? config.heartrateage : config.telemetryheartrateage,
  1402. restingHeartRateBpm: config.restingheartratebpm !== undefined
  1403. ? config.restingheartratebpm
  1404. : config.restingheartrate !== undefined
  1405. ? config.restingheartrate
  1406. : config.telemetryrestingheartratebpm !== undefined
  1407. ? config.telemetryrestingheartratebpm
  1408. : config.telemetryrestingheartrate,
  1409. },
  1410. }),
  1411. audioConfig: parseAudioConfig({
  1412. enabled: config.audioenabled,
  1413. masterVolume: config.audiomastervolume,
  1414. obeyMuteSwitch: config.audioobeymuteswitch,
  1415. approachDistanceMeters: config.audioapproachdistancemeters !== undefined ? config.audioapproachdistancemeters : config.audioapproachdistance,
  1416. cues: {
  1417. session_started: config.audiosessionstarted,
  1418. 'control_completed:start': config.audiostartcomplete,
  1419. 'control_completed:control': config.audiocontrolcomplete,
  1420. 'control_completed:finish': config.audiofinishcomplete,
  1421. 'punch_feedback:warning': config.audiowarning,
  1422. 'guidance:searching': config.audiosearching,
  1423. 'guidance:approaching': config.audioapproaching,
  1424. 'guidance:ready': config.audioready,
  1425. },
  1426. }, gameConfigUrl),
  1427. hapticsConfig: parseHapticsConfig({
  1428. enabled: config.hapticsenabled,
  1429. cues: {
  1430. session_started: config.hapticsstart,
  1431. session_finished: config.hapticsfinish,
  1432. 'control_completed:start': config.hapticsstartcomplete,
  1433. 'control_completed:control': config.hapticscontrolcomplete,
  1434. 'control_completed:finish': config.hapticsfinishcomplete,
  1435. 'punch_feedback:warning': config.hapticswarning,
  1436. 'guidance:searching': config.hapticssearching,
  1437. 'guidance:approaching': config.hapticsapproaching,
  1438. 'guidance:ready': config.hapticsready,
  1439. },
  1440. }),
  1441. uiEffectsConfig: parseUiEffectsConfig({
  1442. enabled: config.uieffectsenabled,
  1443. cues: {
  1444. session_started: { enabled: config.uistartenabled, punchButtonMotion: config.uistartbuttonmotion },
  1445. session_finished: { enabled: config.uifinishenabled, contentCardMotion: config.uifinishcardmotion },
  1446. 'control_completed:start': { enabled: config.uistartcompleteenabled, contentCardMotion: config.uistartcompletecardmotion, punchFeedbackMotion: config.uistartcompletetoastmotion },
  1447. 'control_completed:control': { enabled: config.uicontrolcompleteenabled, contentCardMotion: config.uicontrolcompletecardmotion, punchFeedbackMotion: config.uicontrolcompletetoastmotion },
  1448. 'control_completed:finish': { enabled: config.uifinishcompleteenabled, contentCardMotion: config.uifinishcompletecardmotion, punchFeedbackMotion: config.uifinishcompletetoastmotion },
  1449. 'punch_feedback:warning': { enabled: config.uiwarningenabled, punchFeedbackMotion: config.uiwarningtoastmotion, punchButtonMotion: config.uiwarningbuttonmotion, durationMs: config.uiwarningdurationms },
  1450. 'guidance:ready': { enabled: config.uireadyenabled, punchButtonMotion: config.uireadybuttonmotion, durationMs: config.uireadydurationms },
  1451. },
  1452. }),
  1453. declinationDeg: parseDeclinationValue(config.declination),
  1454. }
  1455. }
  1456. function parseGameConfig(text: string, gameConfigUrl: string): ParsedGameConfig {
  1457. const trimmedText = text.trim()
  1458. const isJson =
  1459. trimmedText.startsWith('{') ||
  1460. trimmedText.startsWith('[') ||
  1461. /\.json(?:[?#].*)?$/i.test(gameConfigUrl)
  1462. return isJson ? parseGameConfigFromJson(trimmedText, gameConfigUrl) : parseGameConfigFromYaml(trimmedText, gameConfigUrl)
  1463. }
  1464. function extractStringField(text: string, key: string): string | null {
  1465. const pattern = new RegExp(`"${key}"\\s*:\\s*"([^"]+)"`)
  1466. const match = text.match(pattern)
  1467. return match ? match[1] : null
  1468. }
  1469. function extractNumberField(text: string, key: string): number | null {
  1470. const pattern = new RegExp(`"${key}"\\s*:\\s*(-?\\d+(?:\\.\\d+)?)`)
  1471. const match = text.match(pattern)
  1472. if (!match) {
  1473. return null
  1474. }
  1475. const value = Number(match[1])
  1476. return Number.isFinite(value) ? value : null
  1477. }
  1478. function extractNumberArrayField(text: string, key: string): number[] | null {
  1479. const pattern = new RegExp(`"${key}"\\s*:\\s*\\[([^\\]]+)\\]`)
  1480. const match = text.match(pattern)
  1481. if (!match) {
  1482. return null
  1483. }
  1484. const numberMatches = match[1].match(/-?\d+(?:\.\d+)?/g)
  1485. if (!numberMatches || !numberMatches.length) {
  1486. return null
  1487. }
  1488. const values = numberMatches
  1489. .map((item) => Number(item))
  1490. .filter((item) => Number.isFinite(item))
  1491. return values.length ? values : null
  1492. }
  1493. function parseMapMeta(text: string): ParsedMapMeta {
  1494. const tileSizeField = extractNumberField(text, 'tileSize')
  1495. const tileSize = tileSizeField === null ? 256 : tileSizeField
  1496. const minZoom = extractNumberField(text, 'minZoom')
  1497. const maxZoom = extractNumberField(text, 'maxZoom')
  1498. const projectionField = extractStringField(text, 'projection')
  1499. const projection = projectionField === null ? 'EPSG:3857' : projectionField
  1500. const tilePathTemplate = extractStringField(text, 'tilePathTemplate')
  1501. const tileFormatFromField = extractStringField(text, 'tileFormat')
  1502. const boundsValues = extractNumberArrayField(text, 'bounds')
  1503. if (!Number.isFinite(minZoom) || !Number.isFinite(maxZoom) || !tilePathTemplate) {
  1504. throw new Error('meta.json 缺少必要字段')
  1505. }
  1506. let tileFormat = tileFormatFromField || ''
  1507. if (!tileFormat) {
  1508. const extensionMatch = tilePathTemplate.match(/\.([A-Za-z0-9]+)$/)
  1509. tileFormat = extensionMatch ? extensionMatch[1].toLowerCase() : 'png'
  1510. }
  1511. return {
  1512. tileSize,
  1513. minZoom: minZoom as number,
  1514. maxZoom: maxZoom as number,
  1515. projection,
  1516. tileFormat,
  1517. tilePathTemplate,
  1518. bounds: boundsValues && boundsValues.length >= 4
  1519. ? [boundsValues[0], boundsValues[1], boundsValues[2], boundsValues[3]]
  1520. : null,
  1521. }
  1522. }
  1523. function getBoundsCorners(
  1524. bounds: [number, number, number, number],
  1525. projection: string,
  1526. ): { northWest: LonLatPoint; southEast: LonLatPoint; center: LonLatPoint } {
  1527. if (projection === 'EPSG:3857') {
  1528. const minX = bounds[0]
  1529. const minY = bounds[1]
  1530. const maxX = bounds[2]
  1531. const maxY = bounds[3]
  1532. return {
  1533. northWest: webMercatorToLonLat({ x: minX, y: maxY }),
  1534. southEast: webMercatorToLonLat({ x: maxX, y: minY }),
  1535. center: webMercatorToLonLat({ x: (minX + maxX) / 2, y: (minY + maxY) / 2 }),
  1536. }
  1537. }
  1538. if (projection === 'EPSG:4326') {
  1539. const minLon = bounds[0]
  1540. const minLat = bounds[1]
  1541. const maxLon = bounds[2]
  1542. const maxLat = bounds[3]
  1543. return {
  1544. northWest: { lon: minLon, lat: maxLat },
  1545. southEast: { lon: maxLon, lat: minLat },
  1546. center: { lon: (minLon + maxLon) / 2, lat: (minLat + maxLat) / 2 },
  1547. }
  1548. }
  1549. throw new Error(`暂不支持的投影: ${projection}`)
  1550. }
  1551. function buildTileBoundsByZoom(
  1552. bounds: [number, number, number, number] | null,
  1553. projection: string,
  1554. minZoom: number,
  1555. maxZoom: number,
  1556. ): Record<number, TileZoomBounds> {
  1557. const boundsByZoom: Record<number, TileZoomBounds> = {}
  1558. if (!bounds) {
  1559. return boundsByZoom
  1560. }
  1561. const corners = getBoundsCorners(bounds, projection)
  1562. for (let zoom = minZoom; zoom <= maxZoom; zoom += 1) {
  1563. const northWestWorld = lonLatToWorldTile(corners.northWest, zoom)
  1564. const southEastWorld = lonLatToWorldTile(corners.southEast, zoom)
  1565. const minX = Math.floor(Math.min(northWestWorld.x, southEastWorld.x))
  1566. const maxX = Math.ceil(Math.max(northWestWorld.x, southEastWorld.x)) - 1
  1567. const minY = Math.floor(Math.min(northWestWorld.y, southEastWorld.y))
  1568. const maxY = Math.ceil(Math.max(northWestWorld.y, southEastWorld.y)) - 1
  1569. boundsByZoom[zoom] = {
  1570. minX,
  1571. maxX,
  1572. minY,
  1573. maxY,
  1574. }
  1575. }
  1576. return boundsByZoom
  1577. }
  1578. function getProjectionModeText(projection: string): string {
  1579. return `${projection} -> XYZ Tile -> Camera -> Screen`
  1580. }
  1581. export function isTileWithinBounds(
  1582. tileBoundsByZoom: Record<number, TileZoomBounds> | null | undefined,
  1583. zoom: number,
  1584. x: number,
  1585. y: number,
  1586. ): boolean {
  1587. if (!tileBoundsByZoom) {
  1588. return true
  1589. }
  1590. const bounds = tileBoundsByZoom[zoom]
  1591. if (!bounds) {
  1592. return true
  1593. }
  1594. return x >= bounds.minX && x <= bounds.maxX && y >= bounds.minY && y <= bounds.maxY
  1595. }
  1596. export async function loadRemoteMapConfig(gameConfigUrl: string): Promise<RemoteMapConfig> {
  1597. const gameConfigText = await requestText(gameConfigUrl)
  1598. const gameConfig = parseGameConfig(gameConfigText, gameConfigUrl)
  1599. const mapMetaUrl = resolveUrl(gameConfigUrl, gameConfig.mapMeta)
  1600. const mapRootUrl = resolveUrl(gameConfigUrl, gameConfig.mapRoot)
  1601. const courseUrl = gameConfig.course ? resolveUrl(gameConfigUrl, gameConfig.course) : null
  1602. const mapMetaText = await requestText(mapMetaUrl)
  1603. const mapMeta = parseMapMeta(mapMetaText)
  1604. let course: OrienteeringCourseData | null = null
  1605. let courseStatusText = courseUrl ? '路线待加载' : '未配置路线'
  1606. if (courseUrl) {
  1607. try {
  1608. const courseText = await requestText(courseUrl)
  1609. course = parseOrienteeringCourseKml(courseText)
  1610. courseStatusText = `路线已载入 (${course.layers.controls.length} controls)`
  1611. } catch (error) {
  1612. const message = error instanceof Error ? error.message : '未知错误'
  1613. courseStatusText = `路线加载失败: ${message}`
  1614. }
  1615. }
  1616. const defaultZoom = clamp(gameConfig.defaultZoom || 17, mapMeta.minZoom, mapMeta.maxZoom)
  1617. const boundsCorners = mapMeta.bounds ? getBoundsCorners(mapMeta.bounds, mapMeta.projection) : null
  1618. const centerWorldTile = boundsCorners
  1619. ? lonLatToWorldTile(boundsCorners.center, defaultZoom)
  1620. : { x: 0, y: 0 }
  1621. return {
  1622. configTitle: gameConfig.title || '未命名配置',
  1623. configAppId: gameConfig.appId || '',
  1624. configSchemaVersion: gameConfig.schemaVersion || '1',
  1625. configVersion: gameConfig.version || '',
  1626. tileSource: resolveUrl(mapRootUrl, mapMeta.tilePathTemplate),
  1627. minZoom: mapMeta.minZoom,
  1628. maxZoom: mapMeta.maxZoom,
  1629. defaultZoom,
  1630. initialCenterTileX: Math.round(centerWorldTile.x),
  1631. initialCenterTileY: Math.round(centerWorldTile.y),
  1632. projection: mapMeta.projection,
  1633. projectionModeText: getProjectionModeText(mapMeta.projection),
  1634. magneticDeclinationDeg: gameConfig.declinationDeg,
  1635. magneticDeclinationText: formatDeclinationText(gameConfig.declinationDeg),
  1636. tileFormat: mapMeta.tileFormat,
  1637. tileSize: mapMeta.tileSize,
  1638. bounds: mapMeta.bounds,
  1639. tileBoundsByZoom: buildTileBoundsByZoom(mapMeta.bounds, mapMeta.projection, mapMeta.minZoom, mapMeta.maxZoom),
  1640. mapMetaUrl,
  1641. mapRootUrl,
  1642. courseUrl,
  1643. course,
  1644. courseStatusText,
  1645. cpRadiusMeters: gameConfig.cpRadiusMeters,
  1646. gameMode: gameConfig.gameMode,
  1647. punchPolicy: gameConfig.punchPolicy,
  1648. punchRadiusMeters: gameConfig.punchRadiusMeters,
  1649. requiresFocusSelection: gameConfig.requiresFocusSelection,
  1650. skipEnabled: gameConfig.skipEnabled,
  1651. skipRadiusMeters: gameConfig.skipRadiusMeters,
  1652. skipRequiresConfirm: gameConfig.skipRequiresConfirm,
  1653. autoFinishOnLastControl: gameConfig.autoFinishOnLastControl,
  1654. controlScoreOverrides: gameConfig.controlScoreOverrides,
  1655. controlContentOverrides: gameConfig.controlContentOverrides,
  1656. controlPointStyleOverrides: gameConfig.controlPointStyleOverrides,
  1657. legStyleOverrides: gameConfig.legStyleOverrides,
  1658. defaultControlScore: gameConfig.defaultControlScore,
  1659. courseStyleConfig: gameConfig.courseStyleConfig,
  1660. trackStyleConfig: gameConfig.trackStyleConfig,
  1661. gpsMarkerStyleConfig: gameConfig.gpsMarkerStyleConfig,
  1662. telemetryConfig: gameConfig.telemetryConfig,
  1663. audioConfig: gameConfig.audioConfig,
  1664. hapticsConfig: gameConfig.hapticsConfig,
  1665. uiEffectsConfig: gameConfig.uiEffectsConfig,
  1666. }
  1667. }