remoteMapConfig.ts 86 KB

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